Files
t/pages2/editCms/preview.vue
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

686 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="body">
<uni-nav-bar
title="预览"
leftText="取消"
left-icon="left"
right-text="发布"
backgroundColor="#ffffff"
color="#000000"
@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>
<view class="" :style="backgrond_color">
<u-parse :content="titleContent"></u-parse>
<view class="">
<view class="meta">
<view class="author">
<cloud-image
height="64rpx" width="64rpx" borderRadius="50%"
:src="userInfo.avatar_file.url"></cloud-image>
<text class="at" :style="color">{{ userInfo.nickname || '' }}</text>
<text class="split">·</text>
<text class="date" :style="color">刚刚</text>
</view>
</view>
<view class="">
<view class="" v-for="(item, index) in cms.cmsLst" :key="index">
<u-parse
:content="item.html"
v-if="item.type === 'image' || item.type === 'video'">
</u-parse>
<view class="" v-if="item.type === 'image'"
style="width: 100wv;margin-top: 30rpx;margin-bottom: 30rpx;">
<cloud-image
width="100%"
height="500rpx"
mode="aspectFit"
:src="item.image.src">
</cloud-image>
</view>
<view class="" v-if="item.type === 'video'"
style="width: 100wv;margin-top: 30rpx;margin-bottom: 30rpx;">
<video
style="width: 100%; height: 500rpx;"
:src="item.video.src"
enable-danmu
danmu-btn
controls>
</video>
</view>
<view class="" v-if="item.type === 'vote'">
<uni-card>
<view class="vote-container">
<text class="vote-title">{{ item.vote.voteTitle }}</text>
<text class="deadline">截止时间:{{ item.vote.datetime }}</text>
<view class="" v-if="item.vote.type === voteType[0]">
<u-radio-group
v-model="item.vote.selectValue"
placement="column"
@change="groupChange"
wrap
>
<u-radio
:customStyle="{marginBottom: '8px'}"
v-for="(radio, radioIndex) in item.vote.voteLst"
:key="radioIndex"
:label="radio.value"
:name="radio.value"
@change="radioChange"
style="width: 100%;"
>
</u-radio>
</u-radio-group>
</view>
<view class="" v-if="item.vote.type === voteType[1]">
<u-checkbox-group
v-model="item.vote.selectValue"
placement="column"
@change="checkboxChange"
>
<u-checkbox
:customStyle="{marginBottom: '8px'}"
v-for="(checkbox, checkboxIndex) in item.vote.voteLst"
:key="checkboxIndex"
:label="checkbox.value"
:name="checkbox.value"
>
</u-checkbox>
</u-checkbox-group>
</view>
<view class="" v-if="item.vote.type === voteType[2]">
<u-checkbox-group
v-model="item.vote.selectValue"
placement="column"
@change="checkboxChange"
>
<view class="">
<u-checkbox
:customStyle="{marginBottom: '8px'}"
v-for="(checkbox, checkboxIndex) in item.vote.voteLst"
:key="checkboxIndex"
:label="checkbox.value"
:name="checkbox.value"
:disabled="isCheckboxDisabled(item.vote.selectValue, checkbox.value)"
>
</u-checkbox>
</view>
</u-checkbox-group>
</view>
<view class=""
v-if="isExpired"
:disabled="isExpired || !selectedValue"
style="border-top: 3rpx solid #ccc;padding-top: 20rpx;margin-top: 20rpx;
text-align: center;;">
已截止
</view>
<view class=""
v-else
:disabled="isExpired || !selectedValue"
style="border-top: 3rpx solid #ccc;padding-top: 20rpx;margin-top: 20rpx;
text-align: center;;">
{{ isExpired ? '已截止' : '点击登录后投票' }}
</view>
</view>
</uni-card>
</view>
</view>
</view>
</view>
</view>
<u-tabbar :value="tabbarValue"
>
<u-tabbar-item
v-for="(item, index) in tabbarText"
:key="index"
@click="tabbarClick"
:text="item.text">
</u-tabbar-item>
</u-tabbar>
<hqs-popup :title="tabbarText[tabbarValue].text" :from="popFrom" :mask-click="maskClick"
@back="onBack"
v-model="showPop">
<view class="page-container" v-if="tabbarText[tabbarValue].text === '模板'">
<media-list
:dataType="'template'"
:pageSize="12"
:currentTemplate="updateTemplateConfig"
@update="updateTemplate"
/>
</view>
<view class="page-container" v-if="tabbarText[tabbarValue].text === '音乐'">
<media-list
:dataType="'music'"
:pageSize="12"
:currentTemplate="updateTemplateConfig"
@update="updateTemplate"
/>
</view>
<view class="page-container" v-if="tabbarText[tabbarValue].text === '排版'">
</view>
</hqs-popup>
<uni-grid :column="3" :highlight="true" @change="change" :show-border="false">
<uni-grid-item v-for="(item, index) in gridLst" :index="index" :key="index">
<view class="grid-item-box">
<uni-icons :type="item.icon" :size="30"
:color="item.select_flag?select_icon_color:icon_color" />
<text class="text" :style="item.select_flag?select_font_color:icon_font_color">
{{item.value?item.value:0}}
</text>
</view>
</uni-grid-item>
</uni-grid>
</view>
</template>
<script>
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { store } from '@/pages3/uni_modules/uni-id-pages/common/store.js'
import uRadioGroup from "@/uni_modules/uview-ui/components/u-radio-group/u-radio-group.vue"
import uRadio from "@/uni_modules/uview-ui/components/u-radio/u-radio.vue"
import uParse from "@/uni_modules/uview-ui/components/u-parse/u-parse.vue"
import uTabbar from "@/uni_modules/uview-ui/components/u-tabbar/u-tabbar.vue"
import uTabbarItem from "@/uni_modules/uview-ui/components/u-tabbar-item/u-tabbar-item.vue"
import mediaList from "@/components/image-grid/media-list.vue"
import cloudImage from "@/components/cloud-image.vue"
import uCheckboxGroup from "@/uni_modules/uview-ui/components/u-checkbox-group/u-checkbox-group.vue"
import uCheckbox from "@/uni_modules/uview-ui/components/u-checkbox/u-checkbox.vue"
export default {
components: {
uTabbar,
uTabbarItem,
mediaList,
uParse,
cloudImage,
uRadioGroup,
uRadio,
uCheckboxGroup,
uCheckbox
},
setup() {
const voteType = ref(["单选(默认)", "多选,无限制", "多选, 最多两项"])
const voteEndTime = ref('2025-04-20 23:59:59')
const cms = ref({})
const temp = ref({})
const titleContent = ref("")
const tabbarValue = ref(0)
const showPop = ref(false)
const maskClick = ref(true)
const popFrom = ref('bottom')
const list = ref(new Array(20).fill(0))
const currentTemplate = ref("")
const saving = ref(false)
const tabbarText = ref([
{ text: "模板" },
{ text: "音乐" },
{ text: "排版" }
])
const userInfo = computed(() => store.userInfo)
const isExpired = computed(() => new Date() > new Date(voteEndTime.value))
const btn_send_commends_color = computed(() => {
if (!temp.value) return ""
return `background-color: ${temp.value.btn_send_commends_color};`
})
const backgrond_color = computed(() => {
if (!temp.value) return ""
return `background-color: ${temp.value.backgrond_color};`
})
const color = computed(() => {
if (!temp.value) return ""
return `color: ${temp.value.font_color};`
})
const select_icon_color = computed(() => {
if (!temp.value) return ""
return `${temp.value.select_icon_color};`
})
const icon_color = computed(() => {
if (!temp.value) return ""
return `${temp.value.icon_color};`
})
onShow(async () => {
let storedCms = uni.getStorageSync("cms")
titleContent.value = storedCms.title_html
cms.value = storedCms
await getTemp()
})
async function getTemp() {
if (currentTemplate.value === "not_image") {
temp.value = {}
}
if (!currentTemplate.value) {
temp.value = {}
return
}
const res = await uniCloud.importObject("uni-cms-articles").getTemp({
temp_id: currentTemplate.value
})
if (res.code === 200) {
temp.value = res.data
}
}
async function updateTemplate(e) {
currentTemplate.value = e
await getTemp()
}
function onBack() {
uni.showToast({ title: 'test' })
}
function tabbarClick(e) {
tabbarValue.value = e
showPopupFrom()
}
function showPopupFrom() {
showPop.value = true
}
function isCheckboxDisabled(arr, name) {
if (arr.length < 2 || arr.includes(name)) {
return false
}
return true
}
function checkboxChange(e) {
console.log(e)
}
function groupChange(n) {
console.log('groupChange', n)
}
function radioChange(n) {
console.log('radioChange', n)
}
function formatDate(date, format) {
const map = {
'yyyy': date.getFullYear(),
'MM': String(date.getMonth() + 1).padStart(2, '0'),
'dd': String(date.getDate()).padStart(2, '0'),
'HH': String(date.getHours()).padStart(2, '0'),
'mm': String(date.getMinutes()).padStart(2, '0'),
'ss': String(date.getSeconds()).padStart(2, '0')
}
return format.replace(/yyyy|MM|dd|HH|mm|ss/g, matched => map[matched])
}
function formatTime() {
return formatDate(new Date(), 'yyyy-MM-dd HH:mm')
}
/**
* 保存文章
* @param {Number} articleStatus 1 发布,0 存草稿
*/
async function saveArticle(articleStatus) {
if (saving.value) return
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' })
}
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() {
uni.navigateBack({
delta: 1,
fail: (err) => {
uni.navigateTo({ url: "/pages2/editCms/editCms" })
}
})
}
return {
voteType,
voteEndTime,
cms,
temp,
titleContent,
tabbarValue,
showPop,
maskClick,
popFrom,
list,
currentTemplate,
tabbarText,
userInfo,
isExpired,
btn_send_commends_color,
backgrond_color,
color,
select_icon_color,
icon_color,
getTemp,
updateTemplate,
onBack,
tabbarClick,
showPopupFrom,
isCheckboxDisabled,
checkboxChange,
groupChange,
radioChange,
formatDate,
formatTime,
saving,
saveArticle,
back,
}
}
}
</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;
padding: 15px;
background: #f5f5f5;
}
.grid-item {
flex: 1;
min-width: 0;
aspect-ratio: 1;
background: white;
border-radius: 8px;
overflow: hidden;
}
.page-container {
padding: 20rpx 0;
}
.rectangle-img {
width: 100%;
height: 100%;
object-fit: cover;
aspect-ratio: 16/9;
}
.meta {
position: relative;
z-index: 1;
padding-top: 20rpx;
.title {
.text {
font-size: 40rpx;
line-height: 66rpx;
font-weight: bold;
color: #333;
}
}
.excerpt {
margin-top: 10rpx;
.text {
font-size: 26rpx;
line-height: 40rpx;
color: #999;
}
}
.author {
display: flex;
align-items: center;
justify-content: flex-start;
flex-direction: row;
margin-top: 20rpx;
.at,
.split,
.date {
font-size: 26rpx;
color: #ccc;
}
.split {
margin: 0 10rpx;
}
}
}
.body {
padding: 20rpx;
.cms-body {
display: flex;
min-height: 60hv;
width: 100wv;
}
}
.v-preview {
.img {
width: 100%;
}
.btn-area {
position: relative;
top: -50rpx;
}
.red-btn-area {
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 {
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 {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 20rpx;
background-color: #b13636;
}
}
}
}
}
.vote-container {
padding: 20rpx;
}
.vote-title {
font-size: 16px;
font-weight: bold;
color: #333;
display: block;
margin-bottom: 10rpx;
}
.deadline {
font-size: 12px;
color: #999;
margin-bottom: 30rpx;
display: block;
}
.radio-group {
margin-bottom: 40rpx;
}
.option-item {
display: flex;
align-items: center;
margin: 15rpx 0;
}
.option-label {
margin-left: 20rpx;
font-size: 14px;
}
.vote-btn {
background-color: #2979FF;
color: white;
border-radius: 8rpx;
font-size: 14px;
margin-top: 30rpx;
&[disabled] {
background-color: #CCCCCC;
}
}
</style>