首次完整推送,

V:1.20240808.006
This commit is contained in:
fm453
2024-08-13 18:32:37 +08:00
parent 15be3e9373
commit c62d15b288
939 changed files with 111777 additions and 0 deletions

View File

@ -0,0 +1,41 @@
## 1.0.162024-06-21
- 修复 小程序发布时无法上传的Bug
## 1.0.152024-06-20
- 修复 小程序访问文章列表报错的Bug
## 1.0.142024-06-12
- 修复 客户端无法显示文章详情问题的Bug
## 1.0.132023-12-09
- 新增 支持uni-app-x需要[uni-cms](https://ext.dcloud.net.cn/plugin?id=11700)插件版本>=1.0.17
## 1.0.122023-10-17
- 修复 使用腾讯云服务空间时无法加载封面图及正文图片的问题
## 1.0.112023-08-07
- 修复 Vue3下因`parse-scen-result.js`文件导出问题导致无法打包编译的bug
## 1.0.102023-07-14
- 新增 文章预览功能,详见[文档](https://uniapp.dcloud.net.cn/uniCloud/uni-cms.html#article-preview)需要uni-cms版本>=1.0.14
- 修复 文章详情页换行重复问题
- 修复 文章列表在数据量小时下拉刷新数据会重复的问题
## 1.0.92023-07-10
- 优化 文章详情页正文可以渲染多个换行
## 1.0.82023-06-21
- 增加 文章列表无图、三图封面样式需要uni-cms版本>=1.0.12
- 优化 文章详情页样式
- 修复 在Vue3下出现 require is not defined 问题
## 1.0.72023-06-07
- 新增 文章详情页面支持播放视频发布视频需要uni-cms版本>=1.0.11
- 优化 文章正文渲染逻辑
- 优化 文章详情页面样式
- 修复 在文章详情页下点击返回按钮无响应问题
## 1.0.62023-04-28
- 修复 文章详情只存在列表时无法渲染的问题
## 1.0.52023-04-24
- 增加 license 文件
## 1.0.42023-04-21
- 优化代码结构,增加代码注释,提高可读性
## 1.0.32023-04-17
- 移除无用schema文件
## 1.0.22023-04-12
- 优化看广告解锁文章交互
## 1.0.12023-04-12
- 优化页面逻辑
## 1.0.02023-04-11
- 插件发布,支持图文内容展示、广告解锁全文功能 [详见文档](https://uniapp.dcloud.net.cn/uniCloud/uni-cms.html)

View File

@ -0,0 +1,68 @@
function parseEditorImage (blocks = []) {
const images = []
if (!Array.isArray(blocks)) {
blocks = [blocks]
}
for (const block of blocks) {
const {insert = {}, attributes = {}} = block
const {'data-custom': custom = ""} = attributes
let parseCustom = custom.split('&').reduce((obj, item) => {
const [key, value] = item.split('=')
if (key && value) {
obj[key] = value
}
return obj
}, {})
images.push({
src: insert.image,
source: parseCustom.source ? parseCustom.source: insert.image
})
}
return images
}
/**
* 解析媒体库/编辑器中的图片
* @param images 图片地址
* @param type {string} 解析类型 media: 媒体库, editor: 编辑器
* @returns {Promise<{src: *, source: *}[]|{src, source: *}[]>}
*/
export async function parseImageUrl (images = [], type = "media") {
if (type === "editor") {
images = parseEditorImage(images).map(item => item.source)
} else {
if (!Array.isArray(images)) {
images = [images]
}
}
if (!images) return null
const tcbFiles = images.filter(item => item.startsWith("cloud://"))
if (tcbFiles.length) {
const res = await uniCloud.getTempFileURL({
fileList: tcbFiles
})
return images.map(image => {
const file = res.fileList.find(item => item.fileID === image)
return {
src: file ? file.tempFileURL : image,
source: image
}
})
} else {
return images.map(image => ({
src: image,
source: image
}))
}
}

View File

@ -0,0 +1,80 @@
export type ParseImageUrlResult = {
src: string
source: string
}
function parseEditorImage (_blocks: any): UTSJSONObject[] {
const images: UTSJSONObject[] = []
let blocks: UTSJSONObject[]
if (!Array.isArray(_blocks)) {
blocks = [_blocks as UTSJSONObject] as UTSJSONObject[]
} else {
blocks = _blocks as UTSJSONObject[]
}
blocks.forEach((block: UTSJSONObject) => {
const insert = block.getJSON('insert')
const attributes = block.getJSON('attributes')
const custom = attributes!.getString('data-custom')
let parseCustom = custom && custom.split('&') ? custom.split('&').reduce((obj: UTSJSONObject, item: string): UTSJSONObject => {
const kv = item.split('=')
if (kv.length > 1) {
obj[kv[0]] = kv[1]
}
return obj
}, {} as UTSJSONObject) : {}
images.push({
src: insert!.getString('image'),
source: parseCustom.getString('source') != null ? parseCustom.getString('source') : insert!.getString('image')
})
})
return images
}
/**
* 解析媒体库/编辑器中的图片
* @param images 图片地址
* @param type {string} 解析类型 media: 媒体库, editor: 编辑器
* @returns {Promise<{src: *, source: *}[]|{src, source: *}[]>}
*/
export async function parseImageUrl (images: any, type: string = "media"): Promise<ParseImageUrlResult[] | null> {
let imagePaths: string[] = []
if (type === "editor") {
imagePaths = parseEditorImage(images).map((item: UTSJSONObject): string => item.getString('source')!)
} else {
if (!Array.isArray(images)) {
imagePaths = [images as string] as string[]
} else {
imagePaths = images
}
}
if (imagePaths.length <= 0) return null
const tcbFiles = imagePaths.filter((item: string): boolean => item.startsWith("cloud://"))
if (tcbFiles.length > 0) {
const res: UniCloudGetTempFileURLResult = await uniCloud.getTempFileURL({
fileList: tcbFiles
})
return imagePaths.map((image: string): ParseImageUrlResult => {
const file = res.fileList.find((item: UniCloudGetTempFileURLResultItem): boolean => item.fileID === image)
return {
src: file ? file.tempFileURL : image,
source: image
} as ParseImageUrlResult
})
} else {
return imagePaths.map((image: string): ParseImageUrlResult => ({
src: image,
source: image
} as ParseImageUrlResult))
}
}

View File

@ -0,0 +1,28 @@
function parseScanResult (scanText) {
const match = scanText.match(/^(.*?):\/\/(.*)/)
if (!match || match.length < 1) {
uni.showToast({
icon: 'none',
title: '未能识别到有效信息'
})
}
const [, protocol, path] = match
switch (protocol) {
case "internallink":
uni.navigateTo({
url: `/${path.replace(/^\//, '')}`,
fail: () => {
uni.showToast({
icon: "none",
title: "访问的路径不存在"
})
}
})
break
}
}
export default parseScanResult

View File

@ -0,0 +1,28 @@
function parseScanResult (scanText: string): void {
const match = scanText.match(/^(.*?):\/\/(.*)/)
if (!match || match.length < 1) {
uni.showToast({
icon: 'none',
title: '未能识别到有效信息'
})
}
const [, protocol, path] = match
switch (protocol) {
case "internallink":
uni.navigateTo({
url: `/${path.replace(/^\//, '')}`,
fail: () => {
uni.showToast({
icon: "none",
title: "访问的路径不存在"
})
}
})
break
}
}
export default parseScanResult

View File

@ -0,0 +1,93 @@
export default function translatePublishTime(timestamp) {
let result = ''
// 获取当前时间
const currentData = new Date()
// 获取发布时间
const date = new Date(timestamp)
// 获取发布年份
const year = date.getFullYear()
// 获取发布月份
const mouth = date.getMonth() + 1
// 获取发布日期
const day = date.getDate()
// 获取发布小时
const hours = date.getHours()
// 获取发布分钟
const minute = date.getMinutes()
// 获取发布秒数
const second = date.getSeconds()
// 获取发布时间戳
const timer = date.getTime()
// 获取当前年份
const currentYear = currentData.getFullYear()
// 获取当前月份
const currentMonth = currentData.getMonth() + 1
// 获取当前日期
const currentDay = currentData.getDate()
// 获取当前小时
const currentHours = currentData.getHours()
// 获取当前分钟
let currentMinute = currentData.getMinutes()
// 获取当前秒数
const currentSecond = currentData.getSeconds()
// 获取当前时间戳
const currentTimer = currentData.getTime()
// 如果时间差小于10秒
if ((currentTimer - timer) < 1000 * 10) {
// 显示刚刚
result = `刚刚`;
// 如果时间差小于60秒
} else if ((currentTimer - timer) < 1000 * 60) {
// 如果当前分钟大于发布分钟
if (currentMinute > minute) {
// 显示秒数差
result = `${(((currentMinute - minute) * 60) + currentSecond - second)}秒前`;
} else {
// 显示秒数差
result = `${(currentSecond - second)}秒前`;
}
// 如果时间差小于1小时
} else if ((currentTimer - timer) < 1000 * (60 * 60)) {
// 如果当前小时大于发布小时
if (currentHours > hours) {
// 显示分钟差
result = `${(((currentHours - hours) * 60) + currentMinute - minute)}分钟前`;
} else {
// 修改 昨天发布的文章时间会出现负数
// 如果当前分钟小于发布分钟
if (currentMinute < minute) {
// 当前分钟加60
currentMinute += 60
}
// 显示分钟差
result = `${(currentMinute - minute)}分钟前`;
}
// 如果时间差小于1天
} else if ((currentTimer - timer) < 1000 * (24 * 60 * 60)) {
// 如果当前日期大于发布日期
if (currentDay > day) {
// 显示小时差
result = `${((currentDay - day) * 24 + currentHours - hours)}小时前`;
} else {
// 修改 跨月-昨天发布的文章时间会出现负数
// 如果当前月份不等于发布月份
if (currentMonth !== mouth) {
// 显示小时差
result = `${(24 + currentHours - hours)}小时前`;
} else {
// 显示小时差
result = `${(currentHours - hours)}小时前`;
}
}
// 如果发布年份等于当前年份
} else if (currentYear === year) {
// 显示月份和日期
result = `${mouth}${day}`;
} else {
// 显示年份、月份和日期
result = `${year}${mouth}${day}`;
}
return result // 返回结果
}

View File

@ -0,0 +1,93 @@
export default function translatePublishTime(timestamp: number): string {
let result: string
// 获取当前时间
const currentData = new Date()
// 获取发布时间
const date = new Date(timestamp)
// 获取发布年份
const year = date.getFullYear()
// 获取发布月份
const mouth = date.getMonth() + 1
// 获取发布日期
const day = date.getDate()
// 获取发布小时
const hours = date.getHours()
// 获取发布分钟
const minute = date.getMinutes()
// 获取发布秒数
const second = date.getSeconds()
// 获取发布时间戳
const timer = date.getTime()
// 获取当前年份
const currentYear = currentData.getFullYear()
// 获取当前月份
const currentMonth = currentData.getMonth() + 1
// 获取当前日期
const currentDay = currentData.getDate()
// 获取当前小时
const currentHours = currentData.getHours()
// 获取当前分钟
let currentMinute = currentData.getMinutes()
// 获取当前秒数
const currentSecond = currentData.getSeconds()
// 获取当前时间戳
const currentTimer = currentData.getTime()
// 如果时间差小于10秒
if ((currentTimer - timer) < 1000 * 10) {
// 显示刚刚
result = `刚刚`;
// 如果时间差小于60秒
} else if ((currentTimer - timer) < 1000 * 60) {
// 如果当前分钟大于发布分钟
if (currentMinute > minute) {
// 显示秒数差
result = `${(((currentMinute - minute) * 60) + currentSecond - second)}秒前`;
} else {
// 显示秒数差
result = `${(currentSecond - second)}秒前`;
}
// 如果时间差小于1小时
} else if ((currentTimer - timer) < 1000 * (60 * 60)) {
// 如果当前小时大于发布小时
if (currentHours > hours) {
// 显示分钟差
result = `${(((currentHours - hours) * 60) + currentMinute - minute)}分钟前`;
} else {
// 修改 昨天发布的文章时间会出现负数
// 如果当前分钟小于发布分钟
if (currentMinute < minute) {
// 当前分钟加60
currentMinute += 60
}
// 显示分钟差
result = `${(currentMinute - minute)}分钟前`;
}
// 如果时间差小于1天
} else if ((currentTimer - timer) < 1000 * (24 * 60 * 60)) {
// 如果当前日期大于发布日期
if (currentDay > day) {
// 显示小时差
result = `${((currentDay - day) * 24 + currentHours - hours)}小时前`;
} else {
// 修改 跨月-昨天发布的文章时间会出现负数
// 如果当前月份不等于发布月份
if (currentMonth !== mouth) {
// 显示小时差
result = `${(24 + currentHours - hours)}小时前`;
} else {
// 显示小时差
result = `${(currentHours - hours)}小时前`;
}
}
// 如果发布年份等于当前年份
} else if (currentYear === year) {
// 显示月份和日期
result = `${mouth}月${day}日`;
} else {
// 显示年份、月份和日期
result = `${year}年${mouth}月${day}日`;
}
return result // 返回结果
}

View File

@ -0,0 +1,55 @@
<template>
<view
:to="'/uni_modules/uni-cms-article/pages/detail/detail?id=' + data?._id"
:key="data?._id"
class="list-item not-cover"
direction="column"
>
<view class="main">
<view>
<text class="title">{{ data?.title }}</text>
</view>
<view class="info">
<text class="author">{{ data!.user_id!.length > 0 ? data!.user_id[0]!.nickname : '' }}</text>
<text class="publish_date">{{ publishTime(data?.publish_date ?? 0) }}</text>
</view>
</view>
</view>
</template>
<script lang="uts">
import { type PropType } from 'vue'
import translatePublishTime from "@/uni_modules/uni-cms-article/common/publish-time.uts";
type ArticleAuthor = {
_id: string
nickname: string
}
type ArticleItem = {
_id: string
title: string
publish_date: number
thumbnail: string[]
user_id: ArticleAuthor[]
}
export default {
name: "not-cover",
props: {
data: {
type: Object as PropType<ArticleItem>
}
},
methods: {
// 格式化时间戳
publishTime(timestamp: number): string {
return translatePublishTime(timestamp)
},
}
}
</script>
<style scoped lang="scss">
@import "./style.scss";
</style>

View File

@ -0,0 +1,46 @@
<template>
<uni-list-item
:to="'/uni_modules/uni-cms-article/pages/detail/detail?id=' + data._id"
:key="data._id"
class="list-item not-cover"
direction="column"
>
<template v-slot:body>
<view class="main">
<view>
<text class="title">{{ data.title }}</text>
</view>
<view class="info">
<text class="author">{{ data.user_id[0] ? data.user_id[0].nickname : '' }}</text>
<text class="publish_date">{{ publishTime(data.publish_date) }}</text>
</view>
</view>
</template>
</uni-list-item>
</template>
<script>
import translatePublishTime from "@/uni_modules/uni-cms-article/common/publish-time";
export default {
name: "not-cover",
props: {
data: {
type: Object,
default: () => {
return {}
}
}
},
methods: {
// 格式化时间戳
publishTime(timestamp) {
return translatePublishTime(timestamp)
},
}
}
</script>
<style scoped lang="scss">
@import "./style.scss";
</style>

View File

@ -0,0 +1,50 @@
<template>
<view
:to="'/uni_modules/uni-cms-article/pages/detail/detail?id=' + data?._id"
:key="data?._id"
class="list-item"
>
<view class="main">
<text class="title">{{ data?.title }}</text>
<view class="info">
<text class="author">{{ data!.user_id!.length > 0 ? data!.user_id[0]!.nickname : '' }}</text>
<text class="publish_date">{{ publishTime(data?.publish_date ?? 0) }}</text>
</view>
</view>
<image class="thumbnail" :src="data!.thumbnail[0]" mode="aspectFill"></image>
</view>
</template>
<script lang="uts">
import { type PropType } from 'vue'
import translatePublishTime from "@/uni_modules/uni-cms-article/common/publish-time.uts";
type ArticleAuthor = {
_id: string
nickname: string
}
type ArticleItem = {
_id: string
title: string
publish_date: number
thumbnail: string[]
user_id: ArticleAuthor[]
}
export default {
name: "right-small-cover",
props: {
data: {
type: Object as PropType<ArticleItem>
}
},
methods: {
// 格式化时间戳
publishTime(timestamp: number): string {
return translatePublishTime(timestamp)
},
}
}
</script>
<style scoped lang="scss">
@import "./style.scss";
</style>

View File

@ -0,0 +1,46 @@
<template>
<uni-list-item
:to="'/uni_modules/uni-cms-article/pages/detail/detail?id=' + data._id"
:key="data._id"
class="list-item"
>
<template v-slot:body>
<view class="main">
<text class="title">{{ data.title }}</text>
<view class="info">
<text class="author">{{ data.user_id[0] ? data.user_id[0].nickname : '' }}</text>
<text class="publish_date">{{ publishTime(data.publish_date) }}</text>
</view>
</view>
</template>
<template v-slot:footer>
<image class="thumbnail" :src="data.thumbnail[0]" mode="aspectFill"></image>
</template>
</uni-list-item>
</template>
<script>
import translatePublishTime from "@/uni_modules/uni-cms-article/common/publish-time";
export default {
name: "right-small-cover",
props: {
data: {
type: Object,
default: () => {
return {}
}
}
},
methods: {
// 格式化时间戳
publishTime(timestamp) {
return translatePublishTime(timestamp)
},
}
}
</script>
<style scoped lang="scss">
@import "./style.scss";
</style>

View File

@ -0,0 +1,63 @@
.list-item {
&.not-cover {
.main {
.info {
margin-top: 20rpx;
}
}
}
.main {
display: flex;
justify-content: space-between;
flex-direction: column;
flex: 1;
.title {
font-size: 30rpx;
color: #333333;
}
.thumbnails {
margin: 20rpx 0;
display: flex;
align-items: center;
flex-direction: row;
.img {
flex: 1;
/* #ifndef APP-NVUE */
width: auto;
/* #endif */
height: 200rpx;
border-radius: 8rpx;
margin: 0 10rpx;
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
}
}
.info {
display: flex;
flex-direction: row;
}
.author,
.publish_date {
font-size: 24rpx;
color: #bbbbbb;
}
.publish_date {
margin-left: 14rpx;
}
}
.thumbnail {
width: 240rpx;
height: 160rpx;
margin-left: 20rpx;
border-radius: 8rpx;
}
}

View File

@ -0,0 +1,58 @@
<template>
<view
:to="'/uni_modules/uni-cms-article/pages/detail/detail?id=' + data?._id"
:key="data?._id"
class="list-item"
direction="column"
>
<view class="main">
<text class="title">{{ data?.title }}</text>
<view class="thumbnails">
<image
v-for="image in data?.thumbnail"
:src="image"
mode="aspectFill"
class="img"
></image>
</view>
<view class="info">
<text class="author">{{ data!.user_id!.length > 0 ? data!.user_id[0]!.nickname : '' }}</text>
<text class="publish_date">{{ publishTime(data?.publish_date ?? 0) }}</text>
</view>
</view>
</view>
</template>
<script lang="uts">
import { type PropType } from 'vue'
import translatePublishTime from "@/uni_modules/uni-cms-article/common/publish-time.uts";
type ArticleAuthor = {
_id: string
nickname: string
}
type ArticleItem = {
_id: string
title: string
publish_date: number
thumbnail: string[]
user_id: ArticleAuthor[]
}
export default {
name: "three-cover",
props: {
data: {
type: Object as PropType<ArticleItem>
}
},
methods: {
// 格式化时间戳
publishTime(timestamp: number): string {
return translatePublishTime(timestamp)
},
}
}
</script>
<style scoped lang="scss">
@import "./style.scss";
</style>

View File

@ -0,0 +1,52 @@
<template>
<uni-list-item
:to="'/uni_modules/uni-cms-article/pages/detail/detail?id=' + data._id"
:key="data._id"
class="list-item"
direction="column"
>
<template v-slot:body>
<view class="main">
<text class="title">{{ data.title }}</text>
<view class="thumbnails">
<image
v-for="image in data.thumbnail"
:src="image"
mode="aspectFill"
class="img"
></image>
</view>
<view class="info">
<text class="author">{{ data.user_id[0] ? data.user_id[0].nickname : '' }}</text>
<text class="publish_date">{{ publishTime(data.publish_date) }}</text>
</view>
</view>
</template>
</uni-list-item>
</template>
<script>
import translatePublishTime from "@/uni_modules/uni-cms-article/common/publish-time";
export default {
name: "three-cover",
props: {
data: {
type: Object,
default: () => {
return {}
}
}
},
methods: {
// 格式化时间戳
publishTime(timestamp) {
return translatePublishTime(timestamp)
},
}
}
</script>
<style scoped lang="scss">
@import "./style.scss";
</style>

View File

@ -0,0 +1,99 @@
<template>
<refresh @refresh="refresh" @pullingdown="onpullingdown" :display="showRefresh ? 'show' : 'hide'">
<view class="refreshBox">
<!-- 可以自己添加图片路径或base64实现图片 <image class="refreshImg" :src="config[state].img" mode="widthFix" resize="cover"></image> -->
<text class="refreshText">{{config[state].text}}</text>
</view>
</refresh>
</template>
<script>
export default {
data() {
return {
showRefresh:false, // 是否显示刷新
state:0 // 刷新状态0继续下拉执行刷新1释放立即刷新2正在加载中3加载成功
}
},
methods:{
// 下拉刷新回调函数
onpullingdown({pullingDistance,viewHeight}) {
if(pullingDistance < viewHeight){
this.state = 0 // 继续下拉执行刷新
}else{
this.state = 1 // 释放立即刷新
}
},
// 执行刷新
refresh(){
// console.log('refresh');
this.showRefresh = true // 显示刷新
this.state = 2 // 正在加载中
this.$emit('refresh') // 触发refresh事件
}
},
watch: {
// 监听loading变化
loading(loading, oldValue) {
if(!loading){
this.showRefresh = false // 隐藏刷新
this.state = 3 // 加载成功
}
}
},
props: {
loading: {
type:Boolean,
default(){
return false
}
},
config: {
type: Array,
default(){
return [
{
text:"继续下拉执行刷新",
img:""//可以自己添加图片路径或base64实现图片
},
{
text:"释放立即刷新",
img:""//可以自己添加图片路径或base64实现图片
},
{
text:"正在加载中",
img:""//可以自己添加图片路径或base64实现图片
},
{
text:"加载成功",
img:""//可以自己添加图片路径或base64实现图片
}
]
}
},
},
}
</script>
<style lang="scss" scoped>
.refreshBox{
width: 750rpx;
height: 50px;
justify-content: center;
align-items: center;
flex-direction: row;
/* #ifndef APP-PLUS */
margin-top: -50px;
/* #endif */
}
.refreshImg{
width: 55rpx;
height: 55rpx;
z-index: 111;
}
.refreshText{
font-size: 26rpx;
color: #999999;
padding-left: 6rpx;
}
</style>

View File

@ -0,0 +1,99 @@
<template>
<refresh @refresh="refresh" @pullingdown="onpullingdown" :display="showRefresh ? 'show' : 'hide'">
<view class="refreshBox">
<!-- 可以自己添加图片路径或base64实现图片 <image class="refreshImg" :src="config[state].img" mode="widthFix" resize="cover"></image> -->
<text class="refreshText">{{config[state].text}}</text>
</view>
</refresh>
</template>
<script>
export default {
data() {
return {
showRefresh:false, // 是否显示刷新
state:0 // 刷新状态0继续下拉执行刷新1释放立即刷新2正在加载中3加载成功
}
},
methods:{
// 下拉刷新回调函数
onpullingdown({pullingDistance,viewHeight}) {
if(pullingDistance < viewHeight){
this.state = 0 // 继续下拉执行刷新
}else{
this.state = 1 // 释放立即刷新
}
},
// 执行刷新
refresh(){
// console.log('refresh');
this.showRefresh = true // 显示刷新
this.state = 2 // 正在加载中
this.$emit('refresh') // 触发refresh事件
}
},
watch: {
// 监听loading变化
loading(loading, oldValue) {
if(!loading){
this.showRefresh = false // 隐藏刷新
this.state = 3 // 加载成功
}
}
},
props: {
loading: {
type:Boolean,
default(){
return false
}
},
config: {
type: Array,
default(){
return [
{
text:"继续下拉执行刷新",
img:""//可以自己添加图片路径或base64实现图片
},
{
text:"释放立即刷新",
img:""//可以自己添加图片路径或base64实现图片
},
{
text:"正在加载中",
img:""//可以自己添加图片路径或base64实现图片
},
{
text:"加载成功",
img:""//可以自己添加图片路径或base64实现图片
}
]
}
},
},
}
</script>
<style lang="scss" scoped>
.refreshBox{
width: 750rpx;
height: 50px;
justify-content: center;
align-items: center;
flex-direction: row;
/* #ifndef APP-PLUS */
margin-top: -50px;
/* #endif */
}
.refreshImg{
width: 55rpx;
height: 55rpx;
z-index: 111;
}
.refreshText{
font-size: 26rpx;
color: #999999;
padding-left: 6rpx;
}
</style>

View File

@ -0,0 +1,167 @@
<template>
<view
:class="classList"
v-if="imageData.image != ''"
>
<image
:src="imagePath"
:style="styles"
:alt="imageData.attributes.alt"
class="img"
mode="aspectFill"
@load="imageLoad"
@click="imagePreview"
></image>
</view>
</template>
<script lang="uts">
import {parseImageUrl} from "@/uni_modules/uni-cms-article/common/parse-image-url.uts";
import type {ParseImageUrlResult} from '@/uni_modules/uni-cms-article/common/parse-image-url.uts';
type ImageAttributes = {
customParams: string | null
width: number | null
height: number | null
alt: string | null
}
type ImageData = {
image: string
attributes: ImageAttributes
}
type ImageCalResult = {
width: number
height: number
}
export default {
name: "render-image",
emits: ['imagePreview'],
props: {
deltaOp: {
type: Object as UTSJSONObject,
default (): UTSJSONObject {
return {}
}
},
reset: {
type: Boolean,
default: false
}
},
data () {
return {
width: 0,
height: 0,
imagePath: ''
}
},
computed: {
imageData (): ImageData {
const insert = this.deltaOp!.getJSON('insert')! as UTSJSONObject
const attributes: UTSJSONObject | null = this.deltaOp!.getJSON('attributes')
console.log(insert, attributes)
return {
image: insert.getString('image')!,
attributes: {
customParams: attributes != null ? attributes.getString('data-custom'): null,
width: attributes != null ? attributes!.getNumber('width'): null,
height: attributes != null ? attributes!.getNumber('height'): null,
alt: attributes != null ? attributes!.getString('alt'): null,
}
} as ImageData
},
classList (): string[] {
return [
'image',
this.reset ? 'reset': ''
] as string[]
},
styles (): string {
let style = ""
if (this.width != 0) {
style += `;width:${this.width}px`
}
if (this.height != 0) {
style += `;height:${this.height}px`
}
return style
}
},
mounted () {
this.loadImagePath()
},
methods: {
async loadImagePath (): Promise<void> {
const {image, attributes} = this.imageData
const parseImages = await parseImageUrl({
insert: {image},
attributes: {
'data-custom': attributes.customParams != null ? attributes.customParams : ""
}
}, "editor")
if (parseImages != null) {
this.imagePath = parseImages[0].src
}
},
imagePreview () {
this.$emit('imagePreview', this.imageData.image)
},
// 图片加载完成
imageLoad(e: ImageLoadEvent) {
const recal = this.wxAutoImageCal(e.detail.width, e.detail.height, 15) // 计算图片宽高
// const image = this.imageData
// ::TODO 关注一下在多端得表现情况
// if (!image.data.attributes.width || Number(image.data.attributes.width) > recal.imageWidth) {
// 如果图片宽度不存在或者图片宽度大于计算出来的宽度,则设置图片宽高
this.width = recal.width
this.height = recal.height
// }
},
// 计算图片宽高
wxAutoImageCal(originalWidth: number, originalHeight: number, imagePadding: number): ImageCalResult {
// 获取系统信息
const systemInfo = uni.getSystemInfoSync()
let windowWidth: number;
// let windowHeight: number;
let autoWidth: number;
let autoHeight: number;
let results: ImageCalResult = {
width: 0,
height: 0
};
// 计算图片宽度
windowWidth = systemInfo.windowWidth - 2 * imagePadding;
// windowHeight = systemInfo.windowHeight;
if (originalWidth > windowWidth) {//在图片width大于手机屏幕width时候
autoWidth = windowWidth;
autoHeight = (autoWidth * originalHeight) / originalWidth;
results.width = autoWidth;
results.height = autoHeight;
} else {//否则展示原来的数据
results.width = originalWidth;
results.height = originalHeight;
}
return results;
}
}
}
</script>
<style scoped lang="scss">
.image {
margin-bottom: 40rpx;
&.reset {
margin-bottom: 0;
}
.img {
display: flex;
border-radius: 12rpx;
margin: 0 auto;
}
}
</style>

View File

@ -0,0 +1,131 @@
<template>
<view
:class="classList"
v-if="data.data"
>
<image
:src="imagePath"
:class="data.data.class"
:style="styles"
:alt="data.data.attributes.alt || ''"
class="img"
mode="aspectFill"
@load="imageLoad"
@click="imagePreview"
></image>
</view>
</template>
<script>
import {parseImageUrl} from "@/uni_modules/uni-cms-article/common/parse-image-url.js";
export default {
name: "render-image",
props: {
data: {
type: Object,
default () {
return {}
}
},
className: String,
reset: false
},
data () {
return {
width: 0,
height: 0,
imagePath: ''
}
},
computed: {
classList () {
return [
'image',
this.reset ? 'reset': '',
this.className
]
},
styles () {
let style = this.data.data.style
if (this.width) {
style += `;width:${this.width}px`
}
if (this.height) {
style += `;height:${this.height}px`
}
return style
}
},
mounted () {
this.loadImagePath()
},
methods: {
async loadImagePath () {
const parseImages = await parseImageUrl({
insert: {image: this.data.data.value},
attributes: this.data.data.attributes,
}, "editor")
this.imagePath = parseImages[0].src
},
imagePreview () {
uni.$emit('imagePreview', this.data.data.value)
},
// 图片加载完成
imageLoad(e) {
const recal = this.wxAutoImageCal(e.detail.width, e.detail.height, 15) // 计算图片宽高
// const image = this.data
// ::TODO 关注一下在多端得表现情况
// if (!image.data.attributes.width || Number(image.data.attributes.width) > recal.imageWidth) {
// 如果图片宽度不存在或者图片宽度大于计算出来的宽度,则设置图片宽高
this.width = recal.imageWidth
this.height = recal.imageHeight
// }
},
// 计算图片宽高
wxAutoImageCal(originalWidth, originalHeight, imagePadding = 0) {
// 获取系统信息
const systemInfo = uni.getSystemInfoSync()
let windowWidth = 0, windowHeight = 0;
let autoWidth = 0, autoHeight = 0;
let results = {};
// 计算图片宽度
windowWidth = systemInfo.windowWidth - 2 * imagePadding;
windowHeight = systemInfo.windowHeight;
if (originalWidth > windowWidth) {//在图片width大于手机屏幕width时候
autoWidth = windowWidth;
autoHeight = (autoWidth * originalHeight) / originalWidth;
results.imageWidth = autoWidth;
results.imageHeight = autoHeight;
} else {//否则展示原来的数据
results.imageWidth = originalWidth;
results.imageHeight = originalHeight;
}
return results;
}
}
}
</script>
<style scoped lang="scss">
.image {
margin-bottom: 40rpx;
&.reset {
margin-bottom: 0;
}
.img {
// #ifdef APP-PLUS
display: block;
// #endif
// #ifndef APP-PLUS
display: flex;
// #endif
border-radius: 12rpx;
margin: 0 auto;
}
}
</style>

View File

@ -0,0 +1,129 @@
<template>
<view class="content">
<template v-for="op in content">
<render-text
v-if="op.type === 'paragraph'"
:data="op.data"
:className="op.class"
:style="op.style"
></render-text>
<render-image
v-else-if="op.type === 'image' && op.data.length > 0"
:data="op.data[0]"
:className="op.class"
:style="op.style"
></render-image>
<render-list
v-else-if="op.type === 'list'"
:data="op.data"
:style="op.style"
></render-list>
<view
v-else-if="op.type === 'divider'"
class="divider"
></view>
<render-video
v-else-if="op.type === 'mediaVideo'"
:data="op.data"
></render-video>
<render-unlock-content
v-else-if="op.type === 'unlockContent'"
:adp-id="adConfig.adpId"
:watch-ad-unique-type="adConfig.watchAdUniqueType"
></render-unlock-content>
</template>
</view>
</template>
<script>
import {parseImageUrl} from "@/uni_modules/uni-cms-article/common/parse-image-url"
import text from './text.vue'
import image from './image.vue'
import video from './video.vue'
import list from './list.vue'
import unlockContent from './unlock-content.vue'
export default {
name: "render-article-detail",
props: {
content: {
type: Array,
default () {
return []
}
},
contentImages: {
type: Array,
default () {
return []
}
},
adConfig: {
type: Object,
default: {}
}
},
data() {
return {
articleImages: []
}
},
components: {
renderUnlockContent: unlockContent,
renderText: text,
renderImage: image,
renderList: list,
renderVideo: video
},
mounted() {
this.initImage()
},
beforeDestroy() {
uni.$off('imagePreview')
},
methods: {
// 初始化图片
async initImage() {
// 获取所有图片
const parseImages = await parseImageUrl(this.contentImages)
if (parseImages != null) {
this.articleImages = parseImages.map(image => image.src)
}
// 监听图片预览
uni.$on('imagePreview', this.imagePreview)
},
// 点击图片预览
imagePreview(src) {
if (src) {
uni.previewImage({
current: src.split('?')[0], // 当前显示图片的http链接
urls: this.articleImages // 需要预览的图片http链接列表
})
}
},
}
}
</script>
<style scoped lang="scss">
.content {
line-height: 1.75;
font-size: 32rpx;
margin-top: 40rpx;
padding: 0 30rpx 80rpx;
word-break: break-word;
color: #333;
}
.divider {
height: 1px;
background: #d8d8d8;
width: 100%;
margin: 40rpx 0;
}
</style>

View File

@ -0,0 +1,50 @@
<template>
<view :class="['list', data.type]">
<view class="list-item" v-for="(item, index) in data.items">
<text class="dot">{{data.type === 'ordered' ? `${index + 1}.` : '&#8226'}}</text>
<render-text :data="item.data" reset class="reset-default"></render-text>
</view>
</view>
</template>
<script>
import text from './text.vue'
export default {
name: "render-list",
props: {
data: {
type: Object,
default () {
return {}
}
}
},
components: {
renderText: text
},
methods: {
}
}
</script>
<style scoped lang="scss">
.list {
margin-bottom: 40rpx;
}
.list-item {
display: flex;
align-items: flex-start;
margin-bottom: 20rpx;
&:last-child {
margin-bottom: 0;
}
.dot {
margin-right: 10rpx;
}
}
.reset-default {
text-indent: 0;
flex: 1;
}
</style>

View File

@ -0,0 +1,160 @@
<template>
<view :class="classList">
<template v-for="item in data">
<text
v-if="item.type === 'text'"
:class="item.data.class"
:style="item.data.style"
class="text"
>
{{item.data.value}}
</text>
<text
v-if="item.type === 'link'"
:class="item.data.class"
:style="item.data.style"
class="link"
@click="goLink(item.data.attributes.link)"
>
{{item.data.value}}
</text>
<image-item v-else-if="item.type === 'image'" :data="item"></image-item>
<!-- #ifdef H5 -->
<br v-else-if="item.type === 'br'" class="br"/>
<!-- #endif -->
<!-- #ifndef H5 -->
<text v-else-if="item.type === 'br'" class="br">\n</text>
<!-- #endif -->
</template>
</view>
</template>
<script>
import ImageItem from './image.vue'
export default {
name: "render-text",
props: {
data: {
type: Array,
default () {
return []
}
},
className: String,
reset: Boolean
},
computed: {
classList () {
return [
'row-text',
this.className,
this.reset ? 'reset': ''
]
}
},
components: {
ImageItem
},
methods: {
show () {
uni.showToast({
title: 'test',
icon: 'none'
})
},
// 点击链接跳转
goLink(link) {
// 如果链接为空,则返回
if (!link) return
// #ifdef H5
// 在新窗口中打开链接
window.open(link, '_blank')
// #endif
// #ifdef MP
// 微信小程序不支持打开外链,复制链接到剪贴板
uni.setClipboardData({
data: link,
success: () => {
uni.showToast({
title: '链接已复制',
icon: 'none'
})
}
})
// #endif
// #ifdef APP
// 在webview中打开链接
uni.navigateTo({
url: `/uni_modules/uni-cms-article/pages/webview/webview?url=${encodeURIComponent(link)}`
})
// #endif
}
}
}
</script>
<style scoped lang="scss">
.row-text, .br {
margin-bottom: 40rpx;
&.reset {
margin-bottom: 0;
}
}
.header-1,
.header-2,
.header-3,
.header-4,
.header-5,
.header-6 {
font-weight: bold;
}
.header-1 {
font-size: 44rpx;
}
.header-2 {
font-size: 40rpx;
}
.header-3 {
font-size: 38rpx;
}
.header-4 {
font-size: 32rpx;
}
.header-5 {
font-size: 28rpx;
}
.header-6 {
font-size: 24rpx;
}
.bold {
font-weight: bold;
}
.italic {
font-style: italic;
}
.strike {
text-decoration: line-through;
}
.underline {
text-decoration: underline;
}
.link {
color: #0064f9;
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,216 @@
<template>
<view class="unlock-content">
<!-- #ifdef H5 -->
<!-- 等广告支持H5后优化-->
<button class="text" @click="callAd">请观看广告后解锁全文</button>
<!-- #endif -->
<!-- #ifndef H5 -->
<ad-rewarded-video ref="rewardedVideo" :adpid="adpId" :preload="false" :disabled="true" :loadnext="true"
:url-callback="urlCallback" @load="onAdLoad" @close="onAdClose" @error="onAdError"
v-slot:default="{ loading, error }">
<text v-if="error" class="text">广告加载失败</text>
</ad-rewarded-video>
<button v-if="!isLoadError" class="text" @click="callAd" :loading="adLoading">请观看广告后解锁全文</button>
<!-- #endif -->
</view>
</template>
<script>
// 实例化数据库
const db = uniCloud.database()
// 定义解锁记录表名
const unlockContentDBName = 'uni-cms-unlock-record'
export default {
name: "ad",
props: {
adpId: String,
watchAdUniqueType: {
type: String,
default: 'device'
},
},
data() {
return {
currentArticleId: '',
currentPageRoute: '',
adLoading: false,
isLoadError: false
}
},
computed: {
// 回调URL
urlCallback() {
return {
extra: JSON.stringify({
article_id: this.currentArticleId,
unique_id: this.uniqueId,
unique_type: this.watchAdUniqueType
})
}
},
// 是否通过设备观看
watchByDevice() {
return this.watchAdUniqueType === 'device'
},
// 是否通过用户观看
watchByUser() {
return this.watchAdUniqueType === 'user'
},
// 获取唯一ID
uniqueId() {
return this.watchByDevice ? uni.getSystemInfoSync().deviceId : uniCloud.getCurrentUserInfo().uid
}
},
// #ifndef H5
mounted() {
// 获取当前页面信息
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
this.currentArticleId = currentPage.options.id
this.currentPageRoute = currentPage.route
// 如果广告位ID未设置则提示广告无法正常加载
if (!this.adpId) {
uni.showModal({
content: '广告位ID未设置广告无法正常加载',
showCancel: false
})
} else {
// 加载广告
this.$refs.rewardedVideo.load()
}
},
// #endif
methods: {
// 调用广告
callAd() {
// #ifdef H5
// 如果在浏览器中则提示需在App或小程序中操作
return uni.showModal({
content: '需观看广告解锁内容, 但浏览器不支持广告播放, 请在App或小程序中操作',
showCancel: false
})
// #endif
if (this.watchByUser) {
// 登录跳转URL 请根据实际情况修改
const redirectUrl = '/uni_modules/uni-id-pages/pages/login/login-withoutpwd' + (this.currentPageRoute ? '?uniIdRedirectUrl=' + this.currentPageRoute + '?id=' + this.currentArticleId : '')
//::TODO 支持设备与用户
// 如果用户未登录,则提示需要登录
if (uniCloud.getCurrentUserInfo().tokenExpired < Date.now()) {
uni.showModal({
content: '请登录后操作',
success: ({ confirm }) => {
confirm && uni.redirectTo({
url: redirectUrl
});
}
})
}
}
// 显示广告
this.adLoading = true
this.$refs.rewardedVideo.show()
},
// 广告加载成功
onAdLoad() {
this.adLoading && this.$refs.rewardedVideo.show()
console.log('广告数据加载成功');
},
// 广告关闭
onAdClose(e) {
console.log('close', e)
const detail = e.detail
// 轮询3次每次1秒如果3秒内没有查询到解锁记录就提示解锁失败
let i = 3
uni.hideLoading()
this.adLoading = false
// detail.isEnded 为true 说明用户观看了完整视频
if (detail && detail.isEnded) {
uni.showLoading({
title: '正在解锁全文',
timeout: 7000
})
let queryResult = setInterval(async () => {
i--;
// 查询解锁记录
const res = await db.collection(unlockContentDBName).where({
unique_id: this.uniqueId,
article_id: this.currentArticleId,
}).get()
// 1. result.data.length 为0 说明没有解锁记录
// 2. i <= 0 说明已经轮询了3次还是没有解锁记录说明解锁失败
// 3. result.data.length && i > 0 说明已经解锁成功
if (i <= 0) {
console.log('解锁失败', i)
clearInterval(queryResult)
uni.hideLoading()
uni.showToast({
title: '解锁失败!',
icon: 'error',
duration: 2000
});
} else if (res.result && res.result.data.length) {
console.log('解锁成功', i)
clearInterval(queryResult)
uni.hideLoading()
uni.showToast({
title: '解锁成功!',
icon: 'success',
duration: 2000
});
uni.$emit('onUnlockContent')
}
}, 1500);
} else {
uni.showModal({
content: "请观看完整视频后解锁全文",
showCancel: false
})
}
},
onAdError(e) {
// uni.hideLoading()
// this.isLoadError = true
console.error('onaderror: ', e)
}
}
}
</script>
<style scoped lang="scss">
.unlock-content {
text-align: center;
padding: 160rpx 0 60rpx;
position: relative;
margin-top: -140rpx;
&:before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 160rpx;
background: linear-gradient(to bottom, transparent, #fff);
}
.text {
border: #f0f0f0 solid 1px;
display: inline-block;
background: #f6f6f6;
border-radius: 10rpx;
font-size: 34rpx;
color: #222;
}
}
</style>

View File

@ -0,0 +1,44 @@
<template>
<view class="video">
<video
class="v"
:src="data.attributes.src"
:poster="data.attributes.poster"
></video>
</view>
</template>
<script>
export default {
name: "render-video",
props: {
data: {
type: Object,
default () {
return {}
}
}
},
data () {
return {
width: 0,
height: 0,
}
},
methods: {
}
}
</script>
<style scoped lang="scss">
.video {
margin-bottom: 40rpx;
.v {
display: block;
width: 100%;
max-width: 500px;
margin: 0 auto;
border-radius: 8rpx;
}
}
</style>

View File

@ -0,0 +1,54 @@
<template>
<view>
<text class="uni-cms-article-icon" :style="{color, 'fontSize': size + 'px', lineHeight: size + 'px'}">{{iconCode}}</text>
</view>
</template>
<script lang="uts">
const icons: UTSJSONObject = {
search: "\ue654",
back: "\ue6b9",
scan: "\ue62a",
closeempty: "\ue66c",
trash: "\ue687",
reload: "\ue6b2",
eye: "\ue651",
'eye-slash': '\ue6b3'
}
export default {
name: 'uni-cms-article-icons',
props: {
type: {
type: String,
default: ""
},
color: {
type: String,
default: '#333333'
},
size: {
type: Number,
default: 16
}
},
computed: {
iconCode(): string {
return icons.getString(this.type) as string
}
}
}
</script>
<style>
@font-face {
font-family: "uni-cms-article-icons";
src: url('/uni_modules/uni-cms-article/static/uniicons.ttf');
}
.uni-cms-article-icon {
font-family: "uni-cms-article-icons";
font-style: normal;
}
</style>

View File

@ -0,0 +1,371 @@
<template>
<unicloud-db
ref='udb'
v-slot:default="{ data, pagination, hasMore, loading, error }"
:collection="collectionList"
:page-size="10"
:loadtime="loadTime"
orderby="publish_date desc"
@load="onListLoad"
@error="onListLoadError"
>
<view
v-if="networkType == 'none'"
class="error-box"
@click="checkNetwork"
>
<image class="disconnect-icon" src="/uni_modules/uni-cms-article/static/disconnection.png" mode="widthFix"></image>
<text class="tip-text">当前网络不可用请点击重试</text>
</view>
<list-view
v-else
class="list-view"
:scroll-y="true"
:refresher-enabled="refresherEnabled"
refresher-default-style="none"
:refresher-triggered="refresherTriggered"
@refresherpulling="refresherpulling"
@refresherrefresh="refresherrefresh"
@scrolltolower="scrolltolower"
>
<list-item slot="refresher" class="refresh-box">
<text class="text">{{ refreshText[refreshState] }}</text>
</list-item>
<!-- 列表渲染 -->
<list-item
v-for="item in articleList"
:class="['list-item', `list-item__thumbnail-${item.thumbnail.length}`]"
:key="item._id"
@click="goToDetailPage(item)"
>
<template v-if="item.thumbnail.length == 0">
<view class="list-item__content">
<view class="list-item__content-title">
<text class="text">{{ item.title }}</text>
</view>
<view class="list-item__content-info">
<view class="list-item__author">
<text class="text">{{ item!.user_id!.length > 0 ? item.user_id[0].nickname : '' }}</text>
</view>
<view class="list-item__publish-date">
<text class="text">{{ publishTime(item.publish_date) }}</text>
</view>
</view>
</view>
</template>
<template v-if="item.thumbnail.length == 1">
<view class="list-item__content">
<view class="list-item__content-title">
<text class="text">{{ item.title }}</text>
</view>
<view class="list-item__content-info">
<view class="list-item__author">
<text class="text">{{ item!.user_id!.length > 0 ? item.user_id[0].nickname : '' }}</text>
</view>
<view class="list-item__publish-date">
<text class="text">{{ publishTime(item.publish_date) }}</text>
</view>
</view>
</view>
<view class="list-item__thumbnails">
<image
v-for="image in item.thumbnail"
:src="image"
mode="aspectFill"
class="list-item__img"
></image>
</view>
</template>
<template v-if="item.thumbnail.length == 3">
<view class="list-item__content">
<view class="list-item__content-title">
<text>{{ item.title }}</text>
</view>
<view class="list-item__thumbnails">
<image
v-for="image in item.thumbnail"
:src="image"
mode="aspectFill"
class="list-item__img"
></image>
</view>
<view class="list-item__content-info">
<view class="list-item__author">
<text class="text">{{ item!.user_id!.length > 0 ? item.user_id[0].nickname : '' }}</text>
</view>
<view class="list-item__publish-date">
<text class="text">{{ publishTime(item.publish_date) }}</text>
</view>
</view>
</view>
</template>
</list-item>
<list-item class="load-state">
<text class="text">{{ loading ? '加载中...' : (hasMore ? '上拉加载更多' : '没有更多数据了') }}</text>
</list-item>
</list-view>
</unicloud-db>
</template>
<script lang="uts">
type ArticleAuthor = {
_id: string
nickname: string
}
type ArticleItem = {
_id: string
title: string
publish_date: number
thumbnail: string[]
user_id: ArticleAuthor[]
}
import {parseImageUrl} from "@/uni_modules/uni-cms-article/common/parse-image-url.uts";
import translatePublishTime from "@/uni_modules/uni-cms-article/common/publish-time.uts";
import type {ParseImageUrlResult} from '@/uni_modules/uni-cms-article/common/parse-image-url.uts'
export default {
name: "uni-cms-article-list",
emits: ['onRefresh', 'onLoadMore'],
props: {
collectionList: {
type: Array as any[],
default: (): any[] => []
},
loadTime: {
type: String,
default: 'auto'
},
refresherEnabled: {
type: Boolean,
default: false
}
},
data() {
return {
articleList: [] as ArticleItem[],
refresherTriggered: false,
refreshState: 0,
refreshText: [
'继续下拉执行刷新',
'释放立即刷新',
'正在加载中',
'加载成功'
],
networkType: ""
}
},
mounted () {
this.checkNetwork()
},
methods: {
checkNetwork() {
uni.getNetworkType({
success: (res) => {
this.networkType = res.networkType;
}
});
},
publishTime(timestamp: number): string {
return translatePublishTime(timestamp)
},
async onListLoad(data: UTSJSONObject[], ended: boolean, pagination: UTSJSONObject): Promise<void> {
const listData: ArticleItem[] = data.map((item: UTSJSONObject): ArticleItem => {
let articleItem: ArticleItem = {
_id: item.getString('_id')!,
title: item.getString('title')!,
publish_date: item.getNumber('publish_date')!,
thumbnail: [],
user_id: item.getArray<ArticleAuthor>('user_id')! as ArticleAuthor[]
}
if (typeof item.getAny('thumbnail') === 'string') {
articleItem.thumbnail = [item.getAny('thumbnail')! as string]
} else {
articleItem.thumbnail = item.getArray<string>('thumbnail')!
}
return articleItem
})
// 处理cloud://文件链接
for (let i = 0; i < listData.length; i++) {
const article = listData[i]
const parseImages = await parseImageUrl(article.thumbnail)
if (parseImages != null) {
article.thumbnail = parseImages.map((image: ParseImageUrlResult): string => image.src)
}
}
this.articleList = pagination.getNumber('current') == 1 ? listData : this.articleList.concat(listData)
},
refresherrefresh() {
this.refresherTriggered = true
this.refreshState = 2;
(this.$refs['udb'] as UniCloudDBElement)!.loadData({
clear: true,
success: (_: any) => {
this.refresherTriggered = false
this.refreshState = 3
}
})
},
refresherpulling(e: RefresherEvent) {
if (e.detail.dy.toDouble() == 0.0) {
this.refreshState = 0
} else if (e.detail.dy > 45) {
this.refreshState = 1
}
},
scrolltolower() {
(this.$refs['udb'] as UniCloudDBElement)!.loadMore()
},
reLoadList() {
(this.$refs['udb'] as UniCloudDBElement)!.loadData({
clear: true
})
},
goToDetailPage(article: ArticleItem) {
uni.navigateTo({
url: `/uni_modules/uni-cms-article/pages/detail/detail?id=${article._id}&title=${article.title}`
})
},
onListLoadError () {
this.checkNetwork()
}
}
}
</script>
<style scoped lang="scss">
.refresh-box {
display: flex;
align-items: center;
justify-content: center;
.text {
padding: 30rpx 0;
font-size: 26rpx;
color: #999999;
}
}
.error-box {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
margin: 0 auto;
align-items: center;
justify-content: center;
.disconnect-icon {
width: 200rpx;
margin: 0 auto;
}
.tip-text {
font-size: 26rpx;
color: #333;
margin-top: 40rpx;
}
}
.load-state {
height: 90rpx;
display: flex;
align-items: center;
justify-content: center;
.text {
font-size: 28rpx;
color: #999999;
}
}
.list-view {
height: 100%;
padding: 0 20rpx;
}
.list-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 0;
border-bottom: #f5f5f5 solid 1px;
&__thumbnail-1 {
.list-item__content-title {
height: 88rpx;
}
}
&__thumbnail-3 {
.list-item__thumbnails {
display: flex;
flex-direction: row;
margin: 20rpx -10rpx;
margin-left: -10rpx;
margin-bottom: 0;
}
.list-item__img {
margin: 0 10rpx;
flex: 1;
}
}
&__thumbnails {
margin-left: 20rpx;
}
&__img {
width: 240rpx;
height: 160rpx;
border-radius: 8rpx;
}
&__content {
display: flex;
justify-content: space-between;
flex-direction: column;
flex: 1;
height: 100%;
&-title {
overflow: hidden;
.text {
font-size: 30rpx;
color: #333333;
line-height: 44rpx;
}
}
&-info {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 20rpx;
}
}
&__author,
&__publish-date {
.text {
font-size: 24rpx;
color: #bbbbbb;
}
}
&__author {
margin-right: 14rpx;
}
}
</style>

View File

@ -0,0 +1,195 @@
<template>
<view class="search-bar">
<view :style="{ height: `${navBarHeight}px` }"></view>
<view class="search-bar__content" @click="goToSearchPage">
<view class="search-bar__left" v-if="!showPlaceholder">
<view class="back-icon">
<uni-cms-article-icons type="back" :size="26" color="#333" @click="back"></uni-cms-article-icons>
</view>
</view>
<view class="search-bar__center">
<uni-cms-article-icons type="search" :size="18" color="#c0c4cc"></uni-cms-article-icons>
<text class="search-bar__placeholder" v-if="showPlaceholder">请输入搜索内容</text>
<input
v-else
ref="search-input"
class="search-bar__input"
placeholder="请输入搜索内容"
v-model="searchVal"
confirm-type="search"
:focus="focus"
@confirm="confirm"
/>
<view class="clear-icon" v-if="hasSearchValue" @click="clear">
<uni-cms-article-icons type="closeempty" :size="12" color="#fff"></uni-cms-article-icons>
</view>
</view>
<view class="search-bar__right" v-if="!showPlaceholder">
<!-- <uni-cms-article-icons type="scan" :size="20" color="#c0c4cc" @click="scan"></uni-cms-article-icons>-->
<text class="search-bar__search-text" @click="confirm">搜索</text>
</view>
</view>
</view>
</template>
<script lang="uts">
// import parseScanResult from "@/uni_modules/uni-cms-article/common/parse-scan-result.uts";
export default {
name: 'search-bar',
emits: ['update:modelValue', 'clear', 'confirm'],
data() {
return {
navBarHeight: 44,
searchVal: ""
}
},
props: {
showPlaceholder: {
type: Boolean,
default: false
},
focus: {
type: Boolean,
default: false
},
modelValue: {
type: String,
default: ""
}
},
watch: {
searchVal(newValue) {
this.$emit('update:modelValue', newValue)
},
modelValue: {
immediate: true,
handler(newVal) {
this.searchVal = newVal
}
},
},
computed: {
hasSearchValue(): boolean {
return this.searchVal != ""
}
},
methods: {
back() {
// 获取当前页面数量
const pages = getCurrentPages()
// 定义文章列表页的路径
const pageUrl = '/uni_modules/uni-cms-article/pages/list/list'
// 如果当前页面数量大于1返回上一页
if (pages.length > 1) {
uni.navigateBack({})
} else { // 否则跳转到文章列表页
uni.redirectTo({
url: pageUrl,
fail: (e: RedirectToFail) => {
// 如果跳转失败说明当前页面是tabbar页面需要使用switchTab跳转
if (e.errMsg.indexOf('tabbar') !== -1) {
uni.switchTab({
url: pageUrl
})
}
}
})
}
},
clear() {
this.searchVal = '';
(this.$refs['search-input'] as Element).blur()
this.$emit('clear')
},
confirm() {
(this.$refs['search-input'] as Element).blur()
this.$emit('confirm', this.searchVal)
},
scan() {
// 扫码暂不支持
// uni.scanCode({
// onlyFromCamera: true,
// scanType: ["qrCode"],
// success: (e) => parseScanResult(e.result),
// fail: (e) => {
// console.error(e)
// }
// })
},
goToSearchPage() {
if (!this.showPlaceholder) return
uni.navigateTo({
url: '/uni_modules/uni-cms-article/pages/search/search'
})
},
hideKeyboard() {
(this.$refs['search-input'] as Element).blur()
}
}
}
</script>
<style lang="scss">
.search-bar {
background: #fff;
&__content {
display: flex;
flex-direction: row;
align-items: center;
padding: 10rpx 20rpx;
}
&__left {
margin-left: -20rpx;
}
&__center {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
background: #F8F8F8;
padding: 20rpx;
margin: 0 20rpx;
margin-left: 0;
border-radius: 40rpx;
flex: 1;
}
&__placeholder {
color: #c0c4cc;
font-size: 28rpx;
margin-left: 10rpx;
}
&__input {
margin-left: 10rpx;
flex: 1;
}
&__search-text {
color: #c0402b;
font-size: 28rpx;
}
}
.back-icon {
padding: 10rpx;
}
.clear-icon {
width: 30rpx;
height: 30rpx;
background: #c0c4cc;
border-radius: 15rpx;
margin-right: 0;
margin-left: 10rpx;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -0,0 +1,6 @@
{
"noData": "No Data",
"noNetwork": "Network error",
"toSet": "Go to settings",
"error": "error"
}

View File

@ -0,0 +1,6 @@
import en from './en.json'
import zhHans from './zh-Hans.json'
export default {
en,
'zh-Hans': zhHans
}

View File

@ -0,0 +1,6 @@
{
"noData": "暂无数据",
"noNetwork": "网络异常",
"toSet": "前往设置",
"error": "错误"
}

View File

@ -0,0 +1,3 @@
新增uni-load-state组件这是一个封装数据请求状态的组件。根据uniCloud-db组件提供的参数直接响应对应的效果。
包括加载中、当前页面为空、没有更多数据、上拉加载更多;
加载错误判断如果是断网就引导打开系统网络设置页面。恢复联网后自动触发networkResume方法。

View File

@ -0,0 +1,171 @@
<template>
<view @appear="appear">
<view v-if="state.error">
<view class="box" v-if="networkType == 'none'">
<image class="icon-image" src="/uni_modules/uni-cms-article/static/disconnection.png" mode="widthFix"></image>
<text class="tip-text">{{noNetwork}}</text>
<view class="btn btn-default" @click="openSettings">
<text class="btn-text">{{toSet}}</text>
</view>
</view>
<text class="error" v-else>{{error}}{{JSON.stringify(state.error)}}</text>
</view>
<template v-else>
<!-- #ifdef APP-NVUE -->
<text class="state-text">{{state.loading?'加载中...':(state.hasMore?'上拉加载更多':'没有更多数据了')}}</text>
<!-- #endif -->
<!-- #ifndef APP-NVUE -->
<uni-load-more class="uni-load-more" :status="state.loading?'loading':(state.hasMore?'hasMore':'noMore')"></uni-load-more>
<!-- #endif -->
</template>
</view>
</template>
<script>
import {
initVueI18n
} from '@dcloudio/uni-i18n'
import messages from './i18n/index.js'
const {
t
} = initVueI18n(messages)
export default {
name: "uni-load-state",
computed: {
noData() {
return t('noData')
},
noNetwork() {
return t('noNetwork')
},
toSet() {
return t('toSet')
},
error() {
return t('error')
}
},
data() {
return {
"networkType": ""
};
},
props: {
state: {
type: Object,
default () {
return {
"loading": true,
"hasMore": false,
"pagination": {
"pages": 0
},
"data": [],
"error": {}
}
}
}
},
mounted() {
uni.onNetworkStatusChange(({
networkType
}) => {
if (this.networkType == 'none' && networkType != 'none') { //之前没网现在有了
this.$emit('networkResume')
}
this.networkType = networkType;
});
uni.getNetworkType({
success: ({
networkType
}) => {
this.networkType = networkType;
}
});
},
methods: {
appear() {
if (!this.state.loading && this.state.hasMore) {
this.$emit('loadMore')
}
},
openSettings() {
if (uni.getSystemInfoSync().platform == "ios") {
var UIApplication = plus.ios.import("UIApplication");
var application2 = UIApplication.sharedApplication();
var NSURL2 = plus.ios.import("NSURL");
var setting2 = NSURL2.URLWithString("App-prefs:root=General");
application2.openURL(setting2);
plus.ios.deleteObject(setting2);
plus.ios.deleteObject(NSURL2);
plus.ios.deleteObject(application2);
} else {
var Intent = plus.android.importClass("android.content.Intent");
var Settings = plus.android.importClass("android.provider.Settings");
var mainActivity = plus.android.runtimeMainActivity();
var intent = new Intent(Settings.ACTION_SETTINGS);
mainActivity.startActivity(intent);
}
}
}
}
</script>
<style scoped>
.box {
flex: 1;
width: 700rpx;
flex-direction: column;
align-items: center;
justify-content: center;
}
.uni-load-more{
align-items: center;
justify-content: center;
}
.state-text {
text-align: center;
font-size: 26rpx;
width: 690rpx;
padding: 10rpx;
color: #999999;
}
.icon-image {
width: 300rpx;
}
.tip-text {
color: #999999;
font-size: 32rpx;
margin-bottom: 30rpx;
}
.btn {
padding: 5px 10px;
width: 128px;
flex-direction: row;
align-items: center;
justify-content: center;
text-align: center;
}
.btn-text {
color: #999999;
font-size: 15px;
}
.btn-default {
border-color: #999999;
border-style: solid;
border-width: 1px;
border-radius: 3px;
}
.error {
width: 690rpx;
color: #DD524D;
}
</style>

View File

@ -0,0 +1,35 @@
# uni-cms源码使用许可协议
2022年10月
本许可协议是数字天堂北京网络技术有限公司以下简称DCloud对其所拥有著作权的“DCloud uni-cms”以下简称软件提供的使用许可协议。
您对“软件”的复制、使用、修改及分发受本许可协议的条款的约束,如您不接受本协议,则不能使用、复制、修改本软件。
**授权许可范围**
a) 授予您永久性的、全球性的、免费的、非独占的、不可撤销的本软件的源码使用许可,您可以使用这些源码制作自己的应用。
b) 您只能在DCloud产品体系内使用本软件及其源码。您不能将源码修改后运行在DCloud产品体系之外的环境比如客户端脱离uni-app或服务端脱离uniCloud。
c) DCloud未向您授权商标使用许可。您在根据本软件源码制作自己的应用时需以自己的名义发布软件而不是以DCloud名义发布。
d) 本协议不构成代理关系。
DCloud的责任限制 “软件”在提供时不带任何明示或默示的担保。在任何情况下DCloud不对任何人因使用“软件”而引发的任何直接或间接损失承担责任不论因何种原因导致或者基于何种法律理论,即使其曾被建议有此种损失的可能性。
**您的责任限制**
a) 您需要在授权许可范围内使用软件。
b) 您在分发自己的应用时不得侵犯DCloud商标和名誉权利。
c) 您不得进行破解、反编译、套壳等侵害DCloud知识产权的行为。您不得利用DCloud系统漏洞谋利或侵害DCloud利益如您发现DCloud系统漏洞应第一时间通知DCloud。您不得进行攻击DCloud的服务器、网络等妨碍DCloud运营的行为。您不得利用DCloud的产品进行与DCloud争夺开发者的行为。
d) 如您违反本许可协议需承担因此给DCloud造成的损失。
本协议签订地点为中华人民共和国北京市海淀区。
根据发展DCloud可能会对本协议进行修改。修改时DCloud会在产品或者网页中显著的位置发布相关信息以便及时通知到用户。如果您选择继续使用本框架即表示您同意接受这些修改。
条款结束

View File

@ -0,0 +1,90 @@
{
"id": "uni-cms-article",
"displayName": "uni-cms-article",
"version": "1.0.16",
"description": "uni-cms的用户端包括文章展示、搜索、看广告解锁等功能",
"keywords": [
"uni-cms-article",
"cms",
"uni-cms",
"内容管理",
"文章"
],
"repository": "",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"type": "uniapp-template-page",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [
"uni-search-bar",
"uni-nav-bar",
"uni-list"
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "y"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "y"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "u",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@ -0,0 +1,223 @@
<template>
<scroll-view :scroll-y="true" v-if="!loading">
<view class="meta">
<view class="title">
<text class="text">{{ articleDetail.title }}</text>
</view>
<view class="excerpt">
<text class="text">{{ articleDetail.excerpt }}</text>
</view>
<view class="author">
<template v-if="articleDetail.user_id != null">
<text class="at">{{ articleDetail.user_id?.nickname ?? '' }}</text>
<text class="split">·</text>
</template>
<text class="date">{{ publishTime(articleDetail.publish_date as number) }}</text>
</view>
</view>
<view class="content" v-if="articleDetail.content != null">
<template v-for="(block, index) in articleDetail.content" :key="index">
<view v-if="block.type == 'mediaVideo'">
<video
style="width: 300px; height: 200px; margin: 0 auto 20px;"
:src="(block.data as UTSJSONObject).getJSON('attributes')!.getString('src')!"
:poster="(block.data as UTSJSONObject).getJSON('attributes')!.getString('poster')!"
></video>
</view>
<view v-if="block.type == 'divider'" class="divider"></view>
<view v-if="block.type == 'unlockContent'" class="unlock-content">
<button @click="unlockContent">请观看广告后解锁全文</button>
</view>
<rich-text v-if="block.type == 'rich-text'" :selectable="false" :nodes="block.data" @itemclick="richTextItemClick"></rich-text>
<render-image-component v-if="block.type == 'image'" :deltaOp="block.data" @image-preview="onImagePreview"></render-image-component>
</template>
</view>
</scroll-view>
</template>
<script lang="uts">
import translatePublishTime from "@/uni_modules/uni-cms-article/common/publish-time";
import RenderImageComponent from '@/uni_modules/uni-cms-article/components/render-article-detail/image.uvue'
type Author = {
_id: string
nickname: string
}
type Content = {
type: string
data: any
}
type Article = {
_id: string | null
title: string | null
content: Content[] | null
excerpt: string | null
publish_date: number | null
user_id: Author | null
thumbnail: string[] | null
content_images: string[] | null
}
const db = uniCloud.databaseForJQL()
const articleDBName = 'uni-cms-articles'
const userDBName = 'uni-id-users'
export default {
components: {
RenderImageComponent
},
data() {
return {
loading: true,
id: "", // 文章ID
title: "", // 文章标题
articleDetail: {} as Article, // 文章详情
// 广告相关配置
adpId: "", // TODO: 请填写广告位ID
watchAdUniqueType: "device" // TODO: 观看广告的唯一标识类型,可选值为 user 或者 deviceuser 表示用户唯一device 表示设备唯一
}
},
computed: {
where(): string {
//拼接where条件 查询条件 ,更多详见 https://uniapp.dcloud.net.cn/uniCloud/unicloud-db?id=jsquery
return `_id =="${this.id}"`
},
collection(): any[] {
return [
db.collection(articleDBName).where(this.where).field('user_id,thumbnail,excerpt,publish_date,title,content').getTemp(),
db.collection(userDBName).field('_id, nickname').getTemp()
]
}
},
onLoad(event: OnLoadOptions) {
if (event.has('id')) {
this.id = event.get('id') as string
this.load()
}
if (event.has('title')) {
uni.setNavigationBarTitle({
title: event.get('title') as string
})
}
},
methods: {
async load (): Promise<void> {
uni.showLoading({
title: "加载中..."
})
const articledb = db.collection(articleDBName).where(this.where).field('user_id,thumbnail,excerpt,publish_date,title,content').getTemp()
const userdb = db.collection(userDBName).field('_id, nickname').getTemp()
const res = await db.collection(articledb, userdb).get()
this.loadData(res.data)
this.loading = false
uni.hideLoading()
},
// 格式化发布时间
publishTime(timestamp: number): string {
return translatePublishTime(timestamp)
},
loadData(data: UTSJSONObject[]) {
if (data.length <= 0) return
const detail = data[0]
const user_id = detail.getArray<Author>('user_id')!;
this.articleDetail = {
title: detail.getString('title'),
content: detail.getArray<Content>('content'),
excerpt: detail.getString('excerpt'),
publish_date: detail.getNumber('publish_date'),
thumbnail: detail.getArray<string>('thumbnail'),
user_id: user_id.length > 0 ? user_id[0]: null,
content_images: detail.getArray<string>('content_images')
} as Article
this.title = detail.getString('title')!
uni.setNavigationBarTitle({
title: this.title
})
},
unlockContent () {
uni.showModal({
content: 'uni-app-x 暂不支持观看广告解锁全文',
showCancel: false
})
},
richTextItemClick (e: RichTextItemClickEvent) {
if (e.detail.href != null) {
uni.navigateTo({
url: `/uni_modules/uni-cms-article/pages/webview/webview?url=${encodeURIComponent(e.detail.href as string)}`
})
}
},
onImagePreview (url: string) {
const contentImages = this.articleDetail.content_images != null ? this.articleDetail.content_images: [] as string[]
uni.previewImage({
current: url, // 当前显示图片的http链接
urls: contentImages as string[] // 需要预览的图片http链接列表
})
}
}
}
</script>
<style scoped lang="scss">
.meta {
padding: 20rpx 30rpx 0;
position: relative;
z-index: 1;
.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;
}
}
}
.content {
margin-top: 40rpx;
padding: 0 30rpx 80rpx;
}
.divider {
height: 1px;
width: 100%;
background: rgba(0, 0, 0, 0.1);
}
</style>

View File

@ -0,0 +1,217 @@
<template>
<unicloud-db v-slot:default="{data, loading, error, options}" :collection="collection" :options="formData"
:getone="true" :where="where" :manual="true" ref="detail" foreignKey="uni-cms-articles.user_id"
@load="loadData"
class="article">
<template v-if="!loading && data">
<view class="meta">
<view class="title">
<text class="text">{{ data.title }}</text>
</view>
<view class="excerpt">
<text class="text">{{ data.excerpt }}</text>
</view>
<view class="author">
<template v-if="data.user_id[0]">
<text class="at">{{ data.user_id[0].nickname || '' }}</text>
<text class="split">·</text>
</template>
<text class="date">{{ publishTime(data.publish_date) }}</text>
</view>
</view>
<render-article-detail
:content="data.content"
:content-images="data.content_images"
:ad-config="{ adpId, watchAdUniqueType }"
></render-article-detail>
</template>
<view class="detail-loading" v-else>
<uni-icons type="spinner-cycle" size="35px"/>
</view>
</unicloud-db>
</template>
<script>
import uniNavBar from '@/uni_modules/uni-nav-bar/components/uni-nav-bar/uni-nav-bar.vue';
import renderArticleDetail from "@/uni_modules/uni-cms-article/components/render-article-detail/index.vue";
import translatePublishTime from "@/uni_modules/uni-cms-article/common/publish-time";
const db = uniCloud.database()
const articleDBName = 'uni-cms-articles'
const userDBName = 'uni-id-users'
export default {
components: {
uniNavBar,
renderArticleDetail
},
data() {
return {
id: "", // 文章ID
title: "", // 文章标题
formData: {}, // 表单数据
// 广告相关配置
adpId: "", // TODO: 请填写广告位ID
watchAdUniqueType: "device" // TODO: 观看广告的唯一标识类型,可选值为 user 或者 deviceuser 表示用户唯一device 表示设备唯一
}
},
computed: {
where() {
//拼接where条件 查询条件 ,更多详见 https://uniapp.dcloud.net.cn/uniCloud/unicloud-db?id=jsquery
return `_id =="${this.id}"`
},
collection() {
return [
db.collection(articleDBName).where(this.where).field('user_id,thumbnail,excerpt,publish_date,title,content').getTemp(),
db.collection(userDBName).field('_id, nickname').getTemp()
]
}
},
onReady() {
// 开始加载数据,修改 where 条件后才开始去加载 clinetDB 的数据 ,需要等组件渲染完毕后才开始执行 loadData所以不能再 onLoad 中执行
if (this.id) { // ID 不为空,则发起查询
this.$refs.detail.loadData()
} else {
uni.showToast({
icon: 'none',
title: 'id 不能为空'
})
}
},
onLoad(event) {
//获取文章id通常 id 来自上一个页面
if (event.id) {
this.id = event.id
}
// 监听解锁内容事件
uni.$on('onUnlockContent', this.onUnlockContent)
},
onUnload() {
// 页面卸载时,移除监听事件
uni.$off('onUnlockContent', this.onUnlockContent)
},
onPageScroll(e) {
// 根据滚动位置判断是否显示导航栏
if (e.scrollTop > 100) {
uni.setNavigationBarTitle({
title: this.title
})
} else {
uni.setNavigationBarTitle({
title: ''
})
}
},
methods: {
// 将时间戳转换为可读的时间格式
publishTime(timestamp) {
return translatePublishTime(timestamp)
},
// 将文章加入阅读历史
setReadHistory() {
// 获取阅读历史缓存,如果不存在则为空数组
const historyCache = uni.getStorageSync('readHistory') || []
// 过滤掉当前文章的阅读历史
const readHistory = historyCache.filter(item => item.article_id !== this.id)
// 将当前文章的阅读历史添加到数组最前面
readHistory.unshift({
article_id: this.id,
last_time: Date.now()
})
// 将更新后的阅读历史缓存到本地
uni.setStorageSync('readHistory', readHistory)
},
// 加载数据
loadData(data) {
// 设置文章标题
this.title = data.title
// 将文章添加进阅读历史
this.setReadHistory()
},
// 监听解锁内容事件,解锁内容后重新加载数据
async onUnlockContent() {
this.$refs.detail.loadData()
}
}
}
</script>
<style scoped lang="scss">
/* #ifdef APP-NVUE */
.article {
background-color: #fff;
}
/* #endif */
@mixin cp {
padding: 0 30rpx;
}
.detail-loading {
margin: 100rpx auto 0;
width: 35px;
height: 35px;
animation: rotate360 2s linear infinite;
}
@keyframes rotate360 {
0% {
transform: rotate(0deg);
transform-origin: center center;
}
100% {
transform: rotate(360deg);
transform-origin: center center;
}
}
.meta {
@include cp;
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;
}
}
}
</style>

View File

@ -0,0 +1,255 @@
<template>
<unicloud-db v-slot:default="{loading, error, options}" :collection="collection" :options="formData"
:getone="true" :where="where" :manual="true" ref="detail" foreignKey="uni-cms-articles.user_id"
@load="loadData"
class="article">
<template v-if="!loading && articleData">
<view class="preview-tip">此页面仅用于临时预览文章链接将会在短期内失效</view>
<view class="meta">
<view class="title">
<text class="text">{{ articleData.title }}</text>
</view>
<view class="excerpt">
<text class="text">{{ articleData.excerpt }}</text>
</view>
<view class="author">
<template v-if="articleData.user_id && articleData.user_id[0]">
<text class="at">{{ articleData.user_id[0].nickname || '' }}</text>
<text class="split">·</text>
</template>
<text class="date">{{ publishTime(articleData.publish_date) }}</text>
</view>
</view>
<render-article-detail
:content="articleData.content"
:content-images="articleData.content_images"
:ad-config="{ adpId, watchAdUniqueType }"
></render-article-detail>
</template>
<view class="detail-loading" v-else>
<uni-icons type="spinner-cycle" size="35px"/>
</view>
</unicloud-db>
</template>
<script>
import uniNavBar from '@/uni_modules/uni-nav-bar/components/uni-nav-bar/uni-nav-bar.vue';
import renderArticleDetail from "@/uni_modules/uni-cms-article/components/render-article-detail/index.vue";
import translatePublishTime from "@/uni_modules/uni-cms-article/common/publish-time";
const db = uniCloud.database()
const articleDBName = 'uni-cms-articles'
const userDBName = 'uni-id-users'
export default {
components: {
uniNavBar,
renderArticleDetail
},
data() {
return {
id: "", // 文章ID
title: "", // 文章标题
secret: "", // 文章预览密钥
formData: {}, // 表单数据
articleData: null, // 文章数据
// 广告相关配置
adpId: "", // TODO: 请填写广告位ID
watchAdUniqueType: "device" // TODO: 观看广告的唯一标识类型,可选值为 user 或者 deviceuser 表示用户唯一device 表示设备唯一
}
},
computed: {
where() {
//拼接where条件 查询条件 ,更多详见 https://uniapp.dcloud.net.cn/uniCloud/unicloud-db?id=jsquery
return `_id =="${this.id}" && preview_secret =="${this.secret}"`
},
collection() {
return [
db.collection(articleDBName).where(this.where).field('user_id,thumbnail,excerpt,publish_date,title,content,preview_secret,preview_expired,article_status').getTemp(),
db.collection(userDBName).field('_id, nickname').getTemp()
]
}
},
onReady() {
// 开始加载数据,修改 where 条件后才开始去加载 clinetDB 的数据 ,需要等组件渲染完毕后才开始执行 loadData所以不能再 onLoad 中执行
if (this.id) { // ID 不为空,则发起查询
this.$refs.detail.loadData()
} else {
uni.showToast({
icon: 'none',
title: 'id 不能为空'
})
}
},
onLoad(event) {
//获取文章id通常 id 来自上一个页面
if (event.id) {
this.id = event.id
this.secret = event.secret
}
// 监听解锁内容事件
uni.$on('onUnlockContent', this.onUnlockContent)
},
onUnload() {
// 页面卸载时,移除监听事件
uni.$off('onUnlockContent', this.onUnlockContent)
},
onPageScroll(e) {
// 根据滚动位置判断是否显示导航栏
if (e.scrollTop > 100) {
uni.setNavigationBarTitle({
title: this.title
})
} else {
uni.setNavigationBarTitle({
title: ''
})
}
},
methods: {
// 将时间戳转换为可读的时间格式
publishTime(timestamp) {
return translatePublishTime(timestamp)
},
// 加载数据
loadData(data) {
if (!data) {
return uni.showModal({
content: "文章不存在/预览密钥不存在",
showCancel: false,
success: () => {
// #ifdef H5
window.close()
// #endif
// #ifndef H5
uni.navigateBack()
// #endif
}
})
}
// 文章已发布,跳转到文章详情页
if (data.article_status === 1) {
uni.showToast({
icon: 'none',
title: '文章已发布'
})
uni.redirectTo({
url: `/uni_modules/uni-cms-article/pages/detail/detail?id=${this.id}`
})
return
}
// 预览已过期,提示用户
if (data.preview_expired < Date.now()) {
return uni.showModal({
content: "预览已失效",
showCancel: false,
success: () => {
// #ifdef H5
window.close()
// #endif
// #ifndef H5
uni.navigateBack()
// #endif
}
})
}
// 设置文章标题
this.title = data.title
this.articleData = data
},
// 监听解锁内容事件,解锁内容后重新加载数据
async onUnlockContent() {
this.$refs.detail.loadData()
}
}
}
</script>
<style scoped lang="scss">
/* #ifdef APP-NVUE */
.article {
background-color: #fff;
}
/* #endif */
@mixin cp {
padding: 0 30rpx;
}
.detail-loading {
margin: 100rpx auto 0;
width: 35px;
height: 35px;
animation: rotate360 2s linear infinite;
}
@keyframes rotate360 {
0% {
transform: rotate(0deg);
transform-origin: center center;
}
100% {
transform: rotate(360deg);
transform-origin: center center;
}
}
.meta {
@include cp;
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;
}
}
}
.preview-tip {
font-size: 13px;
color: #333;
background: #fcd791;
padding: 10px;
}
</style>

View File

@ -0,0 +1,298 @@
<template>
<view class="pages">
<view class="placeholder-bar">
<statusBar></statusBar>
<view :style="{ height: `${navBarHeight}px` }"></view>
</view>
<view class="nav-box">
<!-- #ifndef H5 -->
<statusBar></statusBar>
<!-- #endif -->
<view class="nav" :style="{ height: `${navBarHeight}px` }">
<!-- #ifdef MP -->
<view class="mp-button-left-placeholder" :style="{ width: `${mpButtonLeftPlaceholderSize}px` }"></view>
<!-- #endif -->
<!-- 搜索功能 -->
<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>
<!-- #ifdef MP -->
<view class="mp-button-placeholder" :style="{ width: `${mpButtonPlaceholderSize}px` }"></view>
<!-- #endif -->
</view>
</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">
<!-- 基于 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="item"></not-cover>
<right-small-cover v-else-if="item.thumbnail && item.thumbnail.length === 1"
:data="item"></right-small-cover>
<three-cover v-else-if="item.thumbnail && item.thumbnail.length === 3"
:data="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>
</template>
<script>
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: {
// 根据当前语言返回不同的搜索框占位符
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').getTemp(), // 文章集合
db.collection(userDBName).field('_id,nickname').getTemp() // 用户集合
]
}
},
data() {
return {
where: '"article_status" == 1', // 查询条件
showRefresh: false, // 是否显示刷新按钮
listHeight: 0, // 列表高度
mpButtonLeftPlaceholderSize: 0, // 小程序左侧icon占位大小
mpButtonPlaceholderSize: 87, // 小程序导航栏按钮占位大小
navBarHeight: 44, // 导航栏高度
refreshStatus: 0, // 刷新状态 0: 未刷新 1: 刷新中 2: 刷新完成
listData: [], // 列表数据
loadType: null
}
},
async onReady() {
// #ifdef MP
this.initNavBarSize() // 初始化导航栏大小
// #endif
/* 可用窗口高度 - 搜索框高 - 状态栏高 */
this.listHeight = uni.getSystemInfoSync().windowHeight - uni.getSystemInfoSync().statusBarHeight - this.navBarHeight + 'px'; // 计算列表高度
},
methods: {
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 parseImages = await parseImageUrl(article.thumbnail)
article.thumbnail = parseImages ? parseImages.map(image => image.src): []
}
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()
},
// 查询出错
onqueryerror(e) {
console.error(e);
}
},
// #ifdef H5
// 下拉刷新
onPullDownRefresh() {
this.refresh()
},
// #endif
}
</script>
<style 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;
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<view class="page">
<uni-cms-article-search-bar :show-placeholder="true"></uni-cms-article-search-bar>
<uni-cms-article-list
:collectionList="colList"
:refresherEnabled="true"
style="flex: 1;"
></uni-cms-article-list>
</view>
</template>
<script lang="uts">
const db = uniCloud.databaseForJQL()
export default {
computed: {
colList(): any[] {
return [
db.collection('uni-cms-articles').where("\"article_status\" == 1").field('thumbnail,title,publish_date,user_id').getTemp(), // 文章集合
db.collection('uni-id-users').field('_id,nickname').getTemp() // 用户集合
]
}
}
}
</script>
<style scoped lang="scss">
.page {
display: flex;
flex-direction: column;
height: 100%;
}
</style>

View File

@ -0,0 +1,752 @@
<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 -->
<!-- :cancelText="keyBoardPopup ? '取消' : '搜索'" -->
<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>
<uni-list v-else class="uni-list" :border="false" :style="{ height: listHeight }">
<!-- 列表渲染 -->
<uni-list-item :to="'/uni_modules/uni-cms-article/pages/detail/detail?id=' + item._id"
v-for="(item, index) in searchList" :key="index">
<!-- 通过header插槽定义列表左侧图片 -->
<template v-slot:header>
<image class="thumbnail" :src="item.thumbnail" mode="aspectFill"></image>
</template>
<!-- 通过body插槽定义布局 -->
<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>
<!-- -->
<!-- <uni-dateformat class="publish_date" :date="item.publish_date"-->
<!-- format="yyyy-MM-dd" :threshold="[60000, 2592000000]"/>-->
</view>
</view>
</template>
</uni-list-item>
<!-- 加载状态:上拉加载更多,加载中,没有更多数据了,加载错误 -->
<!-- #ifdef APP-PLUS -->
<uni-list-item>
<template v-slot:body>
<!-- #endif -->
<uni-load-state @networkResume="refresh" :state="{ data: searchList, pagination, hasMore, loading, error }"
@loadMore="loadMore">
</uni-load-state>
<!-- #ifdef APP-PLUS -->
</template>
</uni-list-item>
<!-- #endif -->
</uni-list>
</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 uniCloud云端一体搜索模板自带下拉候选、历史搜索、热搜。无需再开发服务器代码
*/
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'; // 联想时,搜索框值检索数据库字段名
const associativeField = '_id,title'; // 联想列表每一项携带的字段
const localSearchListKey = '__local_search_history'; // 本地历史存储字段名
const db = uniCloud.database();
const articleDBName = 'uni-cms-articles'
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) {
/**
*
* @param {要执行的函数} fn
* @param {在操作多长时间后可再执行,第一次立即执行} interval
*/
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)
// return false;
}
timer = setTimeout(function () {
clearTimeout(timer);
timer = null;
_self.apply(_me, args);
}, interval || 200);
}
}
export default {
// 组件数据
data() {
return {
// 文章数据库名称
articleDbName,
// 搜索记录数据库名称
searchLogDbName,
// 状态栏高度
statusBarHeight: '0px',
// 本地搜索列表
localSearchList: uni.getStorageSync(localSearchListKey),
// 是否删除本地搜索列表
localSearchListDel: false,
// 是否隐藏网络热搜列表
netHotListIsHide: false,
// 搜索文本
searchText: '',
// 图标颜色
iconColor: '#999999',
// 联想列表
associativeList: [],
// 是否弹出键盘
keyBoardPopup: false,
// 搜索热词
hotWorld: 'DCloud', // 搜索热词,如果没有输入即回车,则搜索热词,但是不会加入搜索记录
// 是否自动聚焦
focus: true,
// 语音识别引擎
speechEngine: 'iFly', // 语音识别引擎 iFly 讯飞 baidu 百度
// 是否正在加载数据
isLoadData: false,
// 数据库查询条件
where: '"article_status" == 1',
// 列表高度
listHeight: 0,
// 是否显示联想列表
associativeShow: false,
// 是否显示无联想列表
noAssociativeShow: false,
// 搜索结果列表
searchList: []
}
},
// 组件创建时执行
created() {
// 初始化数据库
this.db = uniCloud.database();
this.searchLogDb = this.db.collection(this.searchLogDbName);
this.articleDbName = this.db.collection(this.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() {
// #ifdef APP-NVUE
/* 可用窗口高度 - 搜索框高 - 状态栏高 */
this.listHeight = uni.getSystemInfoSync().windowHeight + 'px';
// #endif
// #ifndef APP-NVUE
this.listHeight = 'auto'
// #endif
},
// 页面加载时执行
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
// this.loadList();
},
// 执行搜索
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
},
// 添加搜索记录
searchLogDbAdd(value) {
/*
在此处存搜索记录,如果登录则需要存 user_id若未登录则存device_id
*/
this.getDeviceId().then(device_id => {
this.searchLogDb.add({
// user_id: device_id,
device_id,
content: value,
create_date: Date.now()
})
})
},
// 获取设备ID
getDeviceId() {
return new Promise((resolve, reject) => {
// 从本地缓存中获取uni_id
const uniId = uni.getStorageSync('uni_id');
// 如果uni_id不存在则获取设备信息
if (!uniId) {
// #ifdef APP-PLUS
plus.device.getInfo({
success: (deviceInfo) => {
resolve(deviceInfo.uuid)
},
fail: () => {
// 如果获取设备信息失败,则返回一个随机字符串
resolve(uni.getSystemInfoSync().system + '_' + Math.random().toString(36).substr(2))
}
});
// #endif
// #ifndef APP-PLUS
// 如果不是APP-PLUS则返回一个随机字符串
resolve(uni.getSystemInfoSync().system + '_' + Math.random().toString(36).substr(2))
// #endif
} else {
// 如果uni_id存在则直接返回uni_id
resolve(uniId)
}
})
},
// 点击联想词
associativeClick(item) {
/**
* 注意:这里用户根据自己的业务需要,选择跳转的页面即可
*/
console.log("associativeClick: ", item, item.title);
// 隐藏联想词
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
// 延迟0ms后加载数据
setTimeout(() => {
this.$refs.listUdb.loadData({
clear: true
})
}, 0)
},
// 数据库加载完成
async onDbLoad(data) {
console.log('onDbLoad')
// 设置数据已加载标志
this.isLoadData = true
// 显示联想词
this.noAssociativeShow = false;
for (const article of data) {
const parseImages = await parseImageUrl(article.thumbnail)
article.thumbnail = parseImages ? parseImages.map(image => image.src): []
}
this.searchList = data
},
// 查询错误
onqueryerror(e) {
console.error(e);
},
// 刷新
refresh() {
// 刷新数据
this.$refs.listUdb.loadData({
clear: true
}, () => {
// 停止下拉刷新
uni.stopPullDownRefresh()
// #ifdef APP-NVUE
// 隐藏刷新按钮
this.showRefresh = false
// #endif
})
},
// 加载更多
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) {
// 根据搜索框的文本,查询联想词
this.articleDbName.where({
[associativeSearchField]: new RegExp(value, 'gi'),
}).field(associativeField).get().then(res => {
// 将查询结果赋值给联想词列表,并显示联想词
this.associativeList = res.result.data;
this.associativeShow = true
})
} else {
// 如果搜索框的文本为空,则清空联想词列表
this.associativeList = [];
}
}, 100)
}
}
</script>
<style>
/* #ifndef APP-NVUE */
page {
height: 100%;
flex: 1;
}
/* #endif */
</style>
<style lang="scss" scoped>
$search-bar-height: 52px;
$word-container_header-height: 72rpx;
.status-bar {
background-color: #fff;
}
.container {
/* #ifndef APP-NVUE */
height: 100%;
/* #endif */
flex: 1;
background-color: #f7f7f7;
}
.search-body {
background-color: #fff;
border-bottom-right-radius: 10px;
border-bottom-left-radius: 10px;
}
@mixin uni-flex {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
}
@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;
}
/* #ifdef APP-PLUS */
/* #ifndef APP-NVUE || VUE3*/
::v-deep
/* #endif */
.uni-searchbar {
padding-left: 0;
}
/* #endif */
/* #ifndef APP-NVUE || VUE3*/
::v-deep
/* #endif */
.uni-searchbar__box {
border-width: 0;
}
/* #ifndef APP-NVUE || VUE3 */
::v-deep
/* #endif */
.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 {
/* #ifndef APP-NVUE */
overflow-y: auto;
/* #endif */
position: absolute;
top: $search-bar-height;
left: 0;
right: 0;
bottom: 0;
background-color: #fff;
margin-top: 10rpx;
padding-left: 10rpx;
padding-right: 10rpx;
}
.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;
/* #ifndef APP-NVUE */
box-sizing: border-box;
/* #endif */
text-align: center;
}
#{&}-info {
/* #ifndef APP-NVUE */
display: block;
/* #endif */
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>

View File

@ -0,0 +1,363 @@
<template>
<view class="container">
<view class="search">
<uni-cms-article-search-bar
ref="searchBar"
v-model="searchVal"
:show-placeholder="false"
:focus="true"
@clear="showSearchResultPanel = false"
@confirm="search"
></uni-cms-article-search-bar>
</view>
<view class="search-result" v-if="showSearchResultPanel">
<uni-cms-article-list
ref="articleList"
:collectionList="colList"
:refresherEnabled="false"
loadTime="manual"
style="flex: 1;"
></uni-cms-article-list>
</view>
<template v-else>
<view class="panel history-panel" v-if="searchHistory.length > 0">
<view class="panel__title">
<view class="panel__title-text">
<text class="text">搜索历史</text>
</view>
<view class="delete-history-btns" v-if="deleteHistoryLoading">
<text class="text" @click="deleteAllSearchHistory">全部删除</text>
<text class="text danger" @click="deleteHistoryLoading = false">完成</text>
</view>
<uni-cms-article-icons
class="panel__after-icon"
type="trash"
:size="18"
color="#999"
@click="deleteHistoryLoading = true"
v-else
></uni-cms-article-icons>
</view>
<view class="panel__list">
<view class="panel__list-item" v-for="text in searchHistory">
<text class="text" @click="search(text)">{{text}}</text>
<uni-cms-article-icons
class="icon"
type="closeempty"
:size="12"
color="#999"
v-if="deleteHistoryLoading"
@click="deleteSearchHistory(text)"
></uni-cms-article-icons>
</view>
</view>
</view>
<unicloud-db ref="udb" #default="{ data, loading, error }" field="content"
collection="opendb-search-hot" orderby="create_date desc,count desc" page-data="replace"
:page-size="10">
<view class="panel recommend-panel">
<view class="panel__title">
<view class="panel__title-text">
<text class="text">搜索发现</text>
<uni-cms-article-icons
class="icon"
type="reload"
:size="14"
color="#999"
v-if="!hideSearchRecommend"
@click="reLoadSearchRecommend"
></uni-cms-article-icons>
</view>
<uni-cms-article-icons
class="panel__after-icon"
:type="hideSearchRecommend ? 'eye-slash': 'eye'"
:size="18"
color="#999"
@click="hideSearchRecommend = !hideSearchRecommend"
></uni-cms-article-icons>
</view>
<view class="panel__list">
<view class="panel__list-tip" v-if="loading">
<text class="text">正在加载...</text>
</view>
<view class="panel__list-tip" v-else-if="error != null">
<text class="text">{{error.message}}</text>
</view>
<view class="panel__list-tip" v-else-if="hideSearchRecommend">
<text class="text">当前搜索发现已隐藏</text>
</view>
<template v-else>
<view
class="panel__list-item"
v-for="(word, index) in data"
:key="index"
@click="search(word.getString('content')!)"
>
<text class="text">{{ word.getString('content') }}</text>
</view>
</template>
</view>
</view>
</unicloud-db>
</template>
</view>
</template>
<script lang="uts">
type ArticleAuthor = {
_id: string
nickname: string
}
type ArticleItem = {
_id: string
title: string
publish_date: number
thumbnail: string[]
user_id: ArticleAuthor[]
}
const db = uniCloud.databaseForJQL()
const searchLogDB = db.collection('opendb-search-log')
const cmsArticleDB = db.collection('uni-cms-articles')
const uniIdUsersDB = db.collection('uni-id-users')
const localSearchHistoryKey = '__local_search_history'; // 本地历史存储字段名
const localSearchRecommendHiddenKey = '__local_search_recommend_hidden'; // 本地搜索发现开关字段名
const localSearchHistoryMax = 10; // 本地历史存储最大值
export default {
data() {
const localSearchRecommendHidden = uni.getStorageSync(localSearchRecommendHiddenKey)
return {
searchVal: "",
searchHistory: [] as string[],
searchRecommend: [] as string[],
deleteHistoryLoading: false,
hideSearchRecommend: (localSearchRecommendHidden == "" ? false : localSearchRecommendHidden) as boolean,
showSearchResultPanel: false,
searchResult: [] as ArticleItem[]
}
},
watch: {
hideSearchRecommend(newValue) {
uni.setStorageSync(localSearchRecommendHiddenKey, newValue)
}
},
computed: {
hasSearchValue(): boolean {
return this.searchVal != ""
},
where(): string {
let where = "\"article_status\" == 1"
if (this.searchVal != "") {
where += `&& /${this.searchVal}/.test(title)`
}
return where
},
colList(): any[] {
// 返回文章和用户列表
return [
cmsArticleDB.where(this.where).field('thumbnail,title,publish_date,user_id').getTemp(),
uniIdUsersDB.field('_id,nickname').getTemp()
]
}
},
mounted() {
// 本地历史存储
const localSearchHistory = uni.getStorageSync(localSearchHistoryKey)
this.searchHistory = (localSearchHistory == "" ? [] as string[] : localSearchHistory) as string[]
},
methods: {
deleteAllSearchHistory() {
uni.showModal({
title: "确定清空搜索历史吗",
confirmText: "删除",
success: (res) => {
if (res.confirm) {
this.deleteSearchHistory(null)
}
}
})
},
deleteSearchHistory(searchText: string | null) {
let history: string[] = []
if (searchText != null) {
history = this.searchHistory.filter((item: string): boolean => item != searchText)
}
this.searchHistory = history
uni.setStorageSync(localSearchHistoryKey, history)
console.log(history.length, 'history.length')
if (history.length <= 0) {
this.deleteHistoryLoading = false
}
},
search(searchText: string) {
searchText = searchText.trim()
if (searchText == "" || this.deleteHistoryLoading) return
// 隐藏键盘
;(this.$refs['searchBar'] as UniCmsArticleSearchBarComponentPublicInstance)!.hideKeyboard()
// 保存搜索历史
this.setLocalSearchHistory(searchText)
// 显示搜索结果Panel
this.showSearchResultPanel = true
// 搜索
this.loadSearchResult(searchText)
// 添加搜索记录
this.addSearchRecord(searchText)
},
loadSearchResult(searchText: string) {
// 设置查询条件
this.searchVal = searchText
// 延迟0ms后加载数据
setTimeout(() => {
(this.$refs['articleList'] as UniCmsArticleListComponentPublicInstance)!.reLoadList()
}, 0)
},
addSearchRecord(searchText: string) {
const systemInfo = uni.getSystemInfoSync()
/*
在此处存搜索记录,如果登录则需要存 user_id若未登录则存device_id
*/
searchLogDB.add({
// user_id: device_id,
device_id: systemInfo.deviceId,
// device_uuid: systemInfo.deviceId,
content: searchText,
create_date: Date.now()
})
},
setLocalSearchHistory(searchText: string) {
const history = this.searchHistory.filter((item: string): boolean => item != searchText)
history.unshift(searchText)
if (history.length > localSearchHistoryMax) {
history.pop()
}
this.searchHistory = history
this.deleteHistoryLoading = false
uni.setStorageSync(localSearchHistoryKey, history)
},
reLoadSearchRecommend() {
(this.$refs['udb'] as UniCloudDBElement)!.loadData({
clear: true
})
}
}
}
</script>
<style lang="scss">
.container {
display: flex;
flex-direction: column;
height: 100%;
.search-result {
flex: 1;
display: flex;
flex-direction: column;
}
}
.panel {
margin-top: 40rpx;
padding: 10rpx 20rpx;
&__title {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
&-text {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
.text {
color: #3e3e3e;
font-size: 30rpx;
line-height: 36rpx;
font-weight: bold;
}
.icon {
margin-left: 10rpx;
}
}
}
&__after-icon {
margin-left: 10rpx;
}
&__list {
margin-top: 30rpx;
display: flex;
flex-direction: row;
flex-wrap: wrap;
&-item {
background-color: #f6f6f6;
border-radius: 30rpx;
padding: 10rpx 20rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-right: 10rpx;
margin-bottom: 10rpx;
.text {
font-size: 26rpx;
color: #666;
}
.icon {
margin-left: 10rpx;
}
}
&-tip {
display: flex;
flex-direction: row;
justify-content: center;
flex: 1;
margin-top: 20rpx;
.text {
font-size: 26rpx;
color: #808080;
}
}
}
}
.delete-history-btns {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
.text {
color: #666;
font-size: 22rpx;
margin-left: 20rpx;
&.danger {
color: #c0402b;
}
}
}
</style>

View File

@ -0,0 +1,48 @@
<!-- 网络链接内容展示页用于uni-cms-article展示外链内容 -->
<template>
<view class="web-view">
<web-view
class="web-view"
v-if="url"
:src="url"
></web-view>
</view>
</template>
<script lang="uts">
export default {
onLoad(e) {
const url = e.get('url') as string
let title: string | null = e.get('title')
if (url.substring(0, 4) != 'http') {
uni.showModal({
title: "错误",
content: '不是一个有效的网站链接,' + '"' + decodeURIComponent(url) + '"',
showCancel: false,
confirmText: "知道了",
complete: () => {
uni.navigateBack()
}
})
title = "页面路径错误"
} else {
this.url = decodeURIComponent(url)
}
if (title != null) {
uni.setNavigationBarTitle({title})
}
},
data() {
return {
url: null as string | null,
}
}
}
</script>
<style>
.web-view {
height: 100%;
}
</style>

View File

@ -0,0 +1,35 @@
<!-- 网络链接内容展示页用于uni-cms-article展示外链内容 -->
<template>
<view>
<web-view v-if="url" :src="url"></web-view>
</view>
</template>
<script>
export default {
onLoad({url,title}) {
if(url.substring(0, 4) !== 'http'){
uni.showModal({
title:"错误",
content: '不是一个有效的网站链接,'+'"'+url+'"',
showCancel: false,
confirmText:"知道了",
complete: () => {
uni.navigateBack()
}
});
title = "页面路径错误"
}else{
this.url = url;
}
if(title){
uni.setNavigationBarTitle({title});
}
},
data() {
return {
url:null
};
}
}
</script>

View File

@ -0,0 +1,24 @@
# uni-cms-article
uni-CMS是基于uniCloud开发的uni-admin插件可用于快速搭建CMS内容管理系统。
客户端可使用 uni-cms-article 插件进行内容展示,无需开发,即可在多端展示内容。
![](https://web-assets.dcloud.net.cn/unidoc/zh/202304120145412.png)
![](https://web-assets.dcloud.net.cn/unidoc/zh/202304111812979.png)
<div style="display: flex; flex-basis: 10px">
<div style="margin-right: 10px;">
<img src="https://web-assets.dcloud.net.cn/unidoc/zh/202304120144625.png" width="375"/>
</div>
<div style="margin-right: 10px;">
<img src="https://web-assets.dcloud.net.cn/unidoc/zh/202304120139988.png" width="375" />
</div>
<div style="margin-right: 10px;">
<img src="https://web-assets.dcloud.net.cn/unidoc/zh/202304120139209.png" width="375" />
</div>
</div>
---
## 详细文档请在 [uni-cms 文档](https://uniapp.dcloud.net.cn/uniCloud/uni-cms.html) 中查看

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,257 @@
const {
QuillDeltaToHtmlConverter: QuillDeltaToHtmlConverterBase,
BlockGroup,
ListGroup,
BlotBlock,
} = require('./core')
const jsonRules = {
list(list) {
const firstItem = list.items[0];
return {
type: "list",
data: {
type: firstItem.item.op.attributes.list,
items: list.items.map((item) => jsonRules.listItem(item)),
},
attributes: firstItem.item.op.attributes,
class: attributes2class(firstItem.item.op.attributes, 'block'),
style: attributes2style(firstItem.item.op.attributes, 'block'),
};
},
listItem(listItem) {
// listItem.item.op.attributes.indent = 0;
const inlines = jsonRules.inlines(listItem.item.op, listItem.item.ops, false).map((v) => v.ops)
return {
data: inlines[0] || [],
children: listItem.innerList ? jsonRules.list(listItem.innerList): [],
}
},
block (op, ops) {
const type = ops[0].insert.type
if (type === 'text') {
return jsonRules.inlines(op, ops)
}
return {
type: ops[0].insert.type,
data: ops.map(bop => jsonRules.inline(bop)),
attributes: op.attributes,
class: attributes2class(op.attributes, 'block'),
style: attributes2style(op.attributes, 'block'),
}
},
inlines(op = {}, ops, isInlineGroup = true) {
const opsLen = ops.length - 1;
const br = {
type: 'br'
}
const texts = ops.reduce((acc, op, i) => {
if (i > 0 && i === opsLen && op.isJustNewline()) {
acc[acc.length - 1].op = op
return acc;
}
if (!acc[acc.length - 1]) {
acc.push({ops: [], op: {}});
}
if (op.isJustNewline()) {
const nextOp = ops[i + 1];
acc[acc.length - 1].op = op
if (nextOp && nextOp.isJustNewline()) {
acc.push({ops: [br], op: {}});
} else {
acc.push({ops: [], op: {}});
}
return acc;
} else {
acc[acc.length - 1].ops.push(jsonRules.inline(op));
return acc;
}
}, []);
if (!isInlineGroup) {
return texts;
}
return texts.map((v) => {
return {
type: "paragraph",
data: v.ops,
class: attributes2class(op.attributes || v.op.attributes, 'block'),
style: attributes2style(op.attributes || v.op.attributes, 'block'),
};
});
},
inline (op) {
const data = {
value: op.insert.value,
attributes: op.attributes,
class: attributes2class(op.attributes, 'inline'),
style: attributes2style(op.attributes, 'inline'),
}
if (op.isCustomEmbed()) {
return {
type: op.insert.type,
data
}
}
if (op.isLink()) {
return {
type: 'link',
data
}
}
return {
type: op.isImage() ? "image": "text",
data
}
},
blotBlock (op) {
return {
type: op.insert.type,
data: {
value: op.insert.value,
attributes: op.attributes,
class: attributes2class(op.attributes, 'block'),
style: attributes2style(op.attributes, 'block'),
}
}
}
};
function attributes2style(attributes, type) {
if (!attributes) return ''
// 定义允许的属性
const allowAttr = {
inline: ['align', 'color', 'background', 'font', 'fontSize', 'fontStyle', 'fontVariant', 'fontWeight', 'fontFamily', 'lineHeight', 'letterSpacing', 'textDecoration', 'textIndent', 'wordWrap', 'wordBreak', 'whiteSpace'],
block: ['align', 'margin', 'marginTop', 'marginBottom', 'marginLeft', 'marginRight', 'padding', 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight', 'lineHeight', 'textIndent']
}[type]
// 定义属性适配器
const adp = {
align: 'text-align',
}
// 如果不支持该类型的属性,则抛出错误
if (!allowAttr) throw new Error('type not supported')
// 将属性转换为style
return allowAttr.reduce((res, item) => {
// 如果属性为空,则返回空字符串
if (!attributes) return res
// 如果属性不为undefined则将属性名和属性值添加到style列表中
if (attributes[item] !== undefined) {
// 如果属性适配器中存在该属性,则使用适配器中的属性名
// 否则,将属性名转换为短横线连接的形式
res.push(`${adp[item] || item.replace(/([A-Z])/g, (s, m) => `-${m.toLowerCase()}`)}: ${attributes[item]}`)
}
return res
}, []).join(';')
}
function attributes2class(attributes, type) {
if (!attributes) return ''
// 定义允许的属性
const allowAttr = {
inline: ['bold', 'italic', 'underline', 'strike', 'ins', 'link'],
block: ['header', 'list']
}[type]
// 如果不支持该类型的属性,则抛出错误
if (!allowAttr) throw new Error('type not supported')
// 将属性转换为class列表
const classList = allowAttr.reduce((res, item) => {
// 如果属性为空,则返回空字符串
if (!attributes || item === "link") return res
// 如果属性为true则将属性名添加到class列表中
if (attributes[item] === true) {
res.push(item)
// 如果属性不为undefined则将属性名和属性值添加到class列表中
} else if (attributes[item] !== undefined) {
res.push(`${item}-${attributes[item]}`)
}
return res
}, [])
// 如果属性中包含link则添加link类
if ('link' in attributes) {
classList.push('link')
}
return classList.join(' ')
}
class QuillDeltaToJSONConverter {
constructor(deltaOps) {
this.deltaPreHandler(deltaOps)
this.deltaOps = deltaOps
this.converter = new QuillDeltaToHtmlConverterBase(this.deltaOps, {
multiLineParagraph: false,
});
}
deltaPreHandler (deltaOps) {
for (const op of deltaOps) {
if (typeof op.insert === 'string') continue
if (!op.attributes) {
op.attributes = {}
}
const insertType = Object.keys(op.insert)
const blockRenderList = ['divider', 'unlockContent', 'mediaVideo']
if (insertType && insertType.length > 0 && blockRenderList.includes(insertType[0])) {
op.attributes.renderAsBlock = true
}
}
}
convert () {
const opsGroups = this.converter.getGroupedOps();
return [].concat.apply(
[],
opsGroups.map((group) => {
// console.log(JSON.stringify(group), '--------------------group------------------------------')
switch (group.constructor) {
case ListGroup:
return jsonRules.list(group);
case BlockGroup:
return jsonRules.block(group.op, group.ops)
case BlotBlock:
return jsonRules.blotBlock(group.op, group.ops)
default:
return jsonRules.inlines(group.op, group.ops);
}
})
).filter(op => op.data instanceof Array ? op.data.length : op.data);
}
}
class QuillDeltaToHtmlConverter extends QuillDeltaToHtmlConverterBase {
constructor(deltaOps) {
super(deltaOps, {
multiLineParagraph: false,
inlineStyles: true,
});
// this.renderCustomWith(function (customOp, contextOp) {
// console.log(customOp, contextOp)
// })
}
}
exports.QuillDeltaToHtmlConverter = QuillDeltaToHtmlConverter
exports.QuillDeltaToJSONConverter = QuillDeltaToJSONConverter

View File

@ -0,0 +1,5 @@
{
"name": "quill-delta-converter",
"description": "quill编辑器delta格式转换器",
"main": "index.js"
}

View File

@ -0,0 +1,58 @@
const crypto = require('crypto')
const createConfig = require('uni-config-center')
const config = createConfig({
pluginId: 'uni-cms'
}).config()
const unlockRecordDBName = 'uni-cms-unlock-record'
// 定义云函数
exports.main = async function (event) {
// 解构 event 对象
const {trans_id, extra: _extra, sign} = event
let extra = {}
try {
extra = JSON.parse(_extra)
} catch (e) {}
// 如果 adConfig 或 securityKey 配置项不存在,则抛出错误引导用户配置参数
if (!config.adConfig || !config.adConfig.securityKey) throw new Error('请先配置adConfig.securityKey')
// 如果 extra.article_id 不存在,则返回 null
if (!extra.article_id) return null
// 签名验证
const reSign = crypto.createHash('sha256').update(`${config.adConfig.securityKey}:${trans_id}`).digest('hex')
if (sign !== reSign) {
console.log('签名错误', `${config.adConfig.securityKey}:${trans_id}`)
return null
}
// 获取数据库实例
const db = uniCloud.database()
// 查询解锁记录
const unlockRecord = await db.collection(unlockRecordDBName).where({
trans_id
}).get()
// 如果已经解锁过了,则返回 null
if (unlockRecord.data.length) {
console.log('已经解锁过了')
return null // 已经解锁过了
}
// 添加解锁记录
await db.collection(unlockRecordDBName).add({
unique_id: extra.unique_id,
unique_type: extra.unique_type,
article_id: extra.article_id,
trans_id,
create_date: Date.now()
})
console.log('解锁成功')
// 应广告规范,需返回 isValid 为 true
return {
isValid: true
}
}

View File

@ -0,0 +1,7 @@
{
"name": "uni-cms-unlock-callback",
"dependencies": {
"uni-config-center": "file:../../../../uni-config-center/uniCloud/cloudfunctions/common/uni-config-center"
},
"extensions": {}
}

View File

@ -0,0 +1,23 @@
{
"uni-cms-unlock-record": {
"data": [],
"index": [
{
"IndexName": "unique_article_trans_",
"MgoKeySchema": {
"MgoIndexKeys": [{
"Name": "unique_id",
"Direction": "1"
},{
"Name": "article_id",
"Direction": "1"
},{
"Name": "trans_id",
"Direction": "1"
}],
"MgoIsUnique": false
}
}
]
}
}

View File

@ -0,0 +1,31 @@
{
"bsonType": "object",
"required": [
"content",
"count"
],
"permission": {
"read": true,
"create": false,
"update": false,
"delete": false
},
"properties": {
"_id": {
"description": "ID系统自动生成"
},
"content": {
"bsonType": "string",
"description": "搜索内容"
},
"count": {
"bsonType": "int",
"description": "搜索次数"
},
"create_date": {
"bsonType": "timestamp",
"description": "统计时间"
}
},
"version": "0.0.1"
}

View File

@ -0,0 +1,45 @@
{
"bsonType": "object",
"required": [
"content"
],
"permission": {
"read": false,
"create": true,
"update": false,
"delete": false
},
"properties": {
"_id": {
"description": "ID系统自动生成"
},
"user_id": {
"bsonType": "string",
"description": "搜索人id参考uni-id-users表"
},
"device_id": {
"bsonType": "string",
"description": "设备id"
},
"platform": {
"bsonType": "string",
"description": "设备平台mp-weixin、app-plus等"
},
"content": {
"bsonType": "string",
"description": "搜索内容"
},
"ip": {
"bsonType": "string",
"description": "客户端IP地址",
"forceDefaultValue": {
"$env": "clientIP"
}
},
"create_date": {
"bsonType": "timestamp",
"description": "统计时间"
}
},
"version": "0.0.1"
}

View File

@ -0,0 +1,45 @@
{
"bsonType": "object",
"required": [
"content"
],
"permission": {
"read": false,
"create": true,
"update": false,
"delete": false
},
"properties": {
"_id": {
"description": "ID系统自动生成"
},
"user_id": {
"bsonType": "string",
"description": "搜索人id参考uni-id-users表"
},
"device_id": {
"bsonType": "string",
"description": "设备id"
},
"platform": {
"bsonType": "string",
"description": "设备平台mp-weixin、app-plus等"
},
"content": {
"bsonType": "string",
"description": "搜索内容"
},
"ip": {
"bsonType": "string",
"description": "客户端IP地址",
"forceDefaultValue": {
"$env": "clientIP"
}
},
"create_date": {
"bsonType": "timestamp",
"description": "统计时间"
}
},
"version": "0.0.1"
}

View File

@ -0,0 +1,350 @@
// 获取配置
const createConfig = safeRequire('uni-config-center')
const {QuillDeltaToHtmlConverter, QuillDeltaToJSONConverter} = safeRequire('quill-delta-converter')
const config = createConfig({
pluginId: 'uni-cms'
}).config()
// 获取数据库实例
const db = uniCloud.database()
// 文章数据库名称
const articleDBName = 'uni-cms-articles'
// 解锁内容数据库名称
const unlockContentDBName = 'uni-cms-unlock-record'
// 安全检测文本内容
async function checkContentSec(content, requestId, errorMsg) {
// 安全引入内容安全检测模块
const UniSecCheck = safeRequire('uni-sec-check')
// 创建内容安全检测实例
const uniSecCheck = new UniSecCheck({
provider: 'mp-weixin',
requestId
})
// 调用文本安全检测接口
const res = await uniSecCheck.textSecCheck({
content, // 待检测的文本内容
scene: 1, // 表示资料类场景
version: 1 // 调用检测API的版本号
})
// 如果存在敏感词,抛出异常
if (res.errCode === uniSecCheck.ErrorCode.RISK_CONTENT) {
throw new Error(errorMsg || '存在敏感词,请修改后提交')
} else if (res.errCode !== 0) {
console.error(res)
throw new Error('内容安全检测异常:' + res.errCode)
}
}
// 安全检测图片内容
async function checkImageSec(image, requestId, errorMsg) {
// 安全引入内容安全检测模块
const UniSecCheck = safeRequire('uni-sec-check')
// 创建内容安全检测实例
const uniSecCheck = new UniSecCheck({
provider: 'mp-weixin',
requestId
})
const images = typeof image === "string" ? [image]: image
for (let item of images) {
// 处理cloud://开头的链接
if (item.startsWith('cloud://')) {
const res = await uniCloud.getTempFileURL({
fileList: [item]
})
if (res.fileList && res.fileList.length > 0) {
item = res.fileList[0].tempFileURL
}
}
// 调用图片安全检测接口
const res = await uniSecCheck.imgSecCheck({
image: item, // 待检测的图片URL
scene: 1, // 表示资料类场景
version: 1 // 调用检测API的版本号
})
// 如果存在违规内容,抛出异常
if (res.errCode === uniSecCheck.ErrorCode.RISK_CONTENT) {
throw new Error(errorMsg || '图片违规,请修改后提交')
} else if (res.errCode !== 0) {
console.error(res)
throw new Error('内容安全检测异常:' + res.errCode)
}
}
}
// 检测内容安全开关
function checkContentSecurityEnable(field) {
// 1. 从配置中心获取配置
return config.contentSecurity && config.contentSecurity.allowCheckType && config.contentSecurity.allowCheckType.includes(field)
}
// 安全require
function safeRequire(module) {
try {
return require(module)
} catch (e) {
if (e.code === 'MODULE_NOT_FOUND') {
throw new Error(`${module} 公共模块不存在,请在 uniCloud/database 目录右击"配置schema扩展公共模块"添加 ${module} 模块`)
}
}
}
module.exports = {
trigger: {
// 创建文章前触发
beforeCreate: async function ({clientInfo, addDataList}) {
// addDataList 是一个数组,因为可以一次性创建多条数据
if (addDataList.length <= 0) return
// 检测内容安全开关
const allowCheckContent = checkContentSecurityEnable('content')
const allowCheckImage = checkContentSecurityEnable('image')
// 遍历数组,对每一条数据进行安全检测
for (const addData of addDataList) {
// 如果是草稿,不检测
if (addData.article_status !== 1) continue
// 并行检测
const parallel = []
// 检测标题
if (allowCheckContent && addData.title) {
parallel.push(checkContentSec(addData.title, clientInfo.requestId, '标题存在敏感字,请修改后提交'))
}
// 检测摘要
if (allowCheckContent && addData.excerpt) {
parallel.push(checkContentSec(addData.excerpt, clientInfo.requestId, '摘要存在敏感字,请修改后提交'))
}
// 检测内容
if (allowCheckContent && addData.content) {
parallel.push(checkContentSec(JSON.stringify(addData.content), clientInfo.requestId, '内容存在敏感字,请修改后提交'))
}
// 检测封面图
if (allowCheckImage && addData.thumbnail) {
parallel.push(checkImageSec(addData.thumbnail, clientInfo.requestId, '封面图存在违规,请修改后提交'))
}
// 等待所有并行检测完成
await Promise.all(parallel)
}
},
// 更新文章前触发
beforeUpdate: async function ({clientInfo, where, updateData}) {
const id = where && where._id
if (!id) return
// 如果是草稿,不检测
if (updateData.article_status !== 1) return
// 检测内容安全开关
const allowCheckContent = checkContentSecurityEnable('content')
const allowCheckImage = checkContentSecurityEnable('image')
// 并行检测
const parallel = []
// 检测标题
if (allowCheckContent && updateData.title) {
parallel.push(checkContentSec(updateData.title, clientInfo.requestId, '标题存在敏感字,请修改后提交'))
}
// 检测摘要
if (allowCheckContent && updateData.excerpt) {
parallel.push(checkContentSec(updateData.excerpt, clientInfo.requestId, '摘要存在敏感字,请修改后提交'))
}
// 检测内容
if (allowCheckContent && updateData.content) {
parallel.push(checkContentSec(JSON.stringify(updateData.content), clientInfo.requestId, '内容存在敏感字,请修改后提交'))
}
// 检测封面图
if (allowCheckImage && updateData.thumbnail) {
parallel.push(checkImageSec(updateData.thumbnail, clientInfo.requestId, '封面图存在违规,请修改后提交'))
}
// 等待所有并行检测完成
await Promise.all(parallel)
},
// 读取文章后触发
afterRead: async function ({userInfo, clientInfo, result, where, field}) {
const isAdmin = field && field.length && field.includes('is_admin')
// 检查是否配置了clientAppIds字段如果没有则抛出错误
if ((!config.clientAppIds || !config.clientAppIds.length) && !isAdmin) {
throw new Error('请在 uni-cms 配置文件中配置 clientAppIds 字段后访问详见https://uniapp.dcloud.net.cn/uniCloud/uni-cms.html#uni-cms-config')
}
// 如果clientAppIds字段未配置或当前appId不在clientAppIds中则返回
if (!config.clientAppIds || !config.clientAppIds.includes(clientInfo.appId)) return
// 获取广告配置
const adConfig = config.adConfig || {}
// 获取文章id
const id = where && where._id
// 如果id不存在或者field不包含content则返回
if (id && field.includes('content')) {
// 读取了content字段后view_count加1
await db.collection(articleDBName).where(where).update({
view_count: db.command.inc(1)
})
}
// 如果查询结果为空,则返回
if (!result.data || result.data.length <= 0) return
// 获取文章
const article = result.data[0]
// 如果文章内容不存在,则返回
if (!article.content) return
let needUnlock = false
let unlockContent = []
// 获取文章内容中的图片
article.content_images = article.content.ops.reduce((imageBlocks, block) => {
if (!block.insert.image) return imageBlocks
const {attributes} = block
const {'data-custom': custom = ""} = attributes || {}
const parseCustom = custom.split('&').reduce((obj, item) => {
const [key, value] = item.split('=')
obj[key] = value
return obj
})
return imageBlocks.concat(
parseCustom.source ||
block.insert.image
)
}, [])
for (const op of article.content.ops) {
unlockContent.push(op)
// 遍历文章内容,找到解锁内容
if (op.insert.unlockContent) {
needUnlock = true
break
}
}
// 如果文章不需要解锁,则返回
if (!needUnlock) {
article.content = getRenderableArticleContent(article.content, clientInfo)
return
}
// 获取唯一标识符
const uniqueId = adConfig.watchAdUniqueType === 'user' ? userInfo.uid : clientInfo.deviceId
// 如果未登录或者文章未解锁,则返回解锁内容
if (!uniqueId || !article._id) {
article.content = getRenderableArticleContent({
ops: unlockContent
}, clientInfo)
return
}
// 查询解锁记录
const unlockRecord = await db.collection(unlockContentDBName).where({
unique_id: uniqueId,
article_id: article._id
}).get()
// 如果未解锁,则返回解锁内容
if (unlockRecord.data && unlockRecord.data.length <= 0) {
article.content = getRenderableArticleContent({
ops: unlockContent
}, clientInfo)
return
}
// 将文章解锁替换为行结束符 \n
article.content = getRenderableArticleContent({
ops: article.content.ops.map(op => {
if (op.insert.unlockContent) {
op.insert = "\n"
}
return op
})
}, clientInfo)
}
}
}
function getRenderableArticleContent (rawArticleContent, clientInfo) {
const isUniAppX = /uni-app-x/i.test(clientInfo.userAgent)
if (!isUniAppX) {
const quillDeltaConverter = new QuillDeltaToJSONConverter(rawArticleContent.ops)
return quillDeltaConverter.convert()
}
const deltaOps = []
for (let i = 0; i < rawArticleContent.ops.length; i++) {
const op = rawArticleContent.ops[i]
if (typeof op.insert === 'object') {
const insertType = Object.keys(op.insert)
const blockRenderList = ['image', 'divider', 'unlockContent', 'mediaVideo']
if (insertType && insertType.length > 0 && blockRenderList.includes(insertType[0])) {
deltaOps.push({
type: insertType[0],
ops: [op]
})
// 一般块级节点后面都跟一个换行,需要把这个换行给去掉
const nextOps = rawArticleContent.ops[i + 1]
if (nextOps && nextOps.insert === '\n') {
i ++
}
continue
}
}
const currentIndex = deltaOps.length > 0 ? deltaOps.length - 1: 0
if (
typeof deltaOps[currentIndex] !== "object" ||
(deltaOps[currentIndex] && deltaOps[currentIndex].type !== 'rich-text')
) {
deltaOps.push({
type: 'rich-text',
ops: []
})
}
deltaOps[deltaOps.length - 1].ops.push(op)
}
return deltaOps.reduce((content, item) => {
const isRichText = item.type === 'rich-text'
let block = {
type: item.type,
data: isRichText ? item.ops: item.ops[0]
}
if (item.type === 'rich-text') {
const lastOp = item.ops.length > 0 ? item.ops[item.ops.length - 1]: null
if (lastOp !== null && lastOp.insert === "\n") {
item.ops.pop()
}
const quillDeltaConverter = new QuillDeltaToHtmlConverter(item.ops)
block.data = quillDeltaConverter.convert()
}
return content.concat(block)
}, [])
}

View File

@ -0,0 +1,172 @@
{
"bsonType": "object",
"required": ["user_id", "title", "content"],
"permission": {
"read": true,
"create": "'admin' in auth.role || 'CREATE_UNI_CMS_ARTICLE' in auth.permission",
"update": "'admin' in auth.role || 'UPDATE_UNI_CMS_ARTICLE' in auth.permission",
"delete": "'admin' in auth.role || 'DELETE_UNI_CMS_ARTICLE' in auth.permission"
},
"properties": {
"_id": {
"description": "存储文档 ID用户 ID系统自动生成"
},
"user_id": {
"bsonType": "string",
"description": "文章作者ID 参考`uni-id-users` 表",
"foreignKey": "uni-id-users._id",
"defaultValue": {
"$env": "uid"
}
},
"category_id": {
"bsonType": "string",
"title": "分类",
"description": "分类 id参考`uni-news-categories`表",
"foreignKey": "uni-cms-categories._id",
"enum": {
"collection": "uni-cms-categories",
"field": "name as text, _id as value"
}
},
"title": {
"bsonType": "string",
"title": "标题",
"description": "标题",
"label": "标题",
"trim": "both"
},
"content": {
"bsonType": "object",
"title": "文章内容",
"description": "文章内容; 格式为Quill编辑器的Delta格式",
"label": "文章内容"
},
"excerpt": {
"bsonType": "string",
"title": "文章摘录",
"description": "文章摘录",
"label": "摘要",
"trim": "both"
},
"article_status": {
"bsonType": "int",
"title": "文章状态",
"description": "文章状态0 草稿箱 1 已发布",
"defaultValue": 0,
"enum": [{
"value": 0,
"text": "草稿箱"
},
{
"value": 1,
"text": "已发布"
}
]
},
"view_count": {
"bsonType": "int",
"title": "阅读数量",
"description": "阅读数量",
"defaultValue": 0,
"permission": {
"write": false
}
},
"like_count": {
"bsonType": "int",
"description": "喜欢数、点赞数",
"permission": {
"write": false
}
},
"is_sticky": {
"bsonType": "bool",
"title": "是否置顶",
"description": "是否置顶",
"permission": {
"write": false
}
},
"is_essence": {
"bsonType": "bool",
"title": "阅读加精",
"description": "阅读加精",
"permission": {
"write": false
}
},
"comment_status": {
"bsonType": "int",
"title": "开放评论",
"description": "评论状态0 关闭 1 开放",
"enum": [{
"value": 0,
"text": "关闭"
},
{
"value": 1,
"text": "开放"
}
]
},
"comment_count": {
"bsonType": "int",
"description": "评论数量",
"permission": {
"write": false
}
},
"last_comment_user_id": {
"bsonType": "string",
"description": "最后回复用户 id参考`uni-id-users` 表",
"foreignKey": "uni-id-users._id"
},
"thumbnail": {
"bsonType": "array",
"title": "封面大图",
"description": "缩略图地址",
"label": "封面大图",
"defaultValue": []
},
"publish_date": {
"bsonType": "timestamp",
"title": "发表时间",
"description": "发表时间",
"defaultValue": {
"$env": "now"
}
},
"publish_ip": {
"bsonType": "string",
"title": "发布文章时IP地址",
"description": "发表时 IP 地址",
"forceDefaultValue": {
"$env": "clientIP"
}
},
"last_modify_date": {
"bsonType": "timestamp",
"title": "最后修改时间",
"description": "最后修改时间",
"defaultValue": {
"$env": "now"
}
},
"last_modify_ip": {
"bsonType": "string",
"description": "最后修改时 IP 地址",
"forceDefaultValue": {
"$env": "clientIP"
}
},
"preview_secret": {
"bsonType": "string",
"description": "文章预览密钥"
},
"preview_expired": {
"bsonType": "timestamp",
"description": "文章预览过期时间"
}
}
}

View File

@ -0,0 +1,49 @@
{
"bsonType": "object",
"required": [
"user_id",
"trans_id",
"content_id"
],
"permission": {
"read": true,
"create": true,
"update": false,
"delete": false
},
"properties": {
"_id": {
"description": "存储文档 ID用户 ID系统自动生成"
},
"unique_id": {
"bsonType": "string",
"description": "用于标识观看广告的唯一标识"
},
"unique_type": {
"bsonType": "string",
"description": "观看广告的唯一标识类型user 用户device 设备"
},
"trans_id": {
"bsonType": "string",
"title": "交易ID",
"description": "广告回调传回的交易ID",
"label": "内容id",
"trim": "both"
},
"content_id": {
"bsonType": "string",
"title": "内容id",
"description": "内容(文章)ID",
"label": "内容id",
"trim": "both"
},
"create_date": {
"bsonType": "timestamp",
"title": "创建时间",
"description": "创建时间",
"defaultValue": {
"$env": "now"
}
}
}
}