## 阻断性缺陷 - 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:模板与图标资源生成脚本
335 lines
7.5 KiB
Vue
335 lines
7.5 KiB
Vue
<template>
|
|
<view class="page">
|
|
<view class="" style="padding: 10rpx;">
|
|
<u-subsection
|
|
:list="list"
|
|
font-size="20"
|
|
:current="current"
|
|
mode="subsection"
|
|
@change="sectionChange">
|
|
</u-subsection>
|
|
</view>
|
|
|
|
<u-popup :show="show" @close="close" @open="open">
|
|
<view class="popup-page">
|
|
<view class="">
|
|
<uni-list>
|
|
<uni-list-item
|
|
@click="editItem(selectItem)"
|
|
:show-extra-icon="true"
|
|
:extra-icon="iconList.edit"
|
|
clickable="true"
|
|
title="文章编辑" />
|
|
</uni-list>
|
|
<uni-list>
|
|
<uni-list-item
|
|
@click="settingItem()"
|
|
:show-extra-icon="true"
|
|
:extra-icon="iconList.setting"
|
|
clickable="true"
|
|
title="文章设置" />
|
|
</uni-list>
|
|
<uni-list>
|
|
<uni-list-item
|
|
@click="deleteItem()"
|
|
:show-extra-icon="true"
|
|
:extra-icon="iconList.delete"
|
|
clickable="true"
|
|
title="删除" />
|
|
</uni-list>
|
|
</view>
|
|
<view class="close-button" @click="close()">
|
|
取消
|
|
</view>
|
|
</view>
|
|
|
|
</u-popup>
|
|
|
|
<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"
|
|
mode="scaleToFill"
|
|
:src="Array.isArray(item.thumbnail) && item.thumbnail.length > 0?item.thumbnail[0]:''">
|
|
</cloud-image>
|
|
<text class="uni-body">
|
|
{{ item.title || '(无标题)' }}
|
|
</text>
|
|
<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">
|
|
<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>
|
|
</uni-card>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="empty" v-else>
|
|
<text class="empty-text">{{ emptyText }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import cloudImage from "@/components/cloud-image.vue"
|
|
import uSubsection from "@/uni_modules/uview-ui/components/u-subsection/u-subsection.vue"
|
|
import uPopup from "@/uni_modules/uview-ui/components/u-popup/u-popup.vue"
|
|
import { store } from '@/pages3/uni_modules/uni-id-pages/common/store.js'
|
|
|
|
export default {
|
|
components: {
|
|
uSubsection,
|
|
cloudImage,
|
|
uPopup
|
|
},
|
|
setup() {
|
|
// 只保留真实存在的两种状态:草稿(article_status=0)与已发布(=1)。
|
|
// 原实现有 4 个 tab,但只有第 2 个有内容,其余点开是空白。
|
|
const list = ref(['已发布', '草稿'])
|
|
const current = ref(0)
|
|
const show = ref(false)
|
|
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' },
|
|
edit: { color: '#888', size: '22', type: 'compose' }
|
|
}
|
|
|
|
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' })
|
|
setTimeout(() => uni.navigateBack(), 1000)
|
|
return
|
|
}
|
|
uni.showLoading({ title: '加载中' })
|
|
try {
|
|
let res = await uniCloud.importObject("uni-cms-articles")
|
|
.get_cms_articles_for_userId({ user_id: userInfo.value._id })
|
|
cms_list.value = res.data || []
|
|
} catch (e) {
|
|
console.error(e)
|
|
uni.showToast({ title: '加载失败,请重试', icon: 'none' })
|
|
} finally {
|
|
uni.hideLoading()
|
|
}
|
|
})
|
|
|
|
function sectionChange(index) {
|
|
current.value = index
|
|
}
|
|
|
|
// 按对象引用定位,避免列表经过筛选后下标与原始数组错位
|
|
function selectMore(item) {
|
|
show.value = true
|
|
selectItem.value = item
|
|
}
|
|
|
|
function close() {
|
|
show.value = false
|
|
}
|
|
|
|
function open() {
|
|
show.value = true
|
|
}
|
|
|
|
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(item) {
|
|
if (!item) return
|
|
uni.setStorageSync("cms", item)
|
|
await toCmsEdit(item._id)
|
|
}
|
|
|
|
async function toCmsEdit(id) {
|
|
uni.navigateTo({
|
|
url: "/pages2/editCms/editCms?id=" + id
|
|
})
|
|
}
|
|
|
|
function settingItem() {
|
|
const target = selectItem.value
|
|
if (!target) return
|
|
uni.setStorageSync("cms", target)
|
|
close()
|
|
uni.navigateTo({ url: "/pages2/editCms/editSetting" })
|
|
}
|
|
|
|
return {
|
|
list,
|
|
current,
|
|
show,
|
|
selectItem,
|
|
cms_list,
|
|
filteredList,
|
|
emptyText,
|
|
iconList,
|
|
userInfo,
|
|
sectionChange,
|
|
selectMore,
|
|
close,
|
|
open,
|
|
deleteItem,
|
|
editItem,
|
|
toCmsEdit,
|
|
settingItem
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
.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;
|
|
position: relative;
|
|
}
|
|
|
|
.cover-content {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 40px;
|
|
background-color: rgba($color: #000000, $alpha: 0.4);
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
padding-left: 15px;
|
|
font-size: 14px;
|
|
color: #fff;
|
|
}
|
|
|
|
.card-actions {
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: space-around;
|
|
align-items: center;
|
|
height: 45px;
|
|
border-top: 1px #eee solid;
|
|
}
|
|
.card-actions-item {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
}
|
|
.card-actions-item-text {
|
|
font-size: 12px;
|
|
color: #666;
|
|
margin-left: 5px;
|
|
}
|
|
.cover-image {
|
|
flex: 1;
|
|
height: 150px;
|
|
}
|
|
.no-border {
|
|
border-width: 0;
|
|
}
|
|
.close-button {
|
|
background-color: #fff;
|
|
text-align: center;
|
|
border: 3rpx solid #fff;
|
|
height: 50rpx;
|
|
width: 100%;
|
|
padding: 5rpx;
|
|
margin-top: 20rpx;
|
|
margin-bottom: 10rpx;
|
|
}
|
|
.popup-page {
|
|
background-color: #eee;
|
|
}
|
|
</style>
|