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:
xiaji
2026-06-15 07:32:39 +08:00
parent 8dfa302b96
commit b674fb4b22

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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;