refactor(search): 只展示 keyword 续接词,去掉 titles 段

产品决定:搜索建议只展示 ts_stat 高频词续接(如'美'→美国/美军/美国政府),
不要真实文章 id 提示(用户认为这种'文章#566871'是噪音,没连续性)。

改动:
- SearchSuggestionsResponse 去 title,只剩 query + keywords
- SearchService 只查 search_keywords,fallback 路径也只针对 keywords
- Feed.vue: 删掉 suggestTitles 状态 + SuggestTitleOption 类型联合,
  renderSuggestion 简化成 '词' 标签 + 词文本 + 右侧 weight 数字
- 0011 迁移: 删 search_title_suggestions 表 + 3 索引 + trigger + 函数
  (trigger 在每篇文章 INSERT/UPDATE 都会跑,删了能省掉无用性能损耗)
- 删除: app/models/search_title_suggestion.py + backfill_search_suggestions.py
  替换成: app/scripts/refresh_search_keywords.py(只跑一次词频刷新)
This commit is contained in:
mavis
2026-06-15 19:37:40 +08:00
parent db4fd8699b
commit 85c05c19a7
10 changed files with 277 additions and 366 deletions

View File

@@ -6,7 +6,7 @@ import {
NPagination, NAutoComplete, useMessage,
} from 'naive-ui'
import { articlesApi, readsApi, sourcesApi, type ArticleListItem, type Source } from '@/api/articles'
import { searchApi, type SearchKeyword, type SearchTitleSuggestion } from '@/api/search'
import { searchApi, type SearchKeyword } from '@/api/search'
import { useDebounce } from '@/composables/useDebounce'
import { useAuthStore } from '@/stores/auth'
import dayjs from 'dayjs'
@@ -37,18 +37,16 @@ const hideRead = ref(true)
const sourceOptions = ref<{ label: string; value: string }[]>([])
// === 搜索建议(autocomplete) ===
// === 搜索建议(autocomplete) — 纯 keyword 续接词 ===
// 触发:q 变化(用户输入)→ 250ms debounce → 调 /api/v1/search/suggestions
// 取消:每次新输入前 abort 上一次未完成的请求,避免旧响应覆盖新结果
// 选词:@select → 填入 q + 触发搜索(不再等回车)
const suggestTitles = ref<SearchTitleSuggestion[]>([])
const suggestKeywords = ref<SearchKeyword[]>([])
let suggestAbort: AbortController | null = null
async function fetchSuggestions(prefix: string) {
const p = prefix.trim()
if (!p) {
suggestTitles.value = []
suggestKeywords.value = []
return
}
@@ -58,9 +56,8 @@ async function fetchSuggestions(prefix: string) {
suggestAbort = ctrl
try {
const resp = await searchApi.suggestions(p, 10, { signal: ctrl.signal })
// 注意:race condition 防护 — 只采纳最新请求的响应
// race condition 防护 — 只采纳最新请求的响应
if (suggestAbort === ctrl) {
suggestTitles.value = resp.titles
suggestKeywords.value = resp.keywords
}
} catch (e: any) {
@@ -70,7 +67,6 @@ async function fetchSuggestions(prefix: string) {
// eslint-disable-next-line no-console
console.debug('search suggestions failed:', e?.message)
if (suggestAbort === ctrl) {
suggestTitles.value = []
suggestKeywords.value = []
}
}
@@ -84,78 +80,42 @@ watch(q, (v) => {
})
// === NAutoComplete options ===
// 把 titles + keywords 拼成扁平 options 数组
// 用 discriminated union 让 TypeScript 在 onSelect 里能自动 narrow 出 meta 的具体类型。
type SuggestTitleOption = {
// 只用 keyword 续接词,扁平结构
type SuggestOption = {
label: string
value: string
type: 'title'
meta: SearchTitleSuggestion
}
type SuggestKeywordOption = {
label: string
value: string
type: 'keyword'
meta: SearchKeyword
}
type SuggestOption = SuggestTitleOption | SuggestKeywordOption
const suggestOptions = computed<SuggestOption[]>(() => {
const out: SuggestOption[] = []
for (const t of suggestTitles.value) {
// 标题项:label 用 #id 标识(可后续扩展拉标题),value 是 id 字符串
out.push({
label: `#${t.id}`,
value: t.id.toString(),
type: 'title',
meta: t,
})
}
for (const k of suggestKeywords.value) {
out.push({
label: k.word,
value: k.word,
type: 'keyword',
meta: k,
})
}
return out
return suggestKeywords.value.map((k) => ({
label: k.word,
value: k.word,
meta: k,
}))
})
// 自定义 render:显示分类图标 + 类型
// 自定义 render:显示"词"标签 + 词文本 + 权重
function renderSuggestion(opt: SuggestOption) {
return h(
'div',
{ class: 'feed-suggest-row' },
[
h('span', { class: `feed-suggest-tag feed-suggest-tag-${opt.type}` },
opt.type === 'title' ? '文章' : '词'),
h('span', { class: 'feed-suggest-tag feed-suggest-tag-keyword' }, '词'),
h('span', { class: 'feed-suggest-text' }, opt.label),
h('span', { class: 'feed-suggest-weight' }, String(opt.meta.weight)),
],
)
}
// 选完候选词:从当前 suggestOptions 反查 meta
// naive-ui 的 NAutoComplete 选完时只把 value 写回 v-model,我们额外用
// ref 维护一个 lastSelectedType,模板里在 click 时先 setType,on-select 时
// 再读 — 但更简单的做法:看 value 格式。title 类型的 value 是纯数字 id 字符串,
// keyword 类型是中文/字母词。
// 选完候选词:naive-ui 把 value 写回 v-model,我们从 suggestOptions meta
function onSelectSuggestion(value: string) {
// 反查 suggestOptions 找 meta
const matched = suggestOptions.value.find((o) => o.value === value)
if (matched?.type === 'title') {
router.push(`/article/${matched.meta.id}`)
return
}
if (matched?.type === 'keyword') {
if (matched) {
q.value = matched.meta.word
resetToFirstPage()
return
}
// 兜底
if (/^\d+$/.test(value)) {
router.push(`/article/${value}`)
} else {
// 兜底:value 就是用户要的关键词
q.value = value
resetToFirstPage()
}
@@ -762,10 +722,6 @@ onMounted(async () => {
line-height: 1.4;
flex-shrink: 0;
}
.feed-suggest-tag-title {
background: #dbeafe;
color: #1e40af;
}
.feed-suggest-tag-keyword {
background: #f3e8ff;
color: #6b21a8;
@@ -777,6 +733,12 @@ onMounted(async () => {
flex: 1;
min-width: 0;
}
.feed-suggest-weight {
flex-shrink: 0;
font-size: 11px;
color: #94a3b8;
font-variant-numeric: tabular-nums;
}
/* ===== 移动端(<= 768px):过滤条全宽,允许换行 ===== */
@media (max-width: 768px) {