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

653 lines
16 KiB
Vue

<template>
<view class="container">
<view class="search-container">
<!-- 搜索框 -->
<view class="search-container-bar">
<!-- #ifdef APP-PLUS -->
<uni-icons class="search-icons" :color="iconColor" size="22" type="mic-filled" @click="speech" />
<!-- #endif -->
<uni-search-bar ref="searchBar" style="flex:1;" radius="100" v-model="searchText" :focus="focus"
:placeholder="hotWorld" clearButton="auto" cancelButton="none" @clear="cancel" @confirm="confirm"
@cancel="cancel" />
<uni-icons class="scan-icons" :color="iconColor" size="22" type="scan" @click="scanEvent"></uni-icons>
</view>
</view>
<view class="search-body">
<unicloud-db ref='listUdb' v-slot:default="{ pagination, hasMore, loading, error, options }"
@error="onqueryerror" :collection="colList" :page-size="10" orderby="publish_date desc" @load="onDbLoad"
loadtime="manual">
<template v-if="!isLoadData">
<!-- 搜索历史 -->
<view class="word-container" v-if="localSearchList.length">
<view class="word-container_header">
<text class="word-container_header-text">搜索历史</text>
<uni-icons v-if="!localSearchListDel" @click="localSearchListDel = true"
class="search-icons" style="padding-right: 0;" :color="iconColor" size="18"
type="trash"></uni-icons>
<view v-else class="flex-center flex-row"
style="font-weight: 500;justify-content: space-between;">
<text
style="font-size: 22rpx;color: #666;padding-top:4rpx;padding-bottom:4rpx;padding-right:20rpx;"
@click="LocalSearchListClear">全部删除</text>
<text
style="font-size: 22rpx;color: #c0402b;padding-top:4rpx;padding-bottom:4rpx;padding-left:20rpx;"
@click="localSearchListDel = false">完成</text>
</view>
</view>
<view class="word-container_body">
<view class="flex-center flex-row word-container_body-text"
v-for="(word, index) in localSearchList" :key="index"
@click="LocalSearchlistItemClick(word, index)">
<text class="word-display" :key="word">{{ word }}</text>
<uni-icons v-if="localSearchListDel" size="12" type="closeempty" />
</view>
</view>
</view>
<!-- 搜索发现 -->
<view class="word-container">
<view class="word-container_header">
<view class="flex-center flex-row">
<text class="word-container_header-text">搜索发现</text>
<uni-icons v-if="!netHotListIsHide" class="search-icons" :color="iconColor" size="14"
type="reload" @click="searchHotRefresh"></uni-icons>
</view>
<uni-icons class="search-icons" style="padding-right: 0;" :color="iconColor" size="18"
:type="netHotListIsHide ? 'eye-slash' : 'eye'"
@click="netHotListIsHide = !netHotListIsHide"></uni-icons>
</view>
<unicloud-db ref="udb" #default="{ data, loading, error, options }" field="content"
collection="opendb-search-hot" orderby="create_date desc,count desc" page-data="replace"
:page-size="10">
<text v-if="loading && !netHotListIsHide" class="word-container_body-info">正在加载...</text>
<view v-else class="word-container_body">
<template v-if="!netHotListIsHide">
<text v-if="error" class="word-container_body-info">{{ error.message }}</text>
<template v-else>
<text v-for="(word, index) in data" class="word-container_body-text"
:key="index" @click="search(word.content)">{{ word.content }}</text>
</template>
</template>
<view v-else style="flex:1;">
<text class="word-container_body-info">当前搜索发现已隐藏</text>
</view>
</view>
</unicloud-db>
</view>
</template>
<!-- 搜索结果 -->
<view v-else class="result-wrapper">
<uni-list class="uni-list" :border="false">
<uni-list-item :to="'/uni_modules/uni-cms-article/pages/detail/detail?id=' + item._id"
v-for="(item, index) in searchList" :key="index">
<template v-slot:header>
<image class="thumbnail" :src="item.thumbnail" mode="aspectFill"></image>
</template>
<template v-slot:body>
<view class="main">
<text class="title">{{ item.title }}</text>
<view class="info">
<text class="author">{{ item.user_id[0] ? item.user_id[0].nickname : '' }}</text>
<text class="publish_date">{{ publishTime(item.publish_date) }}</text>
</view>
</view>
</template>
</uni-list-item>
<uni-load-state @networkResume="refresh"
:state="{ data: searchList, pagination, hasMore, loading, error }" @loadMore="loadMore">
</uni-load-state>
</uni-list>
</view>
</unicloud-db>
</view>
<!-- 搜索联想 -->
<view class="search-associative" v-if="associativeShow">
<uni-list>
<uni-list-item v-for="(item, index) in associativeList" :key="item._id" :ellipsis="1"
:title="item.title" @click="associativeClick(item)" show-extra-icon clickable
:extra-icon="{ size: 18, color: iconColor, type: 'search' }">
</uni-list-item>
</uni-list>
</view>
</view>
</template>
<script>
/**
* 云端一体搜索模板
* @description 自带下拉候选、历史搜索、热搜,无需额外的服务端代码
*/
import translatePublishTime from "@/uni_modules/uni-cms-article/common/publish-time";
import parseScanResult from "@/uni_modules/uni-cms-article/common/parse-scan-result";
import {
parseImageUrl
} from "@/uni_modules/uni-cms-article/common/parse-image-url";
const searchLogDbName = 'opendb-search-log'; // 搜索记录数据库
const articleDbName = 'uni-cms-articles'; // 文章数据库
const associativeSearchField = 'title,excerpt'; // 联想时,搜索框值检索数据库字段名
const associativeField = '_id,title,excerpt'; // 联想列表每一项携带的字段
const localSearchListKey = '__local_search_history'; // 本地历史存储字段名
const db = uniCloud.database();
const userDBName = 'uni-id-users'
// 数组去重
const arrUnique = arr => {
for (let i = arr.length - 1; i >= 0; i--) {
const curIndex = arr.indexOf(arr[i]);
const lastIndex = arr.lastIndexOf(arr[i])
curIndex != lastIndex && arr.splice(lastIndex, 1)
}
return arr
}
// 防抖
function debounce(fn, interval, isFirstAutoRun) {
var _self = fn;
var timer = null;
var first = true;
if (isFirstAutoRun) {
_self();
}
return function() {
var args = arguments;
var _me = this;
if (first) {
first = false;
_self.apply(_me, args);
}
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(function() {
clearTimeout(timer);
timer = null;
_self.apply(_me, args);
}, interval || 200);
}
}
export default {
data() {
return {
articleDbName,
searchLogDbName,
statusBarHeight: '0px',
// 历史实现直接读取存储,未命中时拿到的是空字符串,这里统一成数组避免 .length 取到 undefined
localSearchList: uni.getStorageSync(localSearchListKey) || [],
localSearchListDel: false,
netHotListIsHide: false,
searchText: '',
iconColor: '#999999',
associativeList: [],
keyBoardPopup: false,
hotWorld: '请输入搜索内容',
focus: false,
speechEngine: 'iFly',
isLoadData: false,
where: '"article_status" == 1',
listHeight: 0,
associativeShow: false,
noAssociativeShow: false,
searchList: []
}
},
created() {
this.searchLogDb = db.collection(searchLogDbName);
this.articleCollection = db.collection(articleDbName);
// #ifndef H5
uni.onKeyboardHeightChange((res) => {
this.keyBoardPopup = res.height !== 0;
})
// #endif
},
computed: {
colList() {
return [
db.collection(articleDbName).where(this.where).field('thumbnail,title,publish_date,user_id')
.getTemp(),
db.collection(userDBName).field('_id,nickname').getTemp()
]
}
},
onReady() {
this.listHeight = 'auto'
},
onLoad() {
// #ifdef APP-PLUS
this.statusBarHeight = `${uni.getSystemInfoSync().statusBarHeight}px`;
// #endif
},
methods: {
clear(res) {
console.log("res: ", res);
},
// 确认搜索
confirm(res) {
this.search(res.value);
},
// 取消搜索
cancel(res) {
uni.hideKeyboard();
this.searchText = '';
this.isLoadData = false
this.associativeShow = false
},
// 执行搜索
search(value) {
if (!value && !this.hotWorld) {
return;
}
if (value) {
if (this.searchText !== value) {
this.searchText = value
}
this.localSearchListManage(value);
this.searchLogDbAdd(value)
} else if (this.hotWorld) {
this.searchText = this.hotWorld
}
uni.hideKeyboard();
this.loadList(this.searchText);
},
// 管理本地搜索列表
localSearchListManage(word) {
let list = uni.getStorageSync(localSearchListKey) || [];
if (list.length) {
this.localSearchList.unshift(word);
arrUnique(this.localSearchList);
if (this.localSearchList.length > 10) {
this.localSearchList.pop();
}
} else {
this.localSearchList = [word];
}
uni.setStorageSync(localSearchListKey, this.localSearchList);
},
// 清空本地搜索列表
LocalSearchListClear() {
uni.showModal({
content: "确认清空搜索历史吗",
confirmText: "删除",
confirmColor: 'red',
cancelColor: '#808080',
success: res => {
if (res.confirm) {
this.localSearchListDel = false;
this.localSearchList = [];
uni.removeStorageSync(localSearchListKey)
}
}
});
},
// 点击本地搜索列表项
LocalSearchlistItemClick(word, index) {
if (this.localSearchListDel) {
this.localSearchList.splice(index, 1);
uni.setStorageSync(localSearchListKey, this.localSearchList);
if (!this.localSearchList.length) {
this.localSearchListDel = false;
}
return;
}
this.noAssociativeShow = true;
this.search(word);
},
// 刷新搜索热词
searchHotRefresh() {
this.$refs.udb.refresh();
},
// 语音搜索
speech() {
// #ifdef APP-PLUS
plus.speech.startRecognize({
engine: this.speechEngine,
punctuation: false,
timeout: 10000
}, word => {
word = word instanceof Array ? word[0] : word;
this.search(word)
}, err => {
console.error("语音识别错误: ", err);
});
// #endif
},
// 添加搜索记录:登录存 uni_id,未登录用设备号
searchLogDbAdd(value) {
const uniId = uni.getStorageSync('uni_id');
const payload = {
content: value,
create_date: Date.now()
};
if (uniId) {
payload.user_id = uniId;
} else {
payload.device_id = this.getDeviceId();
}
return this.searchLogDb.add(payload).catch((e) => {
// 搜索记录失败不应影响搜索本身
console.error('搜索记录写入失败:', e);
});
},
// 获取设备 ID(同步返回,拿不到时退回随机串)
getDeviceId() {
try {
const info = uni.getSystemInfoSync();
return info.deviceId || `${info.system}_${Math.random().toString(36).slice(2)}`;
} catch (e) {
return `unknown_${Math.random().toString(36).slice(2)}`;
}
},
// 点击联想词
associativeClick(item) {
this.noAssociativeShow = true;
this.searchText = item.title;
this.loadList(item.title);
},
// 加载列表
loadList(text = '') {
let where = '"article_status" == 1 '
if (text) {
this.where = where + `&& /${text}/.test(title)`;
} else {
this.where = where;
}
this.associativeList = [];
this.associativeShow = false;
this.searchList = []
this.isLoadData = true
setTimeout(() => {
this.$refs.listUdb.loadData({
clear: true
})
}, 0)
},
// 数据库加载完成
async onDbLoad(data) {
this.isLoadData = true
this.noAssociativeShow = false;
const list = []
for (const article of data) {
const parsed = await parseImageUrl(article.thumbnail)
list.push({
...article,
thumbnail: parsed.length ? parsed[0].src : ''
})
}
this.searchList = list
},
onqueryerror(e) {
console.error(e);
},
refresh() {
this.$refs.listUdb.loadData({
clear: true
}, () => {
uni.stopPullDownRefresh()
})
},
loadMore() {
this.$refs.listUdb.loadMore()
},
publishTime(timestamp) {
return translatePublishTime(timestamp)
},
scanEvent() {
uni.scanCode({
onlyFromCamera: true,
scanType: ["qrCode"],
success: (e) => parseScanResult(e.result),
fail: (e) => {
console.error(e)
}
})
}
},
onReachBottom() {
this.loadMore()
},
watch: {
searchText: debounce(function(value, oldValue) {
if (value === oldValue) return
if (this.noAssociativeShow) return
if (value) {
// 按标题/摘要做前缀联想,转义正则元字符避免用户输入被当成模式
const escapedValue = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const _ = db.command;
this.articleCollection
.where(_.or([{
title: new RegExp(escapedValue)
},
{
excerpt: new RegExp(escapedValue)
},
]))
.field(associativeField)
.get()
.then(res => {
this.associativeList = (res && res.result && res.result.data) || [];
this.associativeShow = this.associativeList.length > 0;
})
.catch(e => {
console.error('联想词查询失败:', e);
this.associativeList = [];
this.associativeShow = false;
});
} else {
this.associativeList = [];
this.associativeShow = false;
}
}, 300)
}
}
</script>
<style>
page {
height: 100%;
flex: 1;
}
</style>
<style lang="scss" scoped>
$search-bar-height: 52px;
$word-container_header-height: 72rpx;
.status-bar {
background-color: #fff;
}
.container {
height: 100%;
flex: 1;
background-color: #f7f7f7;
}
.search-body {
background-color: #fff;
border-bottom-right-radius: 10px;
border-bottom-left-radius: 10px;
}
@mixin uni-flex {
display: flex;
}
@mixin words-display {
font-size: 26rpx;
color: #666;
}
.flex-center {
@include uni-flex;
justify-content: center;
align-items: center;
}
.flex-row {
@include uni-flex;
flex-direction: row;
}
::v-deep .uni-searchbar {
padding-left: 0;
}
::v-deep .uni-searchbar__box {
border-width: 0;
}
::v-deep .uni-input-placeholder {
font-size: 28rpx;
}
.search-container {
height: $search-bar-height;
@include uni-flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
background-color: #fff;
@at-root {
#{&}-bar {
@include uni-flex;
flex-direction: row;
justify-content: center;
align-items: center;
position: absolute;
top: 0;
left: 0;
right: 0;
}
}
}
.search-associative {
overflow-y: auto;
position: absolute;
top: $search-bar-height;
left: 0;
right: 0;
bottom: 0;
background-color: #fff;
margin-top: 10rpx;
padding-left: 10rpx;
padding-right: 10rpx;
z-index: 99;
}
.search-icons,
.scan-icons {
padding: 16rpx;
}
.scan-icons {
padding-left: 0;
}
.word-display {
@include words-display;
}
.word-container {
padding: 20rpx;
@at-root {
#{&}_header {
@include uni-flex;
height: $word-container_header-height;
line-height: $word-container_header-height;
flex-direction: row;
justify-content: space-between;
align-items: center;
@at-root {
#{&}-text {
color: #3e3e3e;
font-size: 30rpx;
font-weight: bold;
}
}
}
#{&}_body {
@include uni-flex;
flex-wrap: wrap;
flex-direction: row;
@at-root {
#{&}-text {
@include uni-flex;
@include words-display;
justify-content: center;
align-items: center;
background-color: #f6f6f6;
padding: 10rpx 20rpx;
margin: 20rpx 30rpx 0 0;
border-radius: 30rpx;
box-sizing: border-box;
text-align: center;
}
#{&}-info {
display: block;
flex: 1;
text-align: center;
font-size: 26rpx;
color: #808080;
margin-top: 20rpx;
}
}
}
}
}
.thumbnail {
width: 240rpx;
height: 160rpx;
margin-right: 20rpx;
border-radius: 8rpx;
}
.main {
justify-content: space-between;
flex: 1;
}
.title {
font-size: 32rpx;
}
.info {
flex-direction: row;
justify-content: space-between;
}
.author,
.publish_date {
font-size: 28rpx;
color: #999999;
}
</style>