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)
|
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 ''
|
if (!c) return ''
|
||||||
const trimmed = c.replace(/\s+/g, ' ').trim()
|
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' {
|
function commentaryStatusType(s?: string | null): 'success' | 'warning' | 'error' | 'default' {
|
||||||
@@ -269,7 +293,7 @@ onMounted(async () => {
|
|||||||
</NText>
|
</NText>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
<!-- 中文标题(主) -->
|
<!-- 中文标题(主)— 搜索时高亮 q -->
|
||||||
<div
|
<div
|
||||||
v-if="a.title_zh"
|
v-if="a.title_zh"
|
||||||
style="
|
style="
|
||||||
@@ -279,17 +303,15 @@ onMounted(async () => {
|
|||||||
color: var(--color-letter);
|
color: var(--color-letter);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
"
|
"
|
||||||
>
|
v-html="highlightHtml(a.title_zh, q)"
|
||||||
{{ a.title_zh }}
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 原标题(灰色,辅助) -->
|
<!-- 原标题(灰色,辅助)— 搜索时高亮 q -->
|
||||||
<div
|
<div
|
||||||
v-if="a.title"
|
v-if="a.title"
|
||||||
style="font-size: 13px; color: var(--color-text-faint); line-height: 1.4;"
|
style="font-size: 13px; color: var(--color-text-faint); line-height: 1.4;"
|
||||||
>
|
v-html="highlightHtml(a.title, q)"
|
||||||
{{ a.title }}
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI 插图(若有;短新闻不显示) -->
|
<!-- AI 插图(若有;短新闻不显示) -->
|
||||||
<img
|
<img
|
||||||
@@ -312,6 +334,7 @@ onMounted(async () => {
|
|||||||
正文摘要:
|
正文摘要:
|
||||||
- 长新闻:body_zh_text 截前 200 字(去多余空白)
|
- 长新闻:body_zh_text 截前 200 字(去多余空白)
|
||||||
- 短新闻:body_zh_text(=body_text)完整展示,保留换行
|
- 短新闻:body_zh_text(=body_text)完整展示,保留换行
|
||||||
|
- 搜索时高亮 q(escape + <mark> 包裹,无 XSS)
|
||||||
-->
|
-->
|
||||||
<div
|
<div
|
||||||
v-if="a.body_zh_text || a.summary_zh"
|
v-if="a.body_zh_text || a.summary_zh"
|
||||||
@@ -322,13 +345,13 @@ onMounted(async () => {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
"
|
"
|
||||||
>
|
v-html="highlightHtml(
|
||||||
{{
|
|
||||||
a.is_short_news
|
a.is_short_news
|
||||||
? bodyExcerpt(a.body_zh_text || a.summary_zh || '', 5000, true)
|
? 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
|
||||||
</div>
|
)"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 评论钩子(双 provider:Angel + 美团,三态显式显示:有内容 / 等待中 / 失败) -->
|
<!-- 评论钩子(双 provider:Angel + 美团,三态显式显示:有内容 / 等待中 / 失败) -->
|
||||||
<div class="commentary-stack">
|
<div class="commentary-stack">
|
||||||
@@ -360,7 +383,8 @@ onMounted(async () => {
|
|||||||
<div
|
<div
|
||||||
v-if="commentaryState(a.commentary_status, a.commentary) === 'ok'"
|
v-if="commentaryState(a.commentary_status, a.commentary) === 'ok'"
|
||||||
class="commentary-text"
|
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">
|
<div v-else-if="commentaryState(a.commentary_status, a.commentary) === 'failed'" class="commentary-text commentary-text-failed">
|
||||||
评论生成失败,后台 enrichment_loop 会重试
|
评论生成失败,后台 enrichment_loop 会重试
|
||||||
</div>
|
</div>
|
||||||
@@ -396,7 +420,8 @@ onMounted(async () => {
|
|||||||
<div
|
<div
|
||||||
v-if="commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'ok'"
|
v-if="commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'ok'"
|
||||||
class="commentary-text"
|
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">
|
<div v-else-if="commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'failed'" class="commentary-text commentary-text-failed">
|
||||||
评论生成失败,后台 enrichment_loop 会重试
|
评论生成失败,后台 enrichment_loop 会重试
|
||||||
</div>
|
</div>
|
||||||
@@ -647,6 +672,15 @@ onMounted(async () => {
|
|||||||
font-size: 11px;
|
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 {
|
.feed-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user