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
+109 -43
View File
@@ -15,7 +15,7 @@
<view class="">
<uni-list>
<uni-list-item
@click="editItem(selectIndex)"
@click="editItem(selectItem)"
:show-extra-icon="true"
:extra-icon="iconList.edit"
clickable="true"
@@ -23,7 +23,7 @@
</uni-list>
<uni-list>
<uni-list-item
@click="settingItem(selectIndex)"
@click="settingItem()"
:show-extra-icon="true"
:extra-icon="iconList.setting"
clickable="true"
@@ -31,7 +31,7 @@
</uni-list>
<uni-list>
<uni-list-item
@click="deleteItem(selectIndex)"
@click="deleteItem()"
:show-extra-icon="true"
:extra-icon="iconList.delete"
clickable="true"
@@ -45,11 +45,10 @@
</u-popup>
<view class="" v-if="current === 1">
<view class="container" style="display: flex;flex-wrap: wrap;">
<view class=""
style="box-sizing: border-box;width: 50%;border-radius: 12rpx;" v-for="(item, index) in cms_list">
<uni-card margin="5" spacing="0" @click="editItem(index)">
<view class="list-wrap">
<view class="container" v-if="filteredList.length">
<view class="card-wrap" v-for="item in filteredList" :key="item._id">
<uni-card margin="5" spacing="0" @click="editItem(item)">
<cloud-image
width="100%"
height="200rpx"
@@ -57,22 +56,20 @@
:src="Array.isArray(item.thumbnail) && item.thumbnail.length > 0?item.thumbnail[0]:''">
</cloud-image>
<text class="uni-body">
{{ item.title }}
{{ item.title || '(无标题)' }}
</text>
<view
slot="actions"
class="card-actions">
<view
class="card-actions-item">
<text
class="card-actions-item-text">
阅读量 {{ item.view_count }}
<view slot="actions" class="card-actions">
<view class="card-actions-item">
<text class="status-tag" :class="item.article_status === 1 ? 'published' : 'draft'">
{{ item.article_status === 1 ? '已发布' : '草稿' }}
</text>
</view>
<view
class="card-actions-item"
@click.stop="selectMore(index)">
<view class="card-actions-item">
<text class="card-actions-item-text">
阅读 {{ item.view_count || 0 }}
</text>
</view>
<view class="card-actions-item" @click.stop="selectMore(item)">
<uni-icons type="more-filled" size="18" color="#999"></uni-icons>
</view>
</view>
@@ -80,6 +77,9 @@
</view>
</view>
<view class="empty" v-else>
<text class="empty-text">{{ emptyText }}</text>
</view>
</view>
</view>
</template>
@@ -98,12 +98,14 @@
uPopup
},
setup() {
const list = ref(['动态', '作品', '相册', '收藏'])
const current = ref(1)
// 只保留真实存在的两种状态:草稿(article_status=0)与已发布(=1)。
// 原实现有 4 个 tab,但只有第 2 个有内容,其余点开是空白。
const list = ref(['已发布', '草稿'])
const current = ref(0)
const show = ref(false)
const selectIndex = ref(0)
const selectItem = ref(null)
const cms_list = ref([])
const iconList = {
delete: { color: '#f55', size: '22', type: 'closeempty' },
setting: { color: '#888', size: '22', type: 'gear-filled' },
@@ -112,6 +114,16 @@
const userInfo = computed(() => store.userInfo)
// 0 = 已发布,1 = 草稿
const filteredList = computed(() => {
const want = current.value === 1 ? 0 : 1
return cms_list.value.filter((it) => (it.article_status || 0) === want)
})
const emptyText = computed(() =>
current.value === 1 ? '还没有草稿' : '还没有发布过文章'
)
onMounted(async () => {
if (!userInfo.value._id) {
uni.showToast({ title: '请先登录', icon: 'none' })
@@ -135,9 +147,10 @@
current.value = index
}
function selectMore(index) {
// 按对象引用定位,避免列表经过筛选后下标与原始数组错位
function selectMore(item) {
show.value = true
selectIndex.value = index
selectItem.value = item
}
function close() {
@@ -148,19 +161,31 @@
show.value = true
}
async function deleteItem(index) {
let res = await uniCloud.importObject("uni-cms-articles").del_cms_articles({ id: cms_list.value[index]._id })
if (res.code === 200) {
cms_list.value.splice(index, 1)
}
async function deleteItem() {
const target = selectItem.value
if (!target) return
close()
try {
const res = await uniCloud.importObject("uni-cms-articles")
.del_cms_articles({ id: target._id })
if (res && res.code === 200) {
const idx = cms_list.value.findIndex((it) => it._id === target._id)
if (idx !== -1) cms_list.value.splice(idx, 1)
uni.showToast({ title: '已删除', icon: 'none' })
} else {
uni.showToast({ title: (res && res.msg) || '删除失败', icon: 'none' })
}
} catch (e) {
console.error('删除文章失败:', e)
uni.showToast({ title: '删除失败,请重试', icon: 'none' })
}
}
async function editItem(index) {
let cms = cms_list.value[index]
if (!cms) return
uni.setStorageSync("cms", cms)
await toCmsEdit(cms._id)
async function editItem(item) {
if (!item) return
uni.setStorageSync("cms", item)
await toCmsEdit(item._id)
}
async function toCmsEdit(id) {
@@ -169,10 +194,11 @@
})
}
function settingItem(index) {
let cms = cms_list.value[index]
if (!cms) return
uni.setStorageSync("cms", cms)
function settingItem() {
const target = selectItem.value
if (!target) return
uni.setStorageSync("cms", target)
close()
uni.navigateTo({ url: "/pages2/editCms/editSetting" })
}
@@ -180,8 +206,10 @@
list,
current,
show,
selectIndex,
selectItem,
cms_list,
filteredList,
emptyText,
iconList,
userInfo,
sectionChange,
@@ -201,11 +229,49 @@
.page {
padding: 20rpx;
}
.list-wrap {
min-height: 40vh;
}
.container {
display: flex;
flex-wrap: wrap;
overflow: hidden;
}
.card-wrap {
box-sizing: border-box;
width: 50%;
border-radius: 12rpx;
}
.status-tag {
font-size: 22rpx;
padding: 2rpx 12rpx;
border-radius: 20rpx;
}
.status-tag.published {
color: #18b566;
background-color: rgba(24, 181, 102, 0.1);
}
.status-tag.draft {
color: #f29100;
background-color: rgba(242, 145, 0, 0.12);
}
.empty {
padding: 160rpx 0;
text-align: center;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
.custom-cover {
flex: 1;
flex-direction: row;