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:
2026-10-22 12:55:53 +08:00
parent d449abe966
commit 63295e98c5
3 changed files with 558 additions and 140 deletions
@@ -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
View File
@@ -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>
+138
View File
@@ -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(/&nbsp;/gi, ' ')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/&amp;/gi, '&')
}
function escapeHtml(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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
}
}