Files
t/uni_modules/uni-cms-article/pages/list/list.nvue
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

508 lines
13 KiB
Plaintext

<template>
<view class="pages">
<!-- <view class="placeholder-bar">
<statusBar></statusBar>
<view :style="{ height: `${navBarHeight}px` }"></view>
</view> -->
<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>
<view class="v-preview">
<image :src="templateSrc" alt="" class="img" mode="widthFix" />
<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" @click="btn(3)">新四军</view>
<view class="red-btn-plain" @click="btn(4)">抗美援朝</view>
<view class="red-btn-plain" @click="btn(5)">抗美援越</view>
<view class="red-btn-plain" @click="btn(6)">对越自卫反击战</view>
<view class="red-btn-plain" @click="btn(7)">和平时期退役兵</view>
</view>
</view>
</view>
<view class="">
<view class="" :style="{ height: listHeight, 'overflow-y': 'scroll' }">
<unicloud-db ref='udb' v-slot:default="{ pagination, hasMore, loading, error, options }" @error="onqueryerror"
@onPullDownRefresh="onPullDownRefresh" :collection="colList" :page-size="10" orderby="publish_date desc"
@load="listLoad">
<!-- 基于 uni-list 的页面布局 field="user_id.nickname"-->
<!-- #ifdef APP-NVUE -->
<list class="uni-list" :border="false" :style="{ height: listHeight }">
<!-- #endif -->
<!-- #ifndef APP-NVUE -->
<scroll-view scroll-y class="uni-list" refresher-enabled :refresher-triggered="loadType=== 'refresh'"
:style="{ height: listHeight }" @refresherrefresh="refresh" @scrolltolower="loadMore">
<!-- #endif -->
<!-- #ifdef APP-NVUE -->
<refresh-box :loading="loading" @refresh="refresh"></refresh-box>
<!-- #endif -->
<!-- 列表渲染 -->
<template v-for="item in listData">
<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>
<!-- 加载状态:上拉加载更多,加载中,没有更多数据了,加载错误 -->
<!-- #ifdef APP-PLUS -->
<uni-list-item>
<template v-slot:body>
<!-- #endif -->
<uni-load-state @networkResume="refresh"
:state="{ data: listData, pagination, hasMore, loading, error }" @loadMore="loadMore">
</uni-load-state>
<!-- #ifdef APP-PLUS -->
</template>
</uni-list-item>
<!-- #endif -->
<!-- #ifndef APP-NVUE -->
</scroll-view>
<!-- #endif -->
<!-- #ifdef APP-NVUE -->
</list>
<!-- #endif -->
</unicloud-db>
</view>
<!-- #ifdef MP-WEIXIN -->
<!-- <view class="content">
<page-pagination :total="listData.length" :pageSize="page.pageSize" @change="change"
layout="total,first,prev,page,next,last,jumper">
</page-pagination>
</view> -->
<!-- #endif -->
</view>
</view>
</template>
<script>
const uniIdCo = uniCloud.importObject("uni-id-co")
import {
store,
mutations
} from '@/pages3/uni_modules/uni-id-pages/common/store.js'
import statusBar from "@/uni_modules/uni-nav-bar/components/uni-nav-bar/uni-status-bar";
import translatePublishTime from "@/uni_modules/uni-cms-article/common/publish-time";
import refreshBox from "@/uni_modules/uni-cms-article/components/refresh-box/refreshBox.nvue";
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: {
statusBar,
refreshBox,
notCover,
rightSmallCover,
threeCover,
},
computed: {
userInfo() {
return store.userInfo
},
realNameStatus() {
if (!this.userInfo.realNameAuth) {
return 0
}
return this.userInfo.realNameAuth.authStatus
},
// 根据当前语言返回不同的搜索框占位符
inputPlaceholder(e) {
if (uni.getStorageSync('CURRENT_LANG') == "en") {
return 'Please enter the search content' // 英文
} else {
return '请输入搜索内容' // 中文
}
},
// 连表查询,返回两个集合的查询结果
colList() {
return [
db.collection(articleDBName).where(this.where).field(
'thumbnail,title,publish_date,user_id,excerpt').getTemp(), // 文章集合
db.collection(userDBName).field('_id,nickname').getTemp() // 用户集合
]
}
},
data() {
return {
content: "",
templateSrc: "/static/template/default.png",
where: '"article_status" == 1', // 查询条件
showRefresh: false, // 是否显示刷新按钮
listHeight: 0, // 列表高度
mpButtonLeftPlaceholderSize: 0, // 小程序左侧icon占位大小
mpButtonPlaceholderSize: 87, // 小程序导航栏按钮占位大小
navBarHeight: 44, // 导航栏高度
refreshStatus: 0, // 刷新状态 0: 未刷新 1: 刷新中 2: 刷新完成
listData: [], // 列表数据
loadType: null,
}
},
onLoad() {
},
async onReady() {
// #ifdef MP
this.initNavBarSize() // 初始化导航栏大小
// #endif
/* 可用窗口高度 - 搜索框高 - 状态栏高 */
this.listHeight = uni.getSystemInfoSync().windowHeight - uni.getSystemInfoSync().statusBarHeight - this
.navBarHeight + 'px'; // 计算列表高度
},
methods: {
async getCloudImage(srcLst) {
for (let i = 0; i < srcLst.length ; i ++ ) {
if (srcLst[i]&&srcLst[i].substring(0, 8) == "cloud://") {
const res = await uniCloud.getTempFileURL({fileList: [srcLst[i]]});
console.log(res)
console.log(res.fileList.tempFileURL)
srcLst[i] = res.fileList[0].tempFileURL
} else if (srcLst[i]&&srcLst[i].substring(0, 8) == "qiniu://") {
console.log(srcLst[i])
const uniCloudStorageExtCo = uniCloud.importObject("ext-storage-co");
const res = await uniCloudStorageExtCo.getTempFileURL({src: srcLst[i]});
console.log(res.fileList[0].tempFileURL)
srcLst[i] = res.fileList[0].tempFileURL
} else {
srcLst[i] = srcLst[i]
}
}
return srcLst;
},
dialogConfirm() {
console.log('点击确认')
},
dialogClose() {
console.log('点击关闭')
},
inputDialogToggle() {
this.$refs.inputDialog.open()
},
btn(item) {
if (item === 0) {
this.inputDialogToggle();
} if ([1, 3, 4, 5, 6, 7].indexOf(item) !== -1) {
uni.switchTab({
url: "/uni_modules/uni-cms-article/pages/list/slist"
})
} else if (item === 2) {
let token = uni.getStorageSync("uni_id_token");
console.log(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;
},
change(currentPage, type) { // 当前页改变
this.page.currentPage = currentPage;
console.log("点击了" + type + ",当前页:" + currentPage);
this.data_clear();
},
async listLoad(data) {
console.log(data);
const listData = data.map(item => {
if (typeof item.thumbnail === 'string') {
item.thumbnail = [item.thumbnail]
}
return item
})
// 处理腾讯云文件链接
for (const article of listData) {
const parseImages = await this.getCloudImage(article.thumbnail)
// const parseImages = await parseImageUrl(article.thumbnail)
console.log(parseImages)
article.thumbnail = parseImages;
}
console.log(listData)
this.listData = this.loadType === 'loadMore' ? this.listData.concat(listData) : listData
this.loadType = null
},
// 初始化导航栏大小
initNavBarSize() {
// 获取小程序导航栏按钮信息
// #ifdef MP-TOUTIAO
let menuButtonInfo = tt.getCustomButtonBoundingClientRect()
menuButtonInfo.width = menuButtonInfo.capsule.width // 小程序按钮区域中使用的按钮宽度
this.mpButtonLeftPlaceholderSize = menuButtonInfo.leftIcon.width + 10
// #endif
// #ifndef MP-TOUTIAO
let menuButtonInfo = uni.getMenuButtonBoundingClientRect()
// #endif
// 计算小程序导航栏按钮占位大小
this.mpButtonPlaceholderSize = menuButtonInfo.width + 10
// 获取系统信息,判断是否为 iOS 系统,设置导航栏高度
this.navBarHeight = uni.getSystemInfoSync().system.toLowerCase().includes('ios') ? 44 : 48
},
// 格式化时间戳
publishTime(timestamp) {
return translatePublishTime(timestamp)
},
// 点击搜索框
searchClick(e) {
uni.hideKeyboard();
uni.navigateTo({
url: '/uni_modules/uni-cms-article/pages/search/search'
});
},
// 重试
retry() {
this.refresh()
},
// 刷新
refresh() {
this.loadType = 'refresh'
this.$refs.udb.loadData({
clear: true
}, () => {
uni.stopPullDownRefresh()
// #ifdef APP-NVUE
this.showRefresh = false
// #endif
})
},
// 加载更多
loadMore() {
this.loadType = 'loadMore'
this.$refs.udb.loadMore();
},
data_clear() {
this.loadType = 'clear'
this.$refs.udb.clear();
},
// 查询出错
onqueryerror(e) {
console.error(e);
}
},
// #ifdef H5
// 下拉刷新
onPullDownRefresh() {
this.refresh()
},
// #endif
}
</script>
<style lang="scss" scoped>
/* #ifndef APP-NVUE */
.pages view {
display: flex;
box-sizing: border-box;
flex-direction: column;
}
/* #endif */
.pages {
background-color: #FFFFFF;
}
.refresh {
text-align: center;
}
.nav-box {
background-color: #FFFFFF;
position: fixed;
top: 0;
left: 0;
right: 0;
/* #ifndef APP-PLUS */
z-index: 9;
/* #endif */
}
.pages .nav {
display: flex;
align-items: center;
flex-direction: row;
}
.uni-search-box {
flex: 1;
padding: 0 10px;
}
.uni-search-box ::v-deep .uni-searchbar {
padding: 0;
}
.uni-search-box ::v-deep .uni-searchbar__box {
height: 32px;
flex-direction: row;
}
.cover-search-bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
/* #ifndef APP-NVUE */
z-index: 999;
/* #endif */
}
.pages .uni-list ::v-deep .uni-list-item__container {
flex-direction: row;
width: 100%;
}
.pages .uni-list ::v-deep .uni-list-item {
align-items: flex-start;
}
.pages .uni-list ::v-deep .uni-load-more {
display: flex;
}
.pages .uni-list ::v-deep .uni-list--border:after {
background-color: #f5f5f5;
}
.v-preview {
.img {
width: 100%;
}
.cu-editor {
box-sizing: border-box;
width: 100%;
height: 100%;
font-size: 28rpx;
line-height: 1.5;
overflow: auto;
padding: 40rpx 35rpx;
min-height: unset !important;
}
.btn-area {
position: relative;
top: -50rpx;
}
.red-btn-area {
flex-direction: row;
display: flex;
justify-content: center;
padding: 10rpx 0;
margin: 0 40rpx 20rpx 40rpx;
font-size: 20rpx;
.red-btn {
position: relative;
color: #fff;
font-weight: 700;
font-size: 24rpx;
height: 40rpx;
line-height: 40rpx;
margin: 0;
background-image: linear-gradient(to bottom, #c11c1f, #5c0201);
+.red-btn {
margin-left: 20rpx;
}
}
}
.red-btn-plain-area {
flex-direction: row;
display: flex;
justify-content: center;
padding: 10rpx 0;
margin: 0 40rpx;
font-size: 21rpx;
border-top: 2px solid;
border-bottom: 2px solid;
border-image: linear-gradient(to right,
#fff,
#f44336bf,
#b13636,
#f44336bf,
#fff) 1;
.red-btn-plain {
position: relative;
color: #b13636;
font-weight: 700;
padding-left: 15rpx;
+.red-btn-plain {
margin-left: 10rpx;
&::before {
// border-left: 2px solid #f44336;
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 20rpx;
background-color: #b13636;
}
}
}
}
}
</style>