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:
@@ -1738,7 +1738,7 @@
|
||||
},
|
||||
toLogin() {
|
||||
uni.navigateTo({
|
||||
url: "../../pages3/uni_modules/uni-id-pages/pages/login/login-withoutpwd?type=weixin"
|
||||
url: "/pages3/uni_modules/uni-id-pages/pages/login/login-withoutpwd?type=weixin"
|
||||
})
|
||||
},
|
||||
_updateLocalData(commentId, serverData) {
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
data() {
|
||||
return {
|
||||
content: "",
|
||||
templateSrc: "https://env-00jxt5y29hrd.normal.cloudstatic.cn/static/static/bg.jpg?expire_at=1741142837&er_sign=291c5dab058782f6472cf8792735686d",
|
||||
templateSrc: "/static/template/default.png",
|
||||
where: '"article_status" == 1', // 查询条件
|
||||
showRefresh: false, // 是否显示刷新按钮
|
||||
listHeight: 0, // 列表高度
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<view class="pages">
|
||||
<!-- 顶部搜索框:点击跳转到搜索页 -->
|
||||
<view class="nav-box">
|
||||
<view class="nav">
|
||||
<view class="uni-search-box">
|
||||
<uni-search-bar ref="searchBar" radius="100" cancelButton="none" disabled
|
||||
:placeholder="inputPlaceholder" />
|
||||
<view class="cover-search-bar" @click="searchClick"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分类标签:多选,全不选时展示全部 -->
|
||||
<view class="tag-bar" v-if="tags.length">
|
||||
<uni-data-checkbox mode="tag" multiple v-model="tags_v" :localdata="tags"></uni-data-checkbox>
|
||||
</view>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
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'
|
||||
const categoryFilterKey = '__cms_category_filter';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
notCover,
|
||||
rightSmallCover,
|
||||
threeCover,
|
||||
},
|
||||
computed: {
|
||||
// 根据当前语言返回不同的搜索框占位符
|
||||
inputPlaceholder() {
|
||||
if (uni.getStorageSync('CURRENT_LANG') == "en") {
|
||||
return 'Please enter the search content'
|
||||
}
|
||||
return '请输入搜索内容'
|
||||
},
|
||||
// 连表查询,返回两个集合的查询结果
|
||||
colList() {
|
||||
const where = {
|
||||
article_status: 1
|
||||
};
|
||||
if (this.tags_v && this.tags_v.length) {
|
||||
where.category_id = db.command.in(this.tags_v);
|
||||
}
|
||||
return [
|
||||
db.collection(articleDBName).where(where).field(
|
||||
'thumbnail,title,publish_date,user_id,excerpt').getTemp(), // 文章集合
|
||||
db.collection(userDBName).field('_id,nickname').getTemp() // 用户集合
|
||||
]
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tags_v: [],
|
||||
tags: [],
|
||||
listData: [], // 列表数据
|
||||
loadType: null
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
await this.getTags();
|
||||
},
|
||||
onShow() {
|
||||
// tabBar 页面切回来时要重新应用首页传来的分类筛选
|
||||
this.applyPendingCategory();
|
||||
},
|
||||
methods: {
|
||||
// 首页「快捷分类」通过缓存传递筛选目标
|
||||
applyPendingCategory() {
|
||||
const pending = uni.getStorageSync(categoryFilterKey);
|
||||
if (!pending) return;
|
||||
uni.removeStorageSync(categoryFilterKey);
|
||||
if (this.tags_v.indexOf(pending) === -1) {
|
||||
this.tags_v = this.tags_v.concat([pending]);
|
||||
}
|
||||
},
|
||||
getNewItem(item) {
|
||||
if (!item.excerpt) {
|
||||
item.excerpt = "";
|
||||
} else if (item.excerpt.length > 50) {
|
||||
item.excerpt = item.excerpt.slice(0, 50) + " ... ...";
|
||||
}
|
||||
return item;
|
||||
},
|
||||
async getTags() {
|
||||
try {
|
||||
const res = await uniCloud.importObject("uni-cms-categories").getTags();
|
||||
if (res && String(res.code) === '200') {
|
||||
this.tags = res.tags || [];
|
||||
this.applyPendingCategory();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('分类加载失败:', e);
|
||||
}
|
||||
},
|
||||
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
|
||||
},
|
||||
// 点击搜索框
|
||||
searchClick() {
|
||||
uni.hideKeyboard();
|
||||
uni.navigateTo({
|
||||
url: '/uni_modules/uni-cms-article/pages/search/search'
|
||||
});
|
||||
},
|
||||
// 刷新
|
||||
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;
|
||||
}
|
||||
|
||||
.nav-box {
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.uni-search-box {
|
||||
flex: 1;
|
||||
padding: 0 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
::v-deep .uni-searchbar {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::v-deep .uni-searchbar__box {
|
||||
height: 32px;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* 覆盖层:让禁用状态的搜索框整体可点 */
|
||||
.cover-search-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.tag-bar {
|
||||
padding: 10rpx 20rpx 20rpx 20rpx;
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.uni-list {
|
||||
height: calc(100vh - 160rpx);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,652 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user