fix(manuscripts): 我的稿件正文改用阅读抽屉渲染富文本
列表把 cms_manuscript.content 当纯文本输出,后台 wangEditor 存的 HTML (<p>、<img>)和早期 AI 草稿的 markdown(##、**)都会原样露出来。 - 列表只留元数据:标题、首行摘要、状态、更新时间、字数;点一行从底部升起阅读抽屉 - 新增 components/manuscript-reader:mp-html 渲染正文(HTML / markdown / 纯文本按内容判定), 支持长按选择、点击图片预览、复制标题、复制正文,有封面时展示封面 - 新增 utils/content-format:格式判定、纯文本化、渲染参数准备 - 搜索改为匹配标题 + 纯文本正文,不再直接匹配带标签的原始 content - 正文每篇只解析一次(加载列表时),摘要、字数、搜索复用同一份纯文本 - 顺带修掉 hero 统计标签在浅色玻璃卡上不可见的问题
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<view v-if="item" class="reader">
|
||||
<view class="reader-mask" @tap="close"></view>
|
||||
<view class="reader-sheet">
|
||||
<view class="reader-sheet__grab"></view>
|
||||
<view class="reader-sheet__head">
|
||||
<view class="reader-sheet__title">{{ item.title || '未命名稿件' }}</view>
|
||||
<view class="reader-sheet__meta">
|
||||
<text class="reader-sheet__status">{{ item.statusText }}</text>
|
||||
<text class="reader-sheet__sep">·</text>
|
||||
<text>{{ item.timeLabel }}</text>
|
||||
<text class="reader-sheet__sep">·</text>
|
||||
<text>{{ item.chars }} 字</text>
|
||||
<text class="reader-sheet__copy" @tap.stop="copyTitle">复制标题</text>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view scroll-y class="reader-sheet__body">
|
||||
<image v-if="item.cover" class="reader-cover" :src="item.cover" mode="widthFix" />
|
||||
<mp-html
|
||||
v-if="payload.html"
|
||||
:content="payload.html"
|
||||
:markdown="payload.markdown"
|
||||
:selectable="true"
|
||||
:preview-img="true"
|
||||
:tag-style="tagStyle"
|
||||
/>
|
||||
<view v-else class="reader-sheet__empty">这篇稿件还没有正文内容。</view>
|
||||
</scroll-view>
|
||||
<view class="reader-sheet__foot">
|
||||
<view class="reader-btn" @tap="copyContent">复制正文</view>
|
||||
<view class="reader-btn reader-btn--primary" @tap="close">关闭</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 稿件阅读抽屉:列表只放元数据,正文在这里用 mp-html 渲染。
|
||||
*
|
||||
* item 的形状(由列表页整理好,缺内容时传 null 即关闭):
|
||||
* { title, content, cover, statusText, timeLabel, chars }
|
||||
* content 可能是 HTML、markdown 或纯文本,由 getRenderPayload 判断怎么渲染。
|
||||
*/
|
||||
import mpHtml from '@/uni_modules/zero-markdown-view/components/mp-html/mp-html.vue'
|
||||
import { getRenderPayload, toPlainText } from '@/utils/content-format'
|
||||
|
||||
export default {
|
||||
name: 'ManuscriptReader',
|
||||
components: {
|
||||
mpHtml
|
||||
},
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['close'],
|
||||
data() {
|
||||
return {
|
||||
tagStyle: {
|
||||
p: 'margin:0 0 14px 0;',
|
||||
h1: 'font-size:20px;margin:6px 0 12px 0;',
|
||||
h2: 'font-size:18px;margin:6px 0 12px 0;',
|
||||
h3: 'font-size:16px;margin:4px 0 10px 0;',
|
||||
li: 'margin:4px 0;',
|
||||
ul: 'padding-left:22px;margin:10px 0;',
|
||||
ol: 'padding-left:22px;margin:10px 0;',
|
||||
blockquote: 'margin:12px 0;padding:8px 14px;border-left:3px solid #f0b98a;color:#8a7466;background:#fdf6ef;',
|
||||
img: 'max-width:100%;height:auto;border-radius:8px;margin:10px 0;',
|
||||
a: 'color:#0f7dd1;'
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
payload() {
|
||||
return getRenderPayload(this.item ? this.item.content : '')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
this.$emit('close')
|
||||
},
|
||||
copyTitle() {
|
||||
const title = (this.item && this.item.title) || ''
|
||||
if (!title) {
|
||||
uni.showToast({
|
||||
title: '暂无标题可复制',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: title,
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: '标题已复制',
|
||||
icon: 'none'
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({
|
||||
title: '复制失败,请长按标题手动复制',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
copyContent() {
|
||||
const text = toPlainText(this.item ? this.item.content : '')
|
||||
if (!text) {
|
||||
uni.showToast({
|
||||
title: '暂无正文可复制',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: text,
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: '正文已复制',
|
||||
icon: 'none'
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({
|
||||
title: '复制失败,可长按正文手动选择',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.reader-mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 900;
|
||||
background: rgba(24, 20, 17, 0.42);
|
||||
animation: reader-mask-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
.reader-sheet {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 901;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 78vh;
|
||||
padding: 16rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
background: #fffdfb;
|
||||
box-shadow: 0 -12rpx 40rpx rgba(40, 26, 16, 0.18);
|
||||
animation: reader-sheet-in 0.22s ease-out;
|
||||
}
|
||||
|
||||
.reader-sheet__grab {
|
||||
width: 76rpx;
|
||||
height: 8rpx;
|
||||
margin: 4rpx auto 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #e3d8d0;
|
||||
}
|
||||
|
||||
.reader-sheet__head {
|
||||
padding-bottom: 18rpx;
|
||||
border-bottom: 1rpx solid rgba(120, 96, 82, 0.12);
|
||||
}
|
||||
|
||||
.reader-sheet__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
color: #1f242b;
|
||||
}
|
||||
|
||||
.reader-sheet__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 10rpx;
|
||||
font-size: 22rpx;
|
||||
color: #9b8f88;
|
||||
}
|
||||
|
||||
.reader-sheet__status {
|
||||
color: #7d716a;
|
||||
}
|
||||
|
||||
.reader-sheet__sep {
|
||||
padding: 0 8rpx;
|
||||
color: #d2c8c1;
|
||||
}
|
||||
|
||||
.reader-sheet__copy {
|
||||
margin-left: auto;
|
||||
padding: 4rpx 0 4rpx 20rpx;
|
||||
color: #c8641f;
|
||||
}
|
||||
|
||||
.reader-sheet__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 20rpx 0;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.85;
|
||||
color: #3f3833;
|
||||
}
|
||||
|
||||
.reader-cover {
|
||||
width: 100%;
|
||||
margin-bottom: 16rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.reader-sheet__empty {
|
||||
padding: 40rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: #a99e97;
|
||||
}
|
||||
|
||||
.reader-sheet__foot {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding-top: 16rpx;
|
||||
border-top: 1rpx solid rgba(120, 96, 82, 0.12);
|
||||
}
|
||||
|
||||
.reader-btn {
|
||||
flex: 1;
|
||||
height: 84rpx;
|
||||
line-height: 84rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #f4f0ec;
|
||||
color: #6d625c;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reader-btn--primary {
|
||||
background: linear-gradient(135deg, #0f7dd1 0%, #1496f2 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@keyframes reader-mask-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reader-sheet-in {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+148
-140
@@ -4,10 +4,10 @@
|
||||
<common-hero title="我的稿件" :use-image-bg="true"></common-hero>
|
||||
<view class="hero-card">
|
||||
<view class="hero-title">我的稿件</view>
|
||||
<view class="hero-desc">汇总当前账号创建或参与的稿件记录,可查看状态和内容摘要。</view>
|
||||
<view class="hero-desc">汇总当前账号创建或参与的稿件记录,点开任意一行即可阅读正文。</view>
|
||||
<view class="hero-stats">
|
||||
<view class="stat-item">
|
||||
<view class="stat-value">{{ manuscripts.length }}</view>
|
||||
<view class="stat-value">{{ rows.length }}</view>
|
||||
<view class="stat-label">稿件总数</view>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
@@ -44,80 +44,86 @@
|
||||
<view class="state-desc">{{ errorText }}</view>
|
||||
</view>
|
||||
<view v-else-if="!filteredList.length" class="state-card">
|
||||
<view class="state-title">暂无稿件</view>
|
||||
<view class="state-desc">当前账号还没有可展示的稿件,可前往文稿工作台开始生成。</view>
|
||||
<view class="state-title">{{ rows.length ? '没有匹配的稿件' : '暂无稿件' }}</view>
|
||||
<view class="state-desc">
|
||||
{{ rows.length ? '换个关键词再试试,或清空搜索框。' : '当前账号还没有可展示的稿件,可前往文稿工作台开始生成。' }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="card-list">
|
||||
<view v-for="item in filteredList" :key="item.id || item.title" class="data-card">
|
||||
<view class="card-top">
|
||||
<view class="card-main">
|
||||
<view class="card-title">{{ item.title || '未命名稿件' }}</view>
|
||||
<view class="card-subtitle">
|
||||
创建时间:{{ formatDateTime(item.createTime) }} · 更新时间:{{ formatDateTime(item.updateTime) }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="status-tag" :class="`status-tag--${getStatusMeta(item).tone}`">
|
||||
{{ getStatusMeta(item).text }}
|
||||
<view v-else class="list-card">
|
||||
<view class="list-head">
|
||||
<view class="list-head__title">共 {{ filteredList.length }} 篇稿件</view>
|
||||
<view class="list-head__hint">点击任意一行进入阅读</view>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in filteredList"
|
||||
:key="item.key"
|
||||
class="list-row"
|
||||
hover-class="list-row--active"
|
||||
@tap="openReader(item)"
|
||||
>
|
||||
<view class="list-row__main">
|
||||
<view class="list-row__title">{{ item.title }}</view>
|
||||
<view class="list-row__headline">{{ item.headline || '暂无正文内容' }}</view>
|
||||
<view class="list-row__meta">
|
||||
<text class="list-dot" :class="`list-dot--${item.tone}`"></text>
|
||||
<text class="list-row__status">{{ item.statusText }}</text>
|
||||
<text class="list-row__sep">·</text>
|
||||
<text>{{ item.timeLabel }}</text>
|
||||
<text class="list-row__sep">·</text>
|
||||
<text>{{ item.chars }} 字</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="item.cover" class="cover-wrap">
|
||||
<image class="cover-image" :src="item.cover" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="content-preview">{{ getContentPreview(item.content) }}</view>
|
||||
<view class="card-actions">
|
||||
<view class="card-action" @tap="toggleExpand(item)">{{ expandedId === item.id ? '收起内容' : '查看内容' }}</view>
|
||||
<view class="card-action" @tap="copyTitle(item.title)">复制标题</view>
|
||||
</view>
|
||||
<view v-if="expandedId === item.id" class="content-detail">{{ item.content || '暂无正文内容' }}</view>
|
||||
<view class="list-row__chevron">›</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<manuscript-reader :item="reader" @close="closeReader"></manuscript-reader>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CommonHero from '../components/common-hero/common-hero.vue'
|
||||
import ManuscriptReader from '../components/manuscript-reader/manuscript-reader.vue'
|
||||
import {
|
||||
formatDateTime,
|
||||
getCurrentUser,
|
||||
getManuscriptStatusMeta,
|
||||
listManuscript
|
||||
} from '../pages/mine/service'
|
||||
import { toPlainText, truncateText } from '../utils/content-format'
|
||||
|
||||
const HEADLINE_LENGTH = 56
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CommonHero
|
||||
CommonHero,
|
||||
ManuscriptReader
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
errorText: '',
|
||||
keyword: '',
|
||||
expandedId: null,
|
||||
manuscripts: []
|
||||
rows: [],
|
||||
reader: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredList() {
|
||||
const keyword = String(this.keyword || '').trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return this.manuscripts
|
||||
return this.rows
|
||||
}
|
||||
return this.manuscripts.filter((item) => {
|
||||
return (
|
||||
String(item.title || '').toLowerCase().includes(keyword) ||
|
||||
String(item.content || '').toLowerCase().includes(keyword)
|
||||
)
|
||||
return this.rows.filter((item) => {
|
||||
return item.searchText.includes(keyword)
|
||||
})
|
||||
},
|
||||
publishedCount() {
|
||||
return this.manuscripts.filter((item) => {
|
||||
const text = this.getStatusMeta(item).text
|
||||
return text === '通过' || text === '已发布'
|
||||
}).length
|
||||
return this.rows.filter((item) => item.statusText === '通过' || item.statusText === '已发布').length
|
||||
},
|
||||
pendingCount() {
|
||||
return this.manuscripts.length - this.publishedCount
|
||||
return this.rows.length - this.publishedCount
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
@@ -128,12 +134,36 @@
|
||||
},
|
||||
methods: {
|
||||
formatDateTime,
|
||||
getStatusMeta(item) {
|
||||
return getManuscriptStatusMeta(item)
|
||||
/**
|
||||
* 稿件 → 列表行视图模型。
|
||||
* 正文可能是 HTML / markdown / 纯文本,这里只做一次纯文本化:
|
||||
* 摘要、搜索、字数都基于它,避免每张卡片反复解析富文本。
|
||||
*/
|
||||
toRow(item, index) {
|
||||
const plainText = toPlainText(item.content)
|
||||
const statusMeta = getManuscriptStatusMeta(item)
|
||||
return {
|
||||
key: item.id || `${index}-${item.title || ''}`,
|
||||
title: item.title || '未命名稿件',
|
||||
headline: truncateText(plainText, HEADLINE_LENGTH),
|
||||
searchText: `${String(item.title || '').toLowerCase()}\n${plainText.toLowerCase()}`,
|
||||
statusText: statusMeta.text,
|
||||
tone: statusMeta.tone,
|
||||
timeLabel: `更新 ${formatDateTime(item.updateTime || item.createTime)}`,
|
||||
chars: plainText.replace(/\s/g, '').length,
|
||||
cover: item.cover || '',
|
||||
content: item.content || ''
|
||||
}
|
||||
},
|
||||
getContentPreview(content) {
|
||||
const text = String(content || '').replace(/\s+/g, ' ').trim()
|
||||
return text ? `${text.slice(0, 90)}${text.length > 90 ? '...' : ''}` : '暂无内容摘要'
|
||||
applyManuscripts(list, user) {
|
||||
this.rows = (Array.isArray(list) ? list : [])
|
||||
.filter((item) => !user.userId || Number(item.userId) === Number(user.userId))
|
||||
.sort((left, right) => {
|
||||
const leftTime = new Date(String((left.updateTime || left.createTime || '')).replace(/-/g, '/')).getTime()
|
||||
const rightTime = new Date(String((right.updateTime || right.createTime || '')).replace(/-/g, '/')).getTime()
|
||||
return (Number.isNaN(rightTime) ? 0 : rightTime) - (Number.isNaN(leftTime) ? 0 : leftTime)
|
||||
})
|
||||
.map((item, index) => this.toRow(item, index))
|
||||
},
|
||||
async loadData(fromPullDown = false) {
|
||||
this.loading = true
|
||||
@@ -141,14 +171,7 @@
|
||||
try {
|
||||
const user = getCurrentUser()
|
||||
const result = await listManuscript()
|
||||
const list = Array.isArray(result) ? result : []
|
||||
this.manuscripts = list
|
||||
.filter((item) => !user.userId || Number(item.userId) === Number(user.userId))
|
||||
.sort((left, right) => {
|
||||
const leftTime = new Date(String((left.updateTime || left.createTime || '')).replace(/-/g, '/')).getTime()
|
||||
const rightTime = new Date(String((right.updateTime || right.createTime || '')).replace(/-/g, '/')).getTime()
|
||||
return (Number.isNaN(rightTime) ? 0 : rightTime) - (Number.isNaN(leftTime) ? 0 : leftTime)
|
||||
})
|
||||
this.applyManuscripts(Array.isArray(result) ? result : [], user)
|
||||
} catch (error) {
|
||||
this.errorText = (error && error.message) || '稿件加载失败'
|
||||
} finally {
|
||||
@@ -158,26 +181,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleExpand(item) {
|
||||
this.expandedId = this.expandedId === item.id ? null : item.id
|
||||
openReader(item) {
|
||||
this.reader = item
|
||||
},
|
||||
copyTitle(title) {
|
||||
if (!title) {
|
||||
uni.showToast({
|
||||
title: '暂无标题可复制',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: title,
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: '标题已复制',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
})
|
||||
closeReader() {
|
||||
this.reader = null
|
||||
},
|
||||
goCreate() {
|
||||
uni.navigateTo({
|
||||
@@ -204,7 +212,7 @@
|
||||
.hero-card,
|
||||
.toolbar-card,
|
||||
.state-card,
|
||||
.data-card {
|
||||
.list-card {
|
||||
border-radius: 30rpx;
|
||||
}
|
||||
|
||||
@@ -237,7 +245,7 @@
|
||||
|
||||
.toolbar-card,
|
||||
.state-card,
|
||||
.data-card {
|
||||
.list-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1rpx solid rgba(240, 122, 61, 0.08);
|
||||
box-shadow: 0 10rpx 28rpx rgba(76, 49, 35, 0.08);
|
||||
@@ -253,19 +261,20 @@
|
||||
.stat-item {
|
||||
padding: 18rpx 16rpx;
|
||||
border-radius: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #22272f;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 250, 245, 0.8);
|
||||
color: #8b817c;
|
||||
}
|
||||
|
||||
.toolbar-card {
|
||||
@@ -336,106 +345,105 @@
|
||||
color: #786e69;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
.list-card {
|
||||
margin-top: 24rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
.list-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 24rpx 24rpx 18rpx;
|
||||
}
|
||||
|
||||
.card-main {
|
||||
.list-head__title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #34393f;
|
||||
}
|
||||
|
||||
.list-head__hint {
|
||||
font-size: 21rpx;
|
||||
color: #a2968e;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 26rpx 24rpx;
|
||||
border-top: 1rpx solid rgba(120, 96, 82, 0.1);
|
||||
}
|
||||
|
||||
.list-row--active {
|
||||
background: #f8f3ee;
|
||||
}
|
||||
|
||||
.list-row__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 30rpx;
|
||||
.list-row__title {
|
||||
font-size: 29rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
color: #22272f;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
.list-row__headline {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.6;
|
||||
color: #8b817c;
|
||||
font-size: 23rpx;
|
||||
color: #8d817a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
padding: 10rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
.list-row__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 12rpx;
|
||||
font-size: 21rpx;
|
||||
color: #a2968e;
|
||||
}
|
||||
|
||||
.status-tag--success {
|
||||
background: #eef9f1;
|
||||
.list-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
margin-right: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.list-dot--success {
|
||||
color: #1f8f49;
|
||||
}
|
||||
|
||||
.status-tag--danger {
|
||||
background: #fff1f0;
|
||||
.list-dot--danger {
|
||||
color: #d4380d;
|
||||
}
|
||||
|
||||
.status-tag--warning {
|
||||
background: #fff8e6;
|
||||
.list-dot--warning {
|
||||
color: #ad7b00;
|
||||
}
|
||||
|
||||
.status-tag--default {
|
||||
background: #f5f5f5;
|
||||
color: #8b817c;
|
||||
.list-dot--default {
|
||||
color: #c2b6ae;
|
||||
}
|
||||
|
||||
.cover-wrap {
|
||||
margin-top: 18rpx;
|
||||
.list-row__status {
|
||||
color: #7d716a;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 260rpx;
|
||||
border-radius: 24rpx;
|
||||
.list-row__sep {
|
||||
padding: 0 8rpx;
|
||||
color: #d2c8c1;
|
||||
}
|
||||
|
||||
.content-preview,
|
||||
.content-detail {
|
||||
margin-top: 18rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.8;
|
||||
color: #5d544f;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.content-detail {
|
||||
padding: 20rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #fbf7f3;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.card-action {
|
||||
padding: 10rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(240, 122, 61, 0.08);
|
||||
color: #c8641f;
|
||||
font-size: 22rpx;
|
||||
.list-row__chevron {
|
||||
font-size: 34rpx;
|
||||
color: #c9bdb4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 稿件正文的格式判定与渲染准备。
|
||||
*
|
||||
* `cms_manuscript.content` 里混着两种来源:
|
||||
* 1) 后台 wangEditor 保存的 HTML(`<p>`、`<img src=...>`);
|
||||
* 2) 早期从 AI 结果直接粘进后台的 markdown(`## 标题`、`**加粗**`、`- 列表`)。
|
||||
* 库里没有格式标记,所以只能按内容猜:能识别成 HTML 就按 HTML 渲染,
|
||||
* 否则看是否带 markdown 记号,两者都不是就按纯文本兜底。
|
||||
*
|
||||
* 只做展示需要的粗粒度判定,不追求完整的 HTML / markdown 解析。
|
||||
*/
|
||||
|
||||
const HTML_TAG_RE = /<\/?([a-z][a-z0-9]*)\b[^>]*>/i
|
||||
const MARKDOWN_LINE_RE = /(^|\n)[ \t]{0,3}(#{1,6}[ \t]|[-*+][ \t]|\d+\.[ \t]|>[ \t])/
|
||||
const MARKDOWN_EMPHASIS_RE = /(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(`[^`\n]+`)|(!\[[^\]]*\]\()/
|
||||
|
||||
function decodeEntities(text) {
|
||||
return String(text || '')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/&/gi, '&')
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export function isHtmlContent(content) {
|
||||
return HTML_TAG_RE.test(String(content || ''))
|
||||
}
|
||||
|
||||
export function isMarkdownContent(content) {
|
||||
const text = String(content || '')
|
||||
if (!text || isHtmlContent(text)) {
|
||||
return false
|
||||
}
|
||||
return MARKDOWN_LINE_RE.test(text) || MARKDOWN_EMPHASIS_RE.test(text)
|
||||
}
|
||||
|
||||
function htmlToText(html) {
|
||||
return decodeEntities(
|
||||
String(html)
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|h[1-6]|li|tr|blockquote|section|article|ul|ol|table|pre)>/gi, '\n')
|
||||
.replace(/<li\b[^>]*>/gi, '· ')
|
||||
.replace(/<img\b[^>]*>/gi, '\n[图片]\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
)
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function markdownToText(markdown) {
|
||||
return String(markdown)
|
||||
.replace(/```[\w-]*\n?([\s\S]*?)```/g, '$1')
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, (_, alt) => (alt ? `[图片:${alt}]` : '[图片]'))
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||
.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '')
|
||||
.replace(/^[ \t]{0,3}>[ \t]?/gm, '')
|
||||
.replace(/^[ \t]{0,3}[-*+][ \t]+/gm, '· ')
|
||||
.replace(/(\*\*\*|___)([^*_]+)\1/g, '$2')
|
||||
.replace(/(\*\*|__)([^*_]+)\1/g, '$2')
|
||||
.replace(/(\*|_)([^*_]+)\1/g, '$2')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/^[ \t]{0,3}(-{3,}|\*{3,})[ \t]*$/gm, '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** 无论来源是 HTML 还是 markdown,都压成可读纯文本(列表摘要、搜索、复制正文都用它)。 */
|
||||
export function toPlainText(content) {
|
||||
const text = String(content || '')
|
||||
if (!text.trim()) {
|
||||
return ''
|
||||
}
|
||||
if (isHtmlContent(text)) {
|
||||
return htmlToText(text)
|
||||
}
|
||||
if (isMarkdownContent(text)) {
|
||||
return markdownToText(text)
|
||||
}
|
||||
return text.replace(/\r\n?/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
/** 摘要不能直接 slice 原始内容——会把 HTML 标签切一半;这里输入的已是纯文本。 */
|
||||
export function truncateText(text, maxLength = 60) {
|
||||
const plain = String(text || '').replace(/\s+/g, ' ').trim()
|
||||
if (!plain) {
|
||||
return ''
|
||||
}
|
||||
return plain.length > maxLength ? `${plain.slice(0, maxLength)}…` : plain
|
||||
}
|
||||
|
||||
/**
|
||||
* 交给 mp-html 的渲染参数:
|
||||
* - HTML 原样渲染;
|
||||
* - markdown 打开 mp-html 的 markdown 插件;
|
||||
* - 纯文本按空行分段、转义后包成 `<p>`,否则 mp-html 会把换行吃掉。
|
||||
*/
|
||||
export function getRenderPayload(content) {
|
||||
const text = String(content || '')
|
||||
if (!text.trim()) {
|
||||
return {
|
||||
html: '',
|
||||
markdown: false
|
||||
}
|
||||
}
|
||||
if (isHtmlContent(text)) {
|
||||
return {
|
||||
html: text,
|
||||
markdown: false
|
||||
}
|
||||
}
|
||||
if (isMarkdownContent(text)) {
|
||||
return {
|
||||
html: text,
|
||||
markdown: true
|
||||
}
|
||||
}
|
||||
return {
|
||||
html: escapeHtml(text)
|
||||
.split(/\n{2,}/)
|
||||
.map((paragraph) => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
|
||||
.join(''),
|
||||
markdown: false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user