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> <common-hero title="我的稿件" :use-image-bg="true"></common-hero>
<view class="hero-card"> <view class="hero-card">
<view class="hero-title">我的稿件</view> <view class="hero-title">我的稿件</view>
<view class="hero-desc">汇总当前账号创建或参与的稿件记录,可查看状态和内容摘要。</view> <view class="hero-desc">汇总当前账号创建或参与的稿件记录,点开任意一行即可阅读正文。</view>
<view class="hero-stats"> <view class="hero-stats">
<view class="stat-item"> <view class="stat-item">
<view class="stat-value">{{ manuscripts.length }}</view> <view class="stat-value">{{ rows.length }}</view>
<view class="stat-label">稿件总数</view> <view class="stat-label">稿件总数</view>
</view> </view>
<view class="stat-item"> <view class="stat-item">
@@ -44,80 +44,86 @@
<view class="state-desc">{{ errorText }}</view> <view class="state-desc">{{ errorText }}</view>
</view> </view>
<view v-else-if="!filteredList.length" class="state-card"> <view v-else-if="!filteredList.length" class="state-card">
<view class="state-title">暂无稿件</view> <view class="state-title">{{ rows.length ? '没有匹配的稿件' : '暂无稿件' }}</view>
<view class="state-desc">当前账号还没有可展示的稿件,可前往文稿工作台开始生成。</view> <view class="state-desc">
{{ rows.length ? '换个关键词再试试,或清空搜索框。' : '当前账号还没有可展示的稿件,可前往文稿工作台开始生成。' }}
</view>
</view> </view>
<view v-else class="card-list"> <view v-else class="list-card">
<view v-for="item in filteredList" :key="item.id || item.title" class="data-card"> <view class="list-head">
<view class="card-top"> <view class="list-head__title">共 {{ filteredList.length }} 篇稿件</view>
<view class="card-main"> <view class="list-head__hint">点击任意一行进入阅读</view>
<view class="card-title">{{ item.title || '未命名稿件' }}</view> </view>
<view class="card-subtitle"> <view
创建时间:{{ formatDateTime(item.createTime) }} · 更新时间:{{ formatDateTime(item.updateTime) }} v-for="item in filteredList"
</view> :key="item.key"
</view> class="list-row"
<view class="status-tag" :class="`status-tag--${getStatusMeta(item).tone}`"> hover-class="list-row--active"
{{ getStatusMeta(item).text }} @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> </view>
<view v-if="item.cover" class="cover-wrap"> <view class="list-row__chevron">›</view>
<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> </view>
</view> </view>
</scroll-view> </scroll-view>
<manuscript-reader :item="reader" @close="closeReader"></manuscript-reader>
</view> </view>
</template> </template>
<script> <script>
import CommonHero from '../components/common-hero/common-hero.vue' import CommonHero from '../components/common-hero/common-hero.vue'
import ManuscriptReader from '../components/manuscript-reader/manuscript-reader.vue'
import { import {
formatDateTime, formatDateTime,
getCurrentUser, getCurrentUser,
getManuscriptStatusMeta, getManuscriptStatusMeta,
listManuscript listManuscript
} from '../pages/mine/service' } from '../pages/mine/service'
import { toPlainText, truncateText } from '../utils/content-format'
const HEADLINE_LENGTH = 56
export default { export default {
components: { components: {
CommonHero CommonHero,
ManuscriptReader
}, },
data() { data() {
return { return {
loading: false, loading: false,
errorText: '', errorText: '',
keyword: '', keyword: '',
expandedId: null, rows: [],
manuscripts: [] reader: null
} }
}, },
computed: { computed: {
filteredList() { filteredList() {
const keyword = String(this.keyword || '').trim().toLowerCase() const keyword = String(this.keyword || '').trim().toLowerCase()
if (!keyword) { if (!keyword) {
return this.manuscripts return this.rows
} }
return this.manuscripts.filter((item) => { return this.rows.filter((item) => {
return ( return item.searchText.includes(keyword)
String(item.title || '').toLowerCase().includes(keyword) ||
String(item.content || '').toLowerCase().includes(keyword)
)
}) })
}, },
publishedCount() { publishedCount() {
return this.manuscripts.filter((item) => { return this.rows.filter((item) => item.statusText === '通过' || item.statusText === '已发布').length
const text = this.getStatusMeta(item).text
return text === '通过' || text === '已发布'
}).length
}, },
pendingCount() { pendingCount() {
return this.manuscripts.length - this.publishedCount return this.rows.length - this.publishedCount
} }
}, },
onShow() { onShow() {
@@ -128,12 +134,36 @@
}, },
methods: { methods: {
formatDateTime, 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) { applyManuscripts(list, user) {
const text = String(content || '').replace(/\s+/g, ' ').trim() this.rows = (Array.isArray(list) ? list : [])
return text ? `${text.slice(0, 90)}${text.length > 90 ? '...' : ''}` : '暂无内容摘要' .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) { async loadData(fromPullDown = false) {
this.loading = true this.loading = true
@@ -141,14 +171,7 @@
try { try {
const user = getCurrentUser() const user = getCurrentUser()
const result = await listManuscript() const result = await listManuscript()
const list = Array.isArray(result) ? result : [] this.applyManuscripts(Array.isArray(result) ? result : [], user)
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)
})
} catch (error) { } catch (error) {
this.errorText = (error && error.message) || '稿件加载失败' this.errorText = (error && error.message) || '稿件加载失败'
} finally { } finally {
@@ -158,26 +181,11 @@
} }
} }
}, },
toggleExpand(item) { openReader(item) {
this.expandedId = this.expandedId === item.id ? null : item.id this.reader = item
}, },
copyTitle(title) { closeReader() {
if (!title) { this.reader = null
uni.showToast({
title: '暂无标题可复制',
icon: 'none'
})
return
}
uni.setClipboardData({
data: title,
success: () => {
uni.showToast({
title: '标题已复制',
icon: 'none'
})
}
})
}, },
goCreate() { goCreate() {
uni.navigateTo({ uni.navigateTo({
@@ -204,7 +212,7 @@
.hero-card, .hero-card,
.toolbar-card, .toolbar-card,
.state-card, .state-card,
.data-card { .list-card {
border-radius: 30rpx; border-radius: 30rpx;
} }
@@ -237,7 +245,7 @@
.toolbar-card, .toolbar-card,
.state-card, .state-card,
.data-card { .list-card {
background: rgba(255, 255, 255, 0.94); background: rgba(255, 255, 255, 0.94);
border: 1rpx solid rgba(240, 122, 61, 0.08); border: 1rpx solid rgba(240, 122, 61, 0.08);
box-shadow: 0 10rpx 28rpx rgba(76, 49, 35, 0.08); box-shadow: 0 10rpx 28rpx rgba(76, 49, 35, 0.08);
@@ -253,19 +261,20 @@
.stat-item { .stat-item {
padding: 18rpx 16rpx; padding: 18rpx 16rpx;
border-radius: 22rpx; border-radius: 22rpx;
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.62);
text-align: center; text-align: center;
} }
.stat-value { .stat-value {
font-size: 32rpx; font-size: 32rpx;
font-weight: 700; font-weight: 700;
color: #22272f;
} }
.stat-label { .stat-label {
margin-top: 6rpx; margin-top: 6rpx;
font-size: 22rpx; font-size: 22rpx;
color: rgba(255, 250, 245, 0.8); color: #8b817c;
} }
.toolbar-card { .toolbar-card {
@@ -336,106 +345,105 @@
color: #786e69; color: #786e69;
} }
.card-list { .list-card {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 24rpx; margin-top: 24rpx;
overflow: hidden;
} }
.data-card { .list-head {
padding: 24rpx;
}
.card-top {
display: flex; display: flex;
align-items: flex-start; align-items: baseline;
justify-content: space-between; 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; flex: 1;
min-width: 0; min-width: 0;
} }
.card-title { .list-row__title {
font-size: 30rpx; font-size: 29rpx;
font-weight: 700; font-weight: 700;
line-height: 1.45;
color: #22272f; color: #22272f;
} }
.card-subtitle { .list-row__headline {
margin-top: 8rpx; margin-top: 8rpx;
font-size: 22rpx; font-size: 23rpx;
line-height: 1.6; color: #8d817a;
color: #8b817c; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.status-tag { .list-row__meta {
padding: 10rpx 16rpx; display: flex;
border-radius: 999rpx; align-items: center;
font-size: 20rpx; margin-top: 12rpx;
font-weight: 700; font-size: 21rpx;
color: #a2968e;
} }
.status-tag--success { .list-dot {
background: #eef9f1; width: 12rpx;
height: 12rpx;
margin-right: 8rpx;
border-radius: 50%;
background: currentColor;
}
.list-dot--success {
color: #1f8f49; color: #1f8f49;
} }
.status-tag--danger { .list-dot--danger {
background: #fff1f0;
color: #d4380d; color: #d4380d;
} }
.status-tag--warning { .list-dot--warning {
background: #fff8e6;
color: #ad7b00; color: #ad7b00;
} }
.status-tag--default { .list-dot--default {
background: #f5f5f5; color: #c2b6ae;
color: #8b817c;
} }
.cover-wrap { .list-row__status {
margin-top: 18rpx; color: #7d716a;
} }
.cover-image { .list-row__sep {
width: 100%; padding: 0 8rpx;
height: 260rpx; color: #d2c8c1;
border-radius: 24rpx;
} }
.content-preview, .list-row__chevron {
.content-detail { font-size: 34rpx;
margin-top: 18rpx; color: #c9bdb4;
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;
} }
</style> </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
}
}