feat(frontend): 完成知识库搜索页面

This commit is contained in:
2026-08-29 22:48:58 +08:00
parent 610fc77b0f
commit 02e19be8bb
4 changed files with 222 additions and 0 deletions
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { useSearchStore } from '@/stores/search'
const searchStore = useSearchStore()
const modes = [
{ value: 'hybrid', label: '混合检索' },
{ value: 'fts', label: '全文检索' },
{ value: 'vector', label: '向量检索' },
] as const
</script>
<template>
<div class="sidebar-panel">
<p class="subtle">检索模式</p>
<div class="sidebar-list mode-list">
<button v-for="item in modes" :key="item.value" class="sidebar-list-item"
:class="{ active: searchStore.mode === item.value }" @click="searchStore.setMode(item.value)">
{{ item.label }}
</button>
</div>
<p class="subtle section-title">最近搜索</p>
<div class="sidebar-list">
<button v-for="query in searchStore.recentQueries" :key="query" class="sidebar-list-item recent"
@click="searchStore.doSearch({ query, mode: searchStore.mode })">{{ query }}</button>
</div>
</div>
</template>
<style scoped>
.mode-list { margin-top: var(--space-sm); }
.section-title { margin-top: var(--space-xl); }
.sidebar-list-item { width: 100%; text-align: left; }
.recent { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-secondary); }
</style>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import type { SearchResult } from '@/contracts'
import { useEditorStore } from '@/stores/editor'
import { useSearchStore } from '@/stores/search'
import { useWorkspaceStore } from '@/stores/workspace'
const searchStore = useSearchStore()
const workspaceStore = useWorkspaceStore()
const editorStore = useEditorStore()
const router = useRouter()
const folder = ref('')
const tag = ref('')
function submitSearch() {
void searchStore.doSearch({
query: searchStore.query,
mode: searchStore.mode,
folder: folder.value || undefined,
tag: tag.value || undefined,
})
}
async function openResult(result: SearchResult) {
workspaceStore.openFile(result.file_path)
await editorStore.loadFile(result.file_path)
editorStore.highlightBlock(result.block_id)
await router.push('/workspace')
}
</script>
<template>
<section class="feature-page search-page">
<header class="feature-header">
<div><h1>搜索知识库</h1><p>在当前 Vault 中进行全文向量或混合检索</p></div>
</header>
<form class="search-form panel" @submit.prevent="submitSearch">
<input v-model="searchStore.query" class="input search-input" placeholder="搜索笔记内容、标题或标签" autofocus />
<button class="button-primary" :disabled="!searchStore.query.trim() || searchStore.isSearching">
{{ searchStore.isSearching ? '搜索中…' : '搜索' }}
</button>
<div class="form-grid advanced">
<div class="field"><label>文件夹范围</label><input v-model="folder" class="input" placeholder="例如 /数据结构" /></div>
<div class="field"><label>标签</label><input v-model="tag" class="input" placeholder="例如 算法" /></div>
</div>
</form>
<div v-if="searchStore.error" class="error-banner">{{ searchStore.error }}</div>
<div v-if="searchStore.vectorUnavailable" class="notice-banner">向量索引不可用已保留全文检索能力</div>
<div v-if="searchStore.results.length" class="results-header">
<span>找到 {{ searchStore.total }} 条结果</span><span class="badge info">{{ searchStore.mode }}</span>
</div>
<div v-if="searchStore.results.length" class="result-list">
<article v-for="result in searchStore.results" :key="`${result.note_id}:${result.block_id}`"
class="item-card result-card" @click="openResult(result)">
<div class="result-title"><strong>{{ result.note_title }}</strong><span class="badge">{{ result.match_type }}</span></div>
<p class="subtle">{{ result.file_path }} · {{ result.heading_path }}</p>
<p class="snippet">{{ result.snippet }}</p>
<div class="result-meta"><span>相关度 {{ Math.round(result.score * 100) }}%</span><span>点击定位原文 </span></div>
</article>
</div>
<div v-else-if="!searchStore.isSearching" class="empty-state">
<div><strong>{{ searchStore.query ? '没有找到匹配内容' : '从你的知识库开始搜索' }}</strong><p>可切换检索模式或缩小文件夹标签范围</p></div>
</div>
</section>
</template>
<style scoped>
.search-form { display: grid; grid-template-columns: 1fr auto; gap: var(--space-md); margin-bottom: var(--space-lg); }
.search-input { height: 44px; font-size: var(--font-size-lg); }
.advanced { grid-column: 1 / -1; }
.results-header, .result-title, .result-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
.results-header { margin: var(--space-xl) 0 var(--space-md); color: var(--color-text-secondary); }
.result-list { display: grid; gap: var(--space-md); }
.result-card { cursor: pointer; }
.snippet { margin: var(--space-md) 0; line-height: var(--line-height-relaxed); }
.result-meta { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
@media (max-width: 700px) { .search-form { grid-template-columns: 1fr; } .advanced { grid-column: auto; } }
</style>