feat(search): 搜索结果关键字高亮(标题/正文/评论)
Feed.vue 搜索 q 时,命中的关键字在卡片标题/正文/双 provider
评论预览里 <mark> 包裹高亮(暖黄底 + 加粗)。
实现:
- 新增 escapeHtml(text) — 防止 XSS(content 来自外部 RSS/ingest,
不可信;先 escape 再 replace,确保 <mark> 之外不会有任何原始
HTML 进入 DOM)
- 新增 highlightHtml(text, q) — 不区分大小写匹配,正则元字符
(.*+?^${}()|[]\\) 自动转义(避免用户搜 "*.x" 时被当 regex)
q 为空时返回纯 escape 文本(行为与原来 {{ }} 插值一致)
- 改造 previewCommentary(text, max, q) — 第三个参数 q 透传
highlightHtml
- 4 处渲染改 {{ }} -> v-html,传 highlightHtml(previewCommentary
(..., q)):
- 中文标题 + 原标题
- 正文摘要
- Angel 评论预览
- 美团评论预览
样式:
- .feed-list :deep(mark) 暖黄底 (#fff3a0) + inherit 父级文字色 +
padding 2px + 加粗
- :deep() 避免 Naive UI 组件 scoped 样式隔离问题
安全:
- 所有用户内容先 escapeHtml,再 replace
- <mark> 标签是 escape 之后才插入,不会引入新的 XSS 通道
- q 特殊字符转义,不构成 regex DoS
不影响:
- q 为空时(highlightHtml(text, '') = escapeHtml(text) 等价于
Vue 原生 {{ }} 自动 escape) - 非搜索场景行为完全不变
- waiting/failed 状态的评论不显示评论内容,不需高亮
- 短新闻正文也支持高亮(q 不空时,完整 5000 字都高亮匹配项)
无后端改动。
This commit is contained in:
@@ -130,11 +130,35 @@ function splitCategory(c?: string | null): string[] {
|
||||
return c.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// 评论预览:长文截断
|
||||
function previewCommentary(c?: string | null, max = 120): string {
|
||||
// === 搜索关键字高亮(用于标题/评论预览的渲染)===
|
||||
// 1) HTML escape — 防止 XSS(content 来自外部 RSS/ingest,不可信)
|
||||
// 2) 不区分大小写匹配 q
|
||||
// 3) 用 <mark> 包裹匹配项(全局 mark 标签由 .commentary-text / 标题区样式接管)
|
||||
// 返回 HTML 字符串,供 v-html 使用。
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
function highlightHtml(text: string | null | undefined, q: string): string {
|
||||
const safe = escapeHtml(text || '')
|
||||
if (!q || !q.trim()) return safe
|
||||
// 转义 q 里可能的正则元字符(用户搜 "*.x" 等不应该当 regex)
|
||||
const escapedQ = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const re = new RegExp(escapedQ, 'gi')
|
||||
return safe.replace(re, (m) => `<mark>${m}</mark>`)
|
||||
}
|
||||
|
||||
// 评论预览:长文截断 + 搜索时高亮
|
||||
// 返回 HTML 字符串(供 v-html 渲染)。q 为空时等价于纯文本(无高亮标签)。
|
||||
function previewCommentary(c?: string | null, max = 120, q = ''): string {
|
||||
if (!c) return ''
|
||||
const trimmed = c.replace(/\s+/g, ' ').trim()
|
||||
return trimmed.length > max ? trimmed.slice(0, max) + '…' : trimmed
|
||||
const text = trimmed.length > max ? trimmed.slice(0, max) + '…' : trimmed
|
||||
return highlightHtml(text, q)
|
||||
}
|
||||
|
||||
function commentaryStatusType(s?: string | null): 'success' | 'warning' | 'error' | 'default' {
|
||||
@@ -269,7 +293,7 @@ onMounted(async () => {
|
||||
</NText>
|
||||
</NSpace>
|
||||
|
||||
<!-- 中文标题(主) -->
|
||||
<!-- 中文标题(主)— 搜索时高亮 q -->
|
||||
<div
|
||||
v-if="a.title_zh"
|
||||
style="
|
||||
@@ -279,17 +303,15 @@ onMounted(async () => {
|
||||
color: var(--color-letter);
|
||||
line-height: 1.4;
|
||||
"
|
||||
>
|
||||
{{ a.title_zh }}
|
||||
</div>
|
||||
v-html="highlightHtml(a.title_zh, q)"
|
||||
/>
|
||||
|
||||
<!-- 原标题(灰色,辅助) -->
|
||||
<!-- 原标题(灰色,辅助)— 搜索时高亮 q -->
|
||||
<div
|
||||
v-if="a.title"
|
||||
style="font-size: 13px; color: var(--color-text-faint); line-height: 1.4;"
|
||||
>
|
||||
{{ a.title }}
|
||||
</div>
|
||||
v-html="highlightHtml(a.title, q)"
|
||||
/>
|
||||
|
||||
<!-- AI 插图(若有;短新闻不显示) -->
|
||||
<img
|
||||
@@ -312,6 +334,7 @@ onMounted(async () => {
|
||||
正文摘要:
|
||||
- 长新闻:body_zh_text 截前 200 字(去多余空白)
|
||||
- 短新闻:body_zh_text(=body_text)完整展示,保留换行
|
||||
- 搜索时高亮 q(escape + <mark> 包裹,无 XSS)
|
||||
-->
|
||||
<div
|
||||
v-if="a.body_zh_text || a.summary_zh"
|
||||
@@ -322,13 +345,13 @@ onMounted(async () => {
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
"
|
||||
>
|
||||
{{
|
||||
v-html="highlightHtml(
|
||||
a.is_short_news
|
||||
? bodyExcerpt(a.body_zh_text || a.summary_zh || '', 5000, true)
|
||||
: bodyExcerpt(a.body_zh_text || a.summary_zh, 200)
|
||||
}}
|
||||
</div>
|
||||
: bodyExcerpt(a.body_zh_text || a.summary_zh, 200),
|
||||
q
|
||||
)"
|
||||
/>
|
||||
|
||||
<!-- 评论钩子(双 provider:Angel + 美团,三态显式显示:有内容 / 等待中 / 失败) -->
|
||||
<div class="commentary-stack">
|
||||
@@ -360,7 +383,8 @@ onMounted(async () => {
|
||||
<div
|
||||
v-if="commentaryState(a.commentary_status, a.commentary) === 'ok'"
|
||||
class="commentary-text"
|
||||
>{{ previewCommentary(a.commentary, 140) }}</div>
|
||||
v-html="previewCommentary(a.commentary, 140, q)"
|
||||
/>
|
||||
<div v-else-if="commentaryState(a.commentary_status, a.commentary) === 'failed'" class="commentary-text commentary-text-failed">
|
||||
评论生成失败,后台 enrichment_loop 会重试
|
||||
</div>
|
||||
@@ -396,7 +420,8 @@ onMounted(async () => {
|
||||
<div
|
||||
v-if="commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'ok'"
|
||||
class="commentary-text"
|
||||
>{{ previewCommentary(a.commentary_meituan, 140) }}</div>
|
||||
v-html="previewCommentary(a.commentary_meituan, 140, q)"
|
||||
/>
|
||||
<div v-else-if="commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'failed'" class="commentary-text commentary-text-failed">
|
||||
评论生成失败,后台 enrichment_loop 会重试
|
||||
</div>
|
||||
@@ -647,6 +672,15 @@ onMounted(async () => {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* === 搜索关键字高亮(<mark> 标签)=== */
|
||||
.feed-list :deep(mark) {
|
||||
background: #fff3a0; /* 暖黄底,跟 Naive UI 主题协调 */
|
||||
color: inherit; /* 保持父级文字色,不被 <mark> 默认色影响 */
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
font-weight: 600; /* 命中后略加粗,视觉强调 */
|
||||
}
|
||||
|
||||
/* === 底部操作栏(浮在卡片右下角)=== */
|
||||
.feed-actions {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user