fix(search): 将搜索历史持久化到应用数据库

This commit is contained in:
2026-09-04 19:33:43 +08:00
parent 6eb97bf9ab
commit 1d0f19508a
9 changed files with 119 additions and 41 deletions
+3 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { SearchResult } from '@/contracts'
import { useEditorStore } from '@/stores/editor'
@@ -7,6 +7,7 @@ import { useSearchStore } from '@/stores/search'
import { useWorkspaceStore } from '@/stores/workspace'
const searchStore = useSearchStore()
onMounted(() => { void searchStore.loadHistory() })
const workspaceStore = useWorkspaceStore()
const editorStore = useEditorStore()
const router = useRouter()
@@ -48,7 +49,7 @@ async function openResult(result: SearchResult) {
<div v-if="searchStore.error" class="error-banner">{{ searchStore.error }}</div>
<div v-if="searchStore.historyError" class="notice-banner">{{ searchStore.historyError }}</div>
<div v-if="searchStore.recentQueries.length" class="search-history">
<span class="subtle">最近搜索保存在当前浏览器</span>
<span class="subtle">最近搜索保存在应用数据中</span>
<button v-for="item in searchStore.recentQueries" :key="item" class="button-secondary" @click="searchStore.query = item; submitSearch()">{{ item }}</button>
<button class="button-secondary" @click="searchStore.clearHistory">清空记录</button>
</div>
+3
View File
@@ -1,6 +1,9 @@
import apiClient from './apiClient'
import type { ApiSearchResult, PageMeta, SearchRequest, SearchResult } from '@/contracts'
export function getHistory() { return apiClient.get<{ queries: string[] }>('/api/search/history') }
export function clearHistory() { return apiClient.delete<{ queries: string[] }>('/api/search/history') }
export async function search(request: SearchRequest): Promise<{
results: SearchResult[]
total: number
+24 -23
View File
@@ -1,34 +1,35 @@
// @vitest-environment happy-dom
import { beforeEach, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useSearchStore } from './search'
import { search } from '@/services/searchService'
vi.mock('@/services/searchService', () => ({ search: vi.fn() }))
import * as service from '@/services/searchService'
vi.mock('@/services/searchService', () => ({ search: vi.fn(), getHistory: vi.fn(), clearHistory: vi.fn() }))
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
vi.mocked(search).mockReset().mockResolvedValue({ results: [], total: 0, mode: 'hybrid' })
vi.mocked(service.getHistory).mockReset().mockResolvedValue({ queries: ['saved'] })
vi.mocked(service.clearHistory).mockReset().mockResolvedValue({ queries: [] })
vi.mocked(service.search).mockReset().mockResolvedValue({ results: [], total: 0, mode: 'hybrid' })
})
it('persists real queries across store recreation, reorders duplicates and clears history', async () => {
it('loads application history after recreation and clears through the backend', async () => {
await useSearchStore().loadHistory()
setActivePinia(createPinia())
const store = useSearchStore()
await store.loadHistory()
expect(store.recentQueries).toEqual(['saved'])
await store.clearHistory()
expect(service.clearHistory).toHaveBeenCalledOnce()
expect(store.recentQueries).toEqual([])
await store.doSearch({ query: ' first ' })
await store.doSearch({ query: 'second' })
await store.doSearch({ query: 'first' })
setActivePinia(createPinia())
const restored = useSearchStore()
expect(restored.recentQueries).toEqual(['first', 'second'])
restored.clearHistory()
setActivePinia(createPinia())
expect(useSearchStore().recentQueries).toEqual([])
})
it('ignores corrupt storage and does not let a stale request overwrite the latest search', async () => {
localStorage.setItem('notes-agent.search-history.v1', '{bad')
let finish!: (value: Awaited<ReturnType<typeof search>>) => void
vi.mocked(search).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
it('retains history and reports a failed delete', async () => {
const store = useSearchStore()
await store.loadHistory()
vi.mocked(service.clearHistory).mockRejectedValue(new Error('offline'))
await store.clearHistory()
expect(store.recentQueries).toEqual(['saved'])
expect(store.historyError).toBeTruthy()
})
it('ignores stale search responses and reloads server history', async () => {
let finish!: (value: Awaited<ReturnType<typeof service.search>>) => void
vi.mocked(service.search).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
const store = useSearchStore()
const first = store.doSearch({ query: 'old' })
await store.doSearch({ query: 'new' })
@@ -36,5 +37,5 @@ it('ignores corrupt storage and does not let a stale request overwrite the lates
await first
expect(store.total).toBe(0)
expect(store.query).toBe('new')
expect(store.recentQueries).toEqual(['new', 'old'])
expect(store.recentQueries).toEqual(['saved'])
})
+20 -16
View File
@@ -10,14 +10,6 @@ const VECTOR_ERROR_CODES = new Set([
'MODEL_NOT_FOUND', 'MODEL_CAPABILITY_MISMATCH', 'PROVIDER_UNAVAILABLE',
])
const HISTORY_KEY = 'notes-agent.search-history.v1'
function readHistory(): string[] {
try {
const value: unknown = JSON.parse(localStorage.getItem(HISTORY_KEY) ?? '[]')
return Array.isArray(value) ? [...new Set(value.filter((item): item is string => typeof item === 'string').map(item => item.trim()).filter(Boolean))].slice(0, 10) : []
} catch { return [] }
}
export const useSearchStore = defineStore('search', () => {
const query = ref('')
const mode = ref<'fts' | 'vector' | 'hybrid'>('hybrid')
@@ -25,14 +17,27 @@ export const useSearchStore = defineStore('search', () => {
const total = ref(0)
const isSearching = ref(false)
const selectedIndex = ref(0)
const recentQueries = ref<string[]>(readHistory())
const recentQueries = ref<string[]>([])
const historyError = ref('')
let searchVersion = 0
function persistHistory() {
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(recentQueries.value)); historyError.value = '' }
catch { historyError.value = '浏览器无法保存搜索记录,本次记录仅保留到页面关闭。' }
let historyVersion = 0
async function loadHistory() {
const version = ++historyVersion
try {
const response = await searchService.getHistory()
if (version !== historyVersion) return
recentQueries.value = response.queries
historyError.value = ''
} catch { if (version === historyVersion) historyError.value = '无法读取应用搜索记录,请检查后端连接。' }
}
async function clearHistory() {
const version = ++historyVersion
try {
await searchService.clearHistory()
if (version !== historyVersion) return
recentQueries.value = []; historyError.value = ''
} catch { if (version === historyVersion) historyError.value = '清空搜索记录失败,请重试。' }
}
function clearHistory() { recentQueries.value = []; persistHistory() }
const error = ref<string | null>(null)
const vectorUnavailable = ref(false)
@@ -40,8 +45,6 @@ export const useSearchStore = defineStore('search', () => {
request = { ...request, query: request.query.trim() }
if (!request.query) return
const version = ++searchVersion
recentQueries.value = [request.query, ...recentQueries.value.filter(item => item !== request.query)].slice(0, 10)
persistHistory()
query.value = request.query
mode.value = request.mode || 'hybrid'
isSearching.value = true
@@ -78,7 +81,7 @@ export const useSearchStore = defineStore('search', () => {
total.value = 0
}
} finally {
if (version === searchVersion) isSearching.value = false
if (version === searchVersion) { isSearching.value = false; await loadHistory() }
}
}
@@ -115,6 +118,7 @@ export const useSearchStore = defineStore('search', () => {
recentQueries,
historyError,
clearHistory,
loadHistory,
error,
vectorUnavailable,
doSearch,