From b674fb4b2253b12b008e9126b83079bbf0cbc326 Mon Sep 17 00:00:00 2001 From: xiaji Date: Mon, 15 Jun 2026 07:32:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(search):=20=E6=90=9C=E7=B4=A2=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=85=B3=E9=94=AE=E5=AD=97=E9=AB=98=E4=BA=AE(?= =?UTF-8?q?=E6=A0=87=E9=A2=98/=E6=AD=A3=E6=96=87/=E8=AF=84=E8=AE=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feed.vue 搜索 q 时,命中的关键字在卡片标题/正文/双 provider 评论预览里 包裹高亮(暖黄底 + 加粗)。 实现: - 新增 escapeHtml(text) — 防止 XSS(content 来自外部 RSS/ingest, 不可信;先 escape 再 replace,确保 之外不会有任何原始 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 - 标签是 escape 之后才插入,不会引入新的 XSS 通道 - q 特殊字符转义,不构成 regex DoS 不影响: - q 为空时(highlightHtml(text, '') = escapeHtml(text) 等价于 Vue 原生 {{ }} 自动 escape) - 非搜索场景行为完全不变 - waiting/failed 状态的评论不显示评论内容,不需高亮 - 短新闻正文也支持高亮(q 不空时,完整 5000 字都高亮匹配项) 无后端改动。 --- frontend/src/views/Feed.vue | 70 +++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/frontend/src/views/Feed.vue b/frontend/src/views/Feed.vue index 517e545..9f414fa 100644 --- a/frontend/src/views/Feed.vue +++ b/frontend/src/views/Feed.vue @@ -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 标签由 .commentary-text / 标题区样式接管) +// 返回 HTML 字符串,供 v-html 使用。 +function escapeHtml(s: string): string { + return s + .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) => `${m}`) +} + +// 评论预览:长文截断 + 搜索时高亮 +// 返回 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 () => { - +
- {{ a.title_zh }} -
+ v-html="highlightHtml(a.title_zh, q)" + /> - +
- {{ a.title }} -
+ v-html="highlightHtml(a.title, q)" + /> { 正文摘要: - 长新闻:body_zh_text 截前 200 字(去多余空白) - 短新闻:body_zh_text(=body_text)完整展示,保留换行 + - 搜索时高亮 q(escape + 包裹,无 XSS) -->
{ 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) - }} -
+ : bodyExcerpt(a.body_zh_text || a.summary_zh, 200), + q + )" + />
@@ -360,7 +383,8 @@ onMounted(async () => {
{{ previewCommentary(a.commentary, 140) }}
+ v-html="previewCommentary(a.commentary, 140, q)" + />
评论生成失败,后台 enrichment_loop 会重试
@@ -396,7 +420,8 @@ onMounted(async () => {
{{ previewCommentary(a.commentary_meituan, 140) }}
+ v-html="previewCommentary(a.commentary_meituan, 140, q)" + />
评论生成失败,后台 enrichment_loop 会重试
@@ -647,6 +672,15 @@ onMounted(async () => { font-size: 11px; } +/* === 搜索关键字高亮( 标签)=== */ +.feed-list :deep(mark) { + background: #fff3a0; /* 暖黄底,跟 Naive UI 主题协调 */ + color: inherit; /* 保持父级文字色,不被 默认色影响 */ + padding: 0 2px; + border-radius: 2px; + font-weight: 600; /* 命中后略加粗,视觉强调 */ +} + /* === 底部操作栏(浮在卡片右下角)=== */ .feed-actions { display: flex;