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
@@ -0,0 +1,286 @@
<template>
<view class="pages">
<!-- 主视觉:纯 CSS 渐变,不依赖外部图片,避免签名链接过期后整块空白 -->
<view class="hero">
<view class="hero-title">军歌嘹亮</view>
<view class="hero-subtitle">军旅故事 · 老兵记忆</view>
<view class="btn-area">
<view class="red-btn-area">
<button class="red-btn" @click="btn(0)">老兵直播</button>
<button class="red-btn" @click="btn(1)">老兵故事</button>
<button class="red-btn" @click="btn(2)">在线注册</button>
</view>
<view class="red-btn-plain-area">
<view class="red-btn-plain" v-for="tag in quickTags" :key="tag._id"
@click="goCategory(tag.value)">{{ tag.text }}</view>
</view>
</view>
</view>
<view class="list-wrapper">
<unicloud-db ref='udb' v-slot:default="{ pagination, hasMore, loading, error, options }"
@error="onqueryerror" :collection="colList" :page-size="10" orderby="publish_date desc"
@load="listLoad">
<scroll-view scroll-y class="uni-list" refresher-enabled
:refresher-triggered="loadType === 'refresh'" @refresherrefresh="refresh"
@scrolltolower="loadMore">
<!-- 列表渲染 -->
<template v-for="item in listData" :key="item._id">
<not-cover v-if="item.thumbnail && item.thumbnail.length === 0" :data="getNewItem(item)">
</not-cover>
<right-small-cover v-else-if="item.thumbnail && item.thumbnail.length === 1"
:data="getNewItem(item)"></right-small-cover>
<three-cover v-else-if="item.thumbnail && item.thumbnail.length === 3" :data="getNewItem(item)">
</three-cover>
</template>
<!-- 加载状态:上拉加载更多,加载中,没有更多数据了,加载错误 -->
<uni-load-state @networkResume="refresh"
:state="{ data: listData, pagination, hasMore, loading, error }" @loadMore="loadMore">
</uni-load-state>
</scroll-view>
</unicloud-db>
</view>
<uni-popup ref="inputDialog" type="dialog">
<uni-popup-dialog type="info" cancelText="关闭" confirmText="继续" title="提示" content="该功能还在开发中!"
@confirm="dialogConfirm" @close="dialogClose">
</uni-popup-dialog>
</uni-popup>
</view>
</template>
<script>
import {
store
} from '@/pages3/uni_modules/uni-id-pages/common/store.js'
import notCover from "@/uni_modules/uni-cms-article/components/list-template/not-cover.vue";
import rightSmallCover from "@/uni_modules/uni-cms-article/components/list-template/right-small-cover.vue";
import threeCover from "@/uni_modules/uni-cms-article/components/list-template/three-cover.vue";
import {
parseImageUrl
} from "@/uni_modules/uni-cms-article/common/parse-image-url";
const db = uniCloud.database();
const articleDBName = 'uni-cms-articles'
const userDBName = 'uni-id-users'
export default {
components: {
notCover,
rightSmallCover,
threeCover,
},
computed: {
userInfo() {
return store.userInfo
},
// 连表查询,返回两个集合的查询结果
colList() {
return [
db.collection(articleDBName).where({
article_status: 1
}).field('thumbnail,title,publish_date,user_id,excerpt').getTemp(), // 文章集合
db.collection(userDBName).field('_id,nickname').getTemp() // 用户集合
]
}
},
data() {
return {
listData: [], // 列表数据
quickTags: [], // 首屏快捷分类
loadType: null
}
},
async onReady() {
await this.loadQuickTags();
},
methods: {
// 快捷分类:取前若干条分类填充到首屏按钮
async loadQuickTags() {
try {
const res = await uniCloud.importObject("uni-cms-categories").getTags();
if (res && String(res.code) === '200') {
this.quickTags = (res.tags || []).slice(0, 5);
}
} catch (e) {
console.error('分类加载失败:', e);
}
},
// 跳转到分类列表:tabBar 页面只能用 switchTab,分类 id 通过缓存传递
goCategory(categoryId) {
uni.setStorageSync('__cms_category_filter', categoryId);
uni.switchTab({
url: "/uni_modules/uni-cms-article/pages/list/slist"
})
},
dialogConfirm() {
console.log('点击确认')
},
dialogClose() {
console.log('点击关闭')
},
inputDialogToggle() {
this.$refs.inputDialog.open()
},
btn(item) {
if (item === 0) {
this.inputDialogToggle();
} else if (item === 1) {
uni.switchTab({
url: "/uni_modules/uni-cms-article/pages/list/slist"
})
} else if (item === 2) {
const token = uni.getStorageSync("uni_id_token");
if (!token) {
uni.navigateTo({
url: "/pages3/uni_modules/uni-id-pages/pages/login/login-withoutpwd?type=weixin"
})
} else {
uni.navigateTo({
url: "/pages3/uni_modules/uni-id-pages/pages/userinfo/userinfo?showLoginManage=true"
})
}
}
},
getNewItem(item) {
if (!item.excerpt) {
item.excerpt = "";
} else if (item.excerpt.length > 50) {
item.excerpt = item.excerpt.slice(0, 50) + " ... ...";
}
return item;
},
async listLoad(data) {
const listData = data.map(item => {
if (typeof item.thumbnail === 'string') {
item.thumbnail = [item.thumbnail]
}
return item
})
for (const article of listData) {
const parsed = await parseImageUrl(article.thumbnail)
article.thumbnail = parsed.map(image => image.src)
}
this.listData = this.loadType === 'loadMore' ? this.listData.concat(listData) : listData
this.loadType = null
},
// 刷新
refresh() {
this.loadType = 'refresh'
this.$refs.udb.loadData({
clear: true
}, () => {
uni.stopPullDownRefresh()
})
},
// 加载更多
loadMore() {
this.loadType = 'loadMore'
this.$refs.udb.loadMore();
},
// 查询出错
onqueryerror(e) {
console.error(e);
}
},
// #ifdef H5
onPullDownRefresh() {
this.refresh()
},
// #endif
}
</script>
<style lang="scss" scoped>
.pages {
background-color: #FFFFFF;
min-height: 100vh;
}
/* 主视觉:军旅红渐变 */
.hero {
background-image: linear-gradient(160deg, #c11c1f 0%, #8a1013 45%, #5c0201 100%);
padding: 60rpx 40rpx 40rpx 40rpx;
}
.hero-title {
color: #fff;
font-size: 64rpx;
font-weight: 700;
letter-spacing: 8rpx;
text-align: center;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.35);
}
.hero-subtitle {
color: rgba(255, 255, 255, 0.82);
font-size: 26rpx;
text-align: center;
margin-top: 16rpx;
letter-spacing: 4rpx;
}
.btn-area {
margin-top: 48rpx;
}
.red-btn-area {
display: flex;
flex-direction: row;
justify-content: center;
padding: 10rpx 0;
margin: 0 40rpx 20rpx 40rpx;
.red-btn {
color: #fff;
font-weight: 700;
font-size: 24rpx;
height: 64rpx;
line-height: 64rpx;
padding: 0 24rpx;
margin: 0;
background-image: linear-gradient(to bottom, #e8433f, #8a1013);
border-radius: 32rpx;
&::after {
border: none;
}
+ .red-btn {
margin-left: 20rpx;
}
}
}
.red-btn-plain-area {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
padding: 16rpx 0;
margin: 0 40rpx;
border-top: 2rpx solid rgba(255, 255, 255, 0.35);
.red-btn-plain {
color: #fff;
font-weight: 700;
font-size: 24rpx;
padding: 8rpx 18rpx;
opacity: 0.92;
}
}
.list-wrapper {
background-color: #FFFFFF;
}
.uni-list {
height: 100%;
}
</style>