Files
t/pages2/editCms/editCms.vue
T
root 4f5893f87a fix: 修复首页空白/失效外链/云函数越权,补齐缺失页面与种子数据
## 阻断性缺陷
- list.vue 是 0 字节空文件、slist.vue 与 search/search.vue 从未存在,
  而前者是 tabBar 首页、后者是 tabBar「搜索」页 —— 开屏即白屏。
  按 .nvue 原型与详情页契约重建三页(CSS 渐变主视觉、分类筛选、搜索历史/热搜/联想)。
- parse-image-url.js 对空封面调 undefined.startsWith 直接抛错,列表页整页崩。
- 云函数目录缺 uni-cms-articles / uni-cms-categories / uni-cms-unlock-record
  schema 与 schema.ext.js,线上内容渲染与解锁逻辑无配置可用。

## 越权与数据一致性
- uni-cms-articles:del/update/add 全部无鉴权,未登录即可删任意文章、
  改他人文章作者与阅读量。补 login + 作者归属校验,作者与计数改为服务端取值。
- comments.likeComment/relikeComment:直接采信客户端传入的 user_id,
  可冒名点赞刷计数。改为以令牌为准,并纳入事务。
- comments.updateComment:对数组取 .author_id,权限判断恒失败;
  字段名 updateTime 与 ip_location 类型与 schema 不符。
- comments.deleteComment:`!root_id === 0` 优先级错误导致计数恒不减;
  且误更新 uni-cms-articles、按不存在的 type 字段删点赞明细产生孤儿数据。
- cms-articles-like/collect:查重条件混入本次请求时间戳,防重永远失效,
  可无限重复刷计数;补唯一索引并事务化。
- cms-vote:读-改-写票数导致并发丢票,记录与统计非原子;改为事务 + 原子自增。
- cms-articles-log:忽略传入 user_id 直接返回全表,泄露全站浏览记录。
- article_info:get() 使用未定义变量必崩;读接口全部无鉴权。
- user-info:公开资料接口可查任意用户 last_login_ip。

## 资源与数据
- 全项目清空失效的签名外链(expire_at 均为 2025-03,必然 403),
  改为本地生成资源:6 套文章模板、8 个编辑器图标、2 张文章配图。
- 新增分类 / 模板 / 礼物 / 热搜词种子数据,并在 db_init.json 登记,
  同时补上点赞、收藏、投票、浏览日志的唯一索引。

## 功能
- 草稿箱:预览页拆出「发布」与「存为草稿」,作品列表按状态筛选并显示徽标。
  原实现有 4 个 tab 但只有 1 个有内容,且 article_status 在 UI 上无体现。
- 编辑中断恢复:接上原本空实现的「编辑草稿」回调,区分新建与编辑已有文章。

## 工具
- tools/audit-project.js:编码 / 页面路由 / 云调用 / 云函数鉴权 / 敏感信息检查
- tools/check-vue.js:SFC 脚本语法(词法扫描处理 import·export 与条件编译)
- tools/verify.js:一键验证;两个检查器各带自测,防止"永远通过"
- tools/gen-*.py:模板与图标资源生成脚本
2026-09-11 17:48:27 +08:00

872 lines
21 KiB
Vue

<template>
<view class="b_col_eee body">
<uni-nav-bar
leftText="取消"
left-icon="left"
right-text="下一步"
backgroundColor="#ffffff"
color="#000000"
@clickRight="toEditSetting()"
@clickLeft="back()">
</uni-nav-bar>
<uni-popup
ref="alertDialog"
type="dialog">
<uni-popup-dialog
type="info"
cancelText="放弃草稿"
confirmText="编辑草稿"
title="提示"
content="文章有草稿未完成,是否继续编辑草稿?"
@confirm="dialogConfirm"
@close="dialogClose">
</uni-popup-dialog>
</uni-popup>
<uni-popup ref="del_popup" type='bottom'>
<view class="del-popup">
<view class="margin_20_top padding_20 color-666 b_col_fff">
确认要删除当前文章段落
</view>
<view class="margin_4 padding_20 color-red b_col_fff" @click="delItem()">
<uni-icons type="trash" size="30" color="#f00"></uni-icons>
删除
</view>
<view class="text-center padding_20 margin_10_top b_col_fff" @click="close_del_popup()">
取消
</view>
</view>
</uni-popup>
<uni-popup ref="popup" type='bottom'>
<view class="b_col_fff">
<view class="grid-container">
<view v-for="(item, index) in cmsType" :key="index" :class="{
'grid-item': true,
}" @click="addItem(item.type)">
<view class="grid-item-box">
<image :src="item.src" class="image" mode="aspectFill" />
<text class="text">
{{ item.text }}
</text>
</view>
</view>
</view>
</view>
</uni-popup>
<view
@click="toTitleStyle()"
class="margin_50 b_col_fff padding_10"
style="display: flex; align-items: center; gap: 20rpx;">
<uni-easyinput
:inputBorder="false"
disabled
v-model="cms.title"
placeholder="请输入标题"
style="flex: 1;"
></uni-easyinput>
<view class="border_ff0" style="white-space: nowrap;">
<uni-icons type="gear" size="20" color="993300"></uni-icons>
标题样式
</view>
</view>
<view class="container">
<button class="add-button" @click="addEditItem(0)"></button>
</view>
<view v-for="(item, index) in cms.cmsLst" :key="index">
<view class="margin_20 b_col_fff edit_item padding_10">
<view class="part part1">
<view v-if="item.type === 'image'" @click="updateImage(index)">
<cloud-image
mode="scaleToFill"
width="100%"
height="200rpx"
v-if="item.image.src"
:src="item.image.src">
</cloud-image>
<view class="not-image" v-else>
<cloud-image
mode="scaleToFill"
width="100%"
height="150rpx"
:src="not_image">
</cloud-image>
<view class="not-font">
添加图片
</view>
</view>
</view>
<view class="video-container"
v-if="item.type === 'video'"
@click="updateVideoItem(index)">
<cloud-image
v-if="item.video.poster"
mode="scaleToFill"
width="100%"
height="200rpx"
:src="item.video.poster">
</cloud-image>
<cloud-image
class="icon-play"
mode="scaleToFill"
width="100rpx"
height="100rpx"
src="/static/editor-icons/stop.png">
</cloud-image>
</view>
<view
v-if="item.type === 'vote'">
<view class="not-image">
<view class="not-image-icon">
<cloud-image
mode="scaleToFill"
width="100%"
height="150rpx"
:src="is_vote">
</cloud-image>
</view>
<view class="not-font">
投票
</view>
</view>
</view>
</view>
<view class="part part2" @click="to(item.type, index)">
<view class="" v-if="item.type === 'vote'">
<view v-if="item.text">
{{ item.text }}
</view>
<view class="font-info" v-else>
点击添加文字
</view>
</view>
<view class="" v-else>
<view v-if="item.text">
{{ item.text }}
</view>
<view class="font-info" v-else>
点击添加文字
</view>
</view>
</view>
<view class="part part3">
<uni-icons
type="closeempty"
class="sub-area"
@click="open_del_popup(index)">
</uni-icons>
<uni-icons
type="bars"
class="sub-area">
</uni-icons>
<view class="sub-area item-no">
{{ index + 1 }}
</view>
</view>
</view>
<view class="container">
<button
class="add-button"
@click="addEditItem(index + 1)">
</button>
</view>
</view>
</view>
</template>
<script>
import { ref, reactive } from 'vue'
import { onLoad, onShow, onReady, onHide, onUnload } from '@dcloudio/uni-app'
import cloudImage from "@/components/cloud-image.vue"
const uniCloudStorageExtCo = uniCloud.importObject("ext-storage-co")
export default {
components: {
cloudImage
},
setup() {
const alertDialog = ref(null)
const delPopup = ref(null)
const popup = ref(null)
const saveInterval = ref(null)
// 有值表示在编辑一篇已存在的文章(带 id 进入)
const editingId = ref("")
// 图标改为本地静态资源:原先的外链依赖第三方域名与签名,失效即整块空白
const is_vote = ref("/static/editor-icons/is_vote.png")
const not_image = ref("/static/editor-icons/not_image.png")
const selectIndex = ref(0)
const cmsType = ref([
{ type: "text", text: "文字", src: "/static/editor-icons/text.png" },
{ type: "video", text: "视频", src: "/static/editor-icons/video.png" },
{ type: "image", text: "图片", src: "/static/editor-icons/image.png" },
{ type: "vote", text: "投票", src: "/static/editor-icons/vote.png" },
{ type: "title", text: "小标题", src: "/static/editor-icons/title.png" },
])
const cms = reactive({
title_html: "",
title_delta: {},
title: "",
cmsLst: []
})
onHide(() => {
clearInterval(saveInterval.value)
})
onUnload(() => {
clearInterval(saveInterval.value)
clearTimeout(saveInterval.value)
})
onLoad((options) => {
// 从「我的作品」进入时带 id,用于区分"编辑已有文章"与"新建"
editingId.value = (options && options.id) || ""
})
onReady(() => {
const storedCms = uni.getStorageSync("cms")
const hasDraft = storedCms && (
(storedCms.cmsLst && storedCms.cmsLst.length > 0) ||
storedCms.title ||
storedCms.title_html
)
// 从「我的作品」带 id 进来是编辑已有文章,不是未完成的草稿,
// 此时弹"草稿未完成"会误导用户。只有新建流程才提示。
const editingExisting = !!(editingId && editingId.value)
if (hasDraft && !editingExisting) {
alertDialog.value.open()
}
saveInterval.value = setInterval(saveCms, 10000)
})
onShow(() => {
const storedCms = uni.getStorageSync("cms")
if (storedCms) {
Object.assign(cms, storedCms)
}
})
function saveCms() {
// 定时保存只在有实际内容时落盘,避免空编辑器把上一次的草稿覆盖成空
const hasContent = (cms.cmsLst && cms.cmsLst.length > 0) || cms.title || cms.title_html
if (!hasContent) return
uni.setStorageSync("cms", JSON.parse(JSON.stringify(cms)))
}
function open() {
popup.value.open()
}
function close() {
popup.value.close()
}
function confirm(value) {
popup.value.close()
}
function addEditItem(index) {
selectIndex.value = index
uni.setStorageSync("selectIndex", selectIndex.value)
open()
}
function getFileInfo(path) {
const parts = path.split('/').pop()
const [filename, ...extParts] = parts.split('.')
const ext = extParts.length ? extParts.pop() : ''
return { name: filename, extname: ext }
}
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
if (i >= sizes.length) return '> 1 TB'
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
async function updateImage(index) {
uni.chooseImage({
sourceType: ["album", "camera"],
count: 1,
success: async (res) => {
if (cms.cmsLst[index] && cms.cmsLst[index].image.src) {
await uniCloudStorageExtCo.deleteFile({ fileList: [cms.cmsLst[index].image.src] })
}
if (res.tempFilePaths.length > 0) {
let filePath = res.tempFilePaths[0]
let { name, extname } = getFileInfo(filePath)
const uploadFileOptionsRes = await uniCloudStorageExtCo.getUploadFileOptions({
cloudPath: `cms/image/${name}_${Math.random().toString(36).substr(2,6)}`
})
await uniCloud.uploadFile({
...uploadFileOptionsRes.uploadFileOptions,
filePath: filePath,
cloudPath: uploadFileOptionsRes.cloudPath,
success: async () => {
const res2 = {
cloudPath: uploadFileOptionsRes.cloudPath,
fileID: uploadFileOptionsRes.fileID,
fileURL: uploadFileOptionsRes.fileURL
}
const selectedMedia = { src: res2.fileID, alt: name }
cms.cmsLst[index].image.src = selectedMedia.src
},
fail: (err) => {
console.log("上传失败", err)
}
})
}
}
})
}
async function delItem() {
let index = selectIndex.value
if (index >= 0 && index < cms.cmsLst.length) {
if (cms.cmsLst[index].type === "image" && cms.cmsLst[index].image.src) {
await uniCloudStorageExtCo.deleteFile({ fileList: [cms.cmsLst[index].image.src] })
} else if (cms.cmsLst[index].type === "video" && (cms.cmsLst[index].video.src || cms.cmsLst[index].video.poster)) {
await uniCloudStorageExtCo.deleteFile({
fileList: [cms.cmsLst[index].video.src, cms.cmsLst[index].video.poster]
})
}
cms.cmsLst.splice(index, 1)
}
delPopup.value.close()
}
async function addImageItem() {
uni.chooseImage({
sourceType: ["album", "camera"],
count: 9,
success: async (res) => {
if (res.tempFilePaths.length > 0) {
for (let i = 0; i < res.tempFilePaths.length; i++) {
let filePath = res.tempFilePaths[i]
let { name, extname } = getFileInfo(filePath)
const uploadFileOptionsRes = await uniCloudStorageExtCo.getUploadFileOptions({
cloudPath: `cms/image/${name}_${Math.random().toString(36).substr(2,6)}`
})
await uniCloud.uploadFile({
...uploadFileOptionsRes.uploadFileOptions,
filePath: filePath,
cloudPath: uploadFileOptionsRes.cloudPath,
success: async () => {
const res2 = {
cloudPath: uploadFileOptionsRes.cloudPath,
fileID: uploadFileOptionsRes.fileID,
fileURL: uploadFileOptionsRes.fileURL
}
const selectedMedia = { src: res2.fileID, alt: name }
let item = {
type: "image",
text: "",
image: { src: selectedMedia.src, html: "", delta: {} }
}
cms.cmsLst.splice(selectIndex.value + i, 0, item)
close()
},
fail: (err) => {
console.log("上传失败", err)
}
})
}
}
}
})
}
function back() {
saveCms()
try {
uni.navigateBack({ delta: 1 })
} catch (e) {
uni.switchTab({ url: "/uni_modules/uni-cms-article/pages/list/list" })
}
}
function toeditText(index) {
if (index >= 0) {
uni.setStorageSync("selectIndex", index)
}
saveCms()
uni.navigateTo({ url: "/pages2/editCms/editText" })
}
function open_del_popup(index) {
selectIndex.value = index
delPopup.value.open()
}
function close_del_popup() {
delPopup.value.close()
}
function toTitleStyle() {
saveCms()
uni.navigateTo({ url: "/pages2/editCms/titleStyle" })
}
function toVote(index) {
if (index >= 0) {
uni.setStorageSync("selectIndex", index)
}
saveCms()
uni.navigateTo({ url: "/pages2/editCms/vote?type=edit" })
}
function to(type, index) {
if (type === 'vote') {
toVote(index)
} else {
toeditText(index)
}
}
function toEditSetting() {
saveCms()
uni.navigateTo({ url: "/pages2/editCms/editSetting" })
}
function addTextItem() {
cms.cmsLst.splice(selectIndex.value, 0, {
type: "text",
text: "",
image: { src: "", html: "", delta: {} },
html: "",
delta: {}
})
toeditText()
}
function addVoteItem() {
saveCms()
uni.navigateTo({ url: "/pages2/editCms/vote?type=add" })
}
function addTitleItem() {
saveCms()
uni.navigateTo({ url: "/pages2/editCms/smallTitle" })
}
async function dialogClose() {
let storedCms = uni.getStorageSync("cms")
if (storedCms && storedCms.cmsLst) {
for (let i = 0; i < storedCms.cmsLst.length; i++) {
if (storedCms.cmsLst[i].type === "image" && storedCms.cmsLst[i].image && storedCms.cmsLst[i].image.src) {
await uniCloudStorageExtCo.deleteFile({ fileList: [storedCms.cmsLst[i].image.src] })
} else if (storedCms.cmsLst[i].type === "video" && storedCms.cmsLst[i].video) {
let fileList = [storedCms.cmsLst[i].video.src].filter(Boolean)
if (storedCms.cmsLst[i].video.poster) fileList.push(storedCms.cmsLst[i].video.poster)
if (fileList.length) await uniCloudStorageExtCo.deleteFile({ fileList })
}
}
}
await uni.setStorageSync("cms", { cmsLst: [], title: "", title_html: "", title_delta: {} })
Object.assign(cms, uni.getStorageSync("cms"))
alertDialog.value.close()
}
// 「编辑草稿」:保留本地草稿(onShow 已把内容灌进 cms),直接进入编辑
async function dialogConfirm() {
alertDialog.value.close()
}
// 「放弃草稿」:清空本地草稿并重置编辑器
async function dialogClose() {
await discardDraft()
}
async function addItem(type) {
if (type === "text") {
addTextItem()
} else if (type === "image") {
await addImageItem()
} else if (type === "video") {
await addVideoItem()
} else if (type === "title") {
addTitleItem()
} else if (type === "vote") {
addVoteItem()
}
}
async function addVideoItem() {
uni.chooseVideo({
sourceType: ['album', 'camera'],
success: async (res) => {
let filePath = res.tempFilePath
let { name, extname } = getFileInfo(filePath)
let duration = res.duration
const uploadFileOptionsRes = await uniCloudStorageExtCo.getUploadFileOptions({
cloudPath: `cms/video/${name}_${Math.random().toString(36).substr(2,6)}`
})
await uniCloud.uploadFile({
...uploadFileOptionsRes.uploadFileOptions,
filePath: filePath,
cloudPath: uploadFileOptionsRes.cloudPath,
success: async () => {
const res2 = {
cloudPath: uploadFileOptionsRes.cloudPath,
fileID: uploadFileOptionsRes.fileID,
fileURL: uploadFileOptionsRes.fileURL
}
const selectedMedia = { src: res2.fileURL, alt: name }
let poster = "vframe/jpg/offset/0/w/440/h/264/"
let cover = res2.fileURL + "?" + poster
let item = {
type: "video",
text: "",
video: { src: selectedMedia.src, poster: cover, duration: duration, html: "", delta: {} }
}
cms.cmsLst.splice(selectIndex.value, 0, item)
close()
},
fail: (err) => {
console.log("上传失败", err)
}
})
}
})
}
async function updateVideoItem(index) {
uni.chooseVideo({
sourceType: ['album', 'camera'],
success: async (res) => {
if (cms.cmsLst[index] && cms.cmsLst[index].video.src) {
await uniCloudStorageExtCo.deleteFile({
fileList: [cms.cmsLst[index].video.src, cms.cmsLst[index].video.poster]
})
}
let filePath = res.tempFilePath
let { name, extname } = getFileInfo(filePath)
let duration = res.duration
const uploadFileOptionsRes = await uniCloudStorageExtCo.getUploadFileOptions({
cloudPath: `cms/video/${name}_${Math.random().toString(36).substr(2,6)}`
})
await uniCloud.uploadFile({
...uploadFileOptionsRes.uploadFileOptions,
filePath: filePath,
cloudPath: uploadFileOptionsRes.cloudPath,
success: async () => {
const res2 = {
cloudPath: uploadFileOptionsRes.cloudPath,
fileID: uploadFileOptionsRes.fileID,
fileURL: uploadFileOptionsRes.fileURL
}
const selectedMedia = { src: res2.fileURL, alt: name }
let poster = "vframe/jpg/offset/0/w/440/h/264/"
let cover = res2.fileURL + "?" + poster
let { text, html, delta } = {
text: cms.cmsLst[index].text,
html: cms.cmsLst[index].video.html,
delta: cms.cmsLst[index].video.delta
}
let item = {
type: "video",
text: text,
video: { src: selectedMedia.src, poster: cover, duration: duration, html: html, delta: delta }
}
cms.cmsLst[index] = item
close()
},
fail: (err) => {
console.log("上传失败", err)
}
})
}
})
}
return {
alertDialog,
delPopup,
popup,
saveInterval,
editingId,
is_vote,
not_image,
selectIndex,
cmsType,
cms,
saveCms,
open,
close,
confirm,
addEditItem,
getFileInfo,
formatBytes,
updateImage,
delItem,
addImageItem,
back,
toeditText,
open_del_popup,
close_del_popup,
toTitleStyle,
toVote,
to,
toEditSetting,
addTextItem,
addVoteItem,
addTitleItem,
dialogClose,
dialogConfirm,
addItem,
addVideoItem,
updateVideoItem
}
}
}
</script>
<style lang="scss" scoped>
.b_col_fff {
background-color: #fff;
}
.b_col_eee {
background-color: #eee;
}
.body {
width: 100vw;
min-height: 100vh;
}
.margin_20 {
margin-top: 20rpx;
margin-bottom: 20rpx;
}
.margin_10_top {
margin-top: 10rpx;
}
.margin_10 {
margin-top: 10rpx;
margin-bottom: 10rpx;
}
.container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.add-button {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
border: 2rpx solid #ccc;
background-color: #eee;
cursor: pointer;
position: relative;
transition: transform 0.3s ease;
}
.add-button::before,
.add-button::after {
content: '';
position: absolute;
background-color: #ccc;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.add-button::before {
width: 24rpx;
height: 4rpx;
}
.color-red {
color: #f00;
}
.add-button::after {
width: 4rpx;
height: 24rpx;
}
.add-button:active {
transform: scale(0.95);
}
.edit_item {
width: 100wv;
height: 200rpx;
display: flex;
.part {
height: 100%;
&.part1 {
flex: 0 0 30%;
}
&.part2 {
flex: 0 0 63%;
display: flex;
flex-direction: column;
}
&.part3 {
flex: 0 0 7%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
.sub-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.item-no {
background-color: #ccc;
color: #fff;
text-align: center;
width: 1.5rem;
height: 1.5rem;
}
}
}
}
.margin_20_top {
margin-top: 20rpx;
}
.padding_10 {
padding: 20rpx;
}
.padding_20 {
padding: 20rpx;
}
.font-info {
color: #666;
}
.grid-container {
.grid-item {
width: calc(98% / 3);
}
display: flex;
flex-wrap: wrap;
box-sizing: border-box;
padding: 10rpx;
width: 100%;
}
.grid-item-box {
flex: 1;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
align-items: center;
justify-content: center;
padding: 15px 0;
}
.margin_4 {
margin-top: 4rpx;
margin-bottom: 4rpx;
}
.image {
width: 50rpx;
height: 50rpx;
}
.not-image {
background-color: #eee;
width: 100%;
height: 200rpx;
text-align: center;
color: #fff;
flex-direction: column;
justify-content: center;
align-items: center;
.not-image-icon {
justify-content: center;
align-items: center;
text-align: center;
}
}
.not-font {
background-color: #7a7e83;
}
.margin_50 {
margin-top: 50rpx;
margin-bottom: 30rpx;
}
.del-popup {
background-color: #ccc;
border-radius: 20rpx 0rpx;
font-size: 40rpx;
}
.text-center {
text-align: center;
}
.margin_30_left {
margin-left: 30rpx;
}
.color-eee {
color: #eee;
}
.color-666 {
color: #666;
}
.border_ff0 {
border: 2rpx solid #993300;
background-color: #ff0;
border-radius: 10rpx;
padding: 10rpx;
}
.icon-play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 50rpx;
color: rgba(255, 255, 255, 0.8);
}
.video-container {
position: relative;
width: 100%;
height: 200rpx;
}
</style>