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:模板与图标资源生成脚本
This commit is contained in:
2026-09-11 17:48:27 +08:00
parent 50f8a89d51
commit 4f5893f87a
62 changed files with 5565 additions and 1104 deletions
+39 -13
View File
@@ -120,7 +120,7 @@
mode="scaleToFill"
width="100rpx"
height="100rpx"
src="https://qnycdn.mymoyu.top/static/icon/stop.png">
src="/static/editor-icons/stop.png">
</cloud-image>
</view>
@@ -190,7 +190,7 @@
<script>
import { ref, reactive } from 'vue'
import { onShow, onReady, onHide, onUnload } from '@dcloudio/uni-app'
import { onLoad, onShow, onReady, onHide, onUnload } from '@dcloudio/uni-app'
import cloudImage from "@/components/cloud-image.vue"
const uniCloudStorageExtCo = uniCloud.importObject("ext-storage-co")
@@ -204,17 +204,20 @@
const delPopup = ref(null)
const popup = ref(null)
const saveInterval = ref(null)
// 有值表示在编辑一篇已存在的文章(带 id 进入)
const editingId = ref("")
const is_vote = ref("https://qnycdn.mymoyu.top/static/icon/is_vote.png")
const not_image = ref("https://qnycdn.mymoyu.top/static/icon/not_image.png")
// 图标改为本地静态资源:原先的外链依赖第三方域名与签名,失效即整块空白
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: "https://qnycdn.mymoyu.top/static/icon/text.png" },
{ type: "video", text: "视频", src: "https://qnycdn.mymoyu.top/static/icon/video.png" },
{ type: "image", text: "图片", src: "https://qnycdn.mymoyu.top/static/icon/image.png" },
{ type: "vote", text: "投票", src: "https://qnycdn.mymoyu.top/static/icon/vote.png" },
{ type: "title", text: "小标题", src: "https://qnycdn.mymoyu.top/static/icon/title.png" },
{ 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({
@@ -233,23 +236,39 @@
clearTimeout(saveInterval.value)
})
onLoad((options) => {
// 从「我的作品」进入时带 id,用于区分"编辑已有文章"与"新建"
editingId.value = (options && options.id) || ""
})
onReady(() => {
let storedCms = uni.getStorageSync("cms")
if (storedCms && ((storedCms.cmsLst && storedCms.cmsLst.length > 0) || storedCms.title || storedCms.title_html)) {
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(() => {
let storedCms = uni.getStorageSync("cms")
const storedCms = uni.getStorageSync("cms")
if (storedCms) {
Object.assign(cms, storedCms)
}
})
function saveCms() {
uni.setStorageSync("cms", cms)
// 定时保存只在有实际内容时落盘,避免空编辑器把上一次的草稿覆盖成空
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() {
@@ -469,10 +488,16 @@
alertDialog.value.close()
}
// 「编辑草稿」:保留本地草稿(onShow 已把内容灌进 cms),直接进入编辑
async function dialogConfirm() {
alertDialog.value.close()
}
// 「放弃草稿」:清空本地草稿并重置编辑器
async function dialogClose() {
await discardDraft()
}
async function addItem(type) {
if (type === "text") {
addTextItem()
@@ -580,6 +605,7 @@
delPopup,
popup,
saveInterval,
editingId,
is_vote,
not_image,
selectIndex,
+94 -59
View File
@@ -4,13 +4,20 @@
title="预览"
leftText="取消"
left-icon="left"
right-text="完成"
right-text="发布"
backgroundColor="#ffffff"
color="#000000"
@clickRight="success()"
@clickRight="saveArticle(1)"
@clickLeft="back()">
</uni-nav-bar>
<!-- 存草稿不发布:草稿仅在「我的作品」中可见 -->
<view class="draft-bar">
<button class="draft-btn" :loading="saving" :disabled="saving" @click="saveArticle(0)">
存为草稿
</button>
</view>
<view class="v-preview" v-if="temp && temp.temptele_src">
<image :src="temp.temptele_src" alt="" class="img" mode="widthFix" />
</view>
@@ -229,6 +236,7 @@
const popFrom = ref('bottom')
const list = ref(new Array(20).fill(0))
const currentTemplate = ref("")
const saving = ref(false)
const tabbarText = ref([
{ text: "模板" },
@@ -340,63 +348,69 @@
return formatDate(new Date(), 'yyyy-MM-dd HH:mm')
}
async function success() {
let storedCms = uni.getStorageSync("cms")
let id = storedCms._id
let title = storedCms.title
let title_html = storedCms.title_html
let title_delta = storedCms.title_delta
let temp_id = currentTemplate.value
let edit_type = "mobile"
let view_count = 0
let article_status = 1
let user_id = userInfo.value._id
let cmsLst = storedCms.cmsLst
let excerpt = ""
let res = await uniCloud.importObject("user-info").getIP({ uid: userInfo.value._id })
let last_modify_ip = res.code === 200 ? res.data.last_login_ip : ""
/**
* 保存文章
* @param {Number} articleStatus 1 发布,0 存草稿
*/
async function saveArticle(articleStatus) {
if (saving.value) return
if (id) {
let query = {
id,
title: storedCms.title,
user_id,
title_html: storedCms.title_html,
title_delta: storedCms.title_delta,
thumbnail: storedCms.thumbnail,
p_type: storedCms.p_type,
category_id: storedCms.category_id,
temp_id,
edit_type,
view_count,
cmsLst: storedCms.cmsLst,
excerpt,
last_modify_ip,
article_status,
}
await uniCloud.importObject("uni-cms-articles").update_cms_articles(query)
} else {
let query = {
title: storedCms.title,
user_id,
title_html: storedCms.title_html,
title_delta: storedCms.title_delta,
thumbnail: storedCms.thumbnail,
p_type: storedCms.p_type,
category_id: storedCms.category_id,
temp_id,
edit_type,
view_count,
cmsLst: storedCms.cmsLst,
excerpt,
last_modify_ip,
article_status,
}
await uniCloud.importObject("uni-cms-articles").add_cms_articles(query)
const storedCms = uni.getStorageSync("cms")
if (!storedCms) {
return uni.showToast({ title: '内容已丢失,请重新编辑', icon: 'none' })
}
if (!storedCms.title || !storedCms.title.trim()) {
return uni.showToast({ title: '请先填写标题', icon: 'none' })
}
// 草稿允许只有标题,发布必须有正文
const hasContent = Array.isArray(storedCms.cmsLst) && storedCms.cmsLst.length > 0
if (articleStatus === 1 && !hasContent) {
return uni.showToast({ title: '正文不能为空', icon: 'none' })
}
uni.setStorageSync("cms", "")
uni.switchTab({ url: "/pages/my/my" })
saving.value = true
try {
// 归属与 IP 由服务端从登录态与请求上下文获取,这里不再传 user_id
const query = {
title: storedCms.title,
title_html: storedCms.title_html,
title_delta: storedCms.title_delta,
thumbnail: storedCms.thumbnail,
p_type: storedCms.p_type,
category_id: storedCms.category_id,
temp_id: currentTemplate.value,
edit_type: "mobile",
cmsLst: storedCms.cmsLst,
excerpt: storedCms.excerpt || "",
article_status: articleStatus,
}
let res
if (storedCms._id) {
res = await uniCloud.importObject("uni-cms-articles")
.update_cms_articles({ ...query, id: storedCms._id })
} else {
res = await uniCloud.importObject("uni-cms-articles").add_cms_articles(query)
}
if (!res || res.code !== 200) {
throw new Error((res && res.msg) || '保存失败')
}
uni.setStorageSync("cms", "")
uni.showToast({
title: articleStatus === 1 ? '发布成功' : '已存为草稿',
icon: 'none'
})
setTimeout(() => {
uni.switchTab({ url: "/pages/my/my" })
}, 800)
} catch (e) {
console.error('保存文章失败:', e)
uni.showToast({ title: e.message || '保存失败,请重试', icon: 'none' })
} finally {
saving.value = false
}
}
async function back() {
@@ -439,7 +453,8 @@
radioChange,
formatDate,
formatTime,
success,
saving,
saveArticle,
back,
}
}
@@ -447,7 +462,27 @@
</script>
<style lang="scss" scoped>
.draft-bar {
padding: 16rpx 20rpx;
background-color: #fff;
border-bottom: 1rpx solid #eee;
}
.draft-btn {
font-size: 28rpx;
line-height: 72rpx;
height: 72rpx;
color: #c11c1f;
background-color: #fff;
border: 1rpx solid #c11c1f;
border-radius: 36rpx;
&::after {
border: none;
}
}
.grid-container {
display: flex;
gap: 10px;