feat(search): 智能搜索建议 - 固化候选词表 (search_keywords + search_title_suggestions)

后端:
- alembic 0009: 两张固化表 + GIN prefix_keys 索引 + articles trigger
- /api/v1/search/suggestions: 混合 A(高频词 ts_stat) + B(真实标题) + 冷启动 fallback
- worker 每日 03:00 + 启动时刷新 search_keywords
- 顺便填 commit 11 TODO: articles.title_zh_tsv + GIN 索引(未来 FTS 基础)

前端:
- NInput -> NAutoComplete + debounce 250ms
- 选标题 -> 跳详情;选关键词 -> 填入 + 触发搜索
- AbortController 防 race condition

性能: prefix_keys @> ARRAY[prefix] 走 GIN 亚毫秒,100w 行也稳
This commit is contained in:
mavis
2026-06-15 18:26:35 +08:00
parent b674fb4b22
commit c3aa0f0cb6
13 changed files with 1028 additions and 7 deletions

View File

@@ -0,0 +1,35 @@
import type { AxiosRequestConfig } from 'axios'
import { http } from './client'
export interface SearchTitleSuggestion {
id: number
published_at: string | null
lang: string // 'zh' / 'src'
}
export interface SearchKeyword {
word: string
weight: number
source: string // 'ts_stat' / 'title_extract' / 'manual' / 'ts_stat_live'
}
export interface SearchSuggestionsResponse {
query: string
titles: SearchTitleSuggestion[]
keywords: SearchKeyword[]
}
/** 搜索建议(autocomplete)。q 必须是 1-20 字符前缀。 */
export const searchApi = {
async suggestions(
q: string,
limit = 10,
config?: AxiosRequestConfig,
): Promise<SearchSuggestionsResponse> {
const { data } = await http.get<SearchSuggestionsResponse>('/search/suggestions', {
params: { q, limit },
...config,
})
return data
},
}

View File

@@ -0,0 +1,37 @@
/**
* 通用 debounce composable。
*
* 用法:
* const debouncedFn = useDebounce((v: string) => doSomething(v), 250)
* // 在 watch/input 里:
* debouncedFn(value)
*
* 为什么不直接 lodash.debounce:项目没装 lodash,这个场景不值得装;
* 实现 ~15 行,不引依赖。
*/
import { onBeforeUnmount } from 'vue'
export function useDebounce<T extends (...args: any[]) => any>(
fn: T,
delay = 250,
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null
const debounced = (...args: Parameters<T>) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
timer = null
fn(...args)
}, delay)
}
// 组件卸载时清掉挂起的 timer,避免内存泄漏 + setState on unmounted
onBeforeUnmount(() => {
if (timer) {
clearTimeout(timer)
timer = null
}
})
return debounced
}

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, h, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
NCard, NSpace, NTag, NText, NSelect, NInput, NButton, NEmpty, NSkeleton, NSpin,
NPagination, useMessage,
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 { useDebounce } from '@/composables/useDebounce'
import { useAuthStore } from '@/stores/auth'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
@@ -35,6 +37,130 @@ const hideRead = ref(true)
const sourceOptions = ref<{ label: string; value: string }[]>([])
// === 搜索建议(autocomplete) ===
// 触发: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
}
// 取消上一次未完成的
suggestAbort?.abort()
const ctrl = new AbortController()
suggestAbort = ctrl
try {
const resp = await searchApi.suggestions(p, 10, { signal: ctrl.signal })
// 注意:race condition 防护 — 只采纳最新请求的响应
if (suggestAbort === ctrl) {
suggestTitles.value = resp.titles
suggestKeywords.value = resp.keywords
}
} catch (e: any) {
// 被 abort 的请求静默忽略(用户继续输入中)
if (e?.code !== 'ERR_CANCELED' && e?.name !== 'CanceledError') {
// 静默失败:不弹错误(autocomplete 失败不应干扰用户)
// eslint-disable-next-line no-console
console.debug('search suggestions failed:', e?.message)
if (suggestAbort === ctrl) {
suggestTitles.value = []
suggestKeywords.value = []
}
}
}
}
const debouncedFetchSuggestions = useDebounce(fetchSuggestions, 250)
watch(q, (v) => {
debouncedFetchSuggestions(v)
})
// === NAutoComplete options ===
// 把 titles + keywords 拼成扁平 options 数组。
// 用 discriminated union 让 TypeScript 在 onSelect 里能自动 narrow 出 meta 的具体类型。
type SuggestTitleOption = {
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
})
// 自定义 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-text' }, opt.label),
],
)
}
// 选完候选词:从当前 suggestOptions 反查 meta。
// naive-ui 的 NAutoComplete 选完时只把 value 写回 v-model,我们额外用
// ref 维护一个 lastSelectedType,模板里在 click 时先 setType,on-select 时
// 再读 — 但更简单的做法:看 value 格式。title 类型的 value 是纯数字 id 字符串,
// keyword 类型是中文/字母词。
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') {
q.value = matched.meta.word
resetToFirstPage()
return
}
// 兜底
if (/^\d+$/.test(value)) {
router.push(`/article/${value}`)
} else {
q.value = value
resetToFirstPage()
}
}
async function load() {
if (loading.value) return
loading.value = true
@@ -212,8 +338,17 @@ onMounted(async () => {
class="feed-source-select"
@update:value="resetToFirstPage"
/>
<NInput v-model:value="q" placeholder="关键词搜索" clearable class="feed-search-input"
@keyup.enter="resetToFirstPage" @clear="resetToFirstPage" />
<NAutoComplete
v-model:value="q"
placeholder="关键词搜索"
clearable
class="feed-search-input"
:options="suggestOptions"
:render-label="renderSuggestion"
@select="onSelectSuggestion"
@keyup.enter="resetToFirstPage"
@clear="resetToFirstPage"
/>
<NSpace align="center" :size="6" class="feed-hideread-toggle">
<NText style="font-size: 13px">隐藏已读</NText>
<NSwitch v-model:value="hideRead" @update:value="resetToFirstPage" />
@@ -602,6 +737,46 @@ onMounted(async () => {
.feed-search-input {
width: 220px;
}
/* NAutoComplete 外层 div 不默认撑满,需要让内部 n-input 占满 */
.feed-search-input.n-auto-complete {
display: inline-block;
}
.feed-search-input.n-auto-complete > .n-input {
width: 100%;
}
/* === 搜索建议下拉项 === */
.feed-suggest-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.feed-suggest-tag {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
line-height: 1.4;
flex-shrink: 0;
}
.feed-suggest-tag-title {
background: #dbeafe;
color: #1e40af;
}
.feed-suggest-tag-keyword {
background: #f3e8ff;
color: #6b21a8;
}
.feed-suggest-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
/* ===== 移动端(<= 768px):过滤条全宽,允许换行 ===== */
@media (max-width: 768px) {