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:
35
frontend/src/api/search.ts
Normal file
35
frontend/src/api/search.ts
Normal 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
|
||||
},
|
||||
}
|
||||
37
frontend/src/composables/useDebounce.ts
Normal file
37
frontend/src/composables/useDebounce.ts
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user