feat(M4): FR-R2/R6/R7 搜索+标签筛选——search hay 加 tags+links 元数据(R2); TagFilter 加折叠按钮(R6); tagPaths IPC+FileTree AND 过滤选中标签的文档(R7); store 加 tagFilter; search-tag E2E 2 项

This commit is contained in:
hunter-dev
2026-08-26 14:53:00 +08:00
parent bfb1f4b014
commit 01ea477d18
7 changed files with 150 additions and 13 deletions
+1
View File
@@ -49,6 +49,7 @@ export function registerIpc(vault: VaultService) {
fs2().writeDoc({ path: p.path, meta: p.meta, body: p.body }));
ipcMain.handle(IPC.search, async (_e, p: { query: string }) => vault.searchService?.search(p.query) ?? []);
ipcMain.handle(IPC.tagStats, async () => vault.searchService?.tagStats() ?? []);
ipcMain.handle(IPC.tagPaths, async () => vault.searchService?.tagPaths() ?? {});
ipcMain.handle(IPC.graph, async () => {
const fs2 = vault.fileService;
if (!fs2) return { nodes: [], edges: [] };
@@ -12,8 +12,8 @@ export class SearchService {
constructor(private root: string, private fileService: { getRoot: () => string }) {}
// 遍历库读取所有文档,建立简易索引(M4:内存全量,大库后续优化)
private async readAll(): Promise<{ path: string; title: string; body: string; tags: string[] }[]> {
const out: { path: string; title: string; body: string; tags: string[] }[] = [];
private async readAll(): Promise<{ path: string; title: string; body: string; tags: string[]; links: string[] }[]> {
const out: { path: string; title: string; body: string; tags: string[]; links: string[] }[] = [];
const walk = async (dirAbs: string, dirRel: string) => {
const entries = await fs.readdir(dirAbs, { withFileTypes: true });
for (const e of entries) {
@@ -25,7 +25,7 @@ export class SearchService {
const raw = await fs.readFile(abs, 'utf8');
const { meta, body } = splitFrontmatter(raw);
const relPosix = rel.split(nodePath.sep).join('/');
out.push({ path: relPosix, title: meta.title ?? e.name.slice(0, -MD_EXT.length), body, tags: meta.tags ?? [] });
out.push({ path: relPosix, title: meta.title ?? e.name.slice(0, -MD_EXT.length), body, tags: meta.tags ?? [], links: meta.links ?? [] });
}
}
};
@@ -39,7 +39,7 @@ export class SearchService {
const all = await this.readAll();
const hits: SearchHit[] = [];
for (const d of all) {
const hay = (d.title + '\n' + d.body).toLowerCase();
const hay = (d.title + '\n' + d.body + '\n' + d.tags.join(' ') + '\n' + (d.links ?? []).join(' ')).toLowerCase();
const idx = hay.indexOf(q);
if (idx !== -1) {
// 摘要:命中位置附近,超长截尾保留 ~120 字符
@@ -58,4 +58,14 @@ export class SearchService {
for (const d of all) for (const t of d.tags) m.set(t, (m.get(t) ?? 0) + 1);
return [...m.entries()].map(([tag, count]) => ({ tag, count })).sort((a, b) => a.tag.localeCompare(b.tag));
}
// FR-R7:返回标签→文档路径列表映射,供 FileTree AND 过滤
async tagPaths(): Promise<Record<string, string[]>> {
const all = await this.readAll();
const m = new Map<string, string[]>();
for (const d of all) for (const t of d.tags) { const arr = m.get(t) ?? []; arr.push(d.path); m.set(t, arr); }
const out: Record<string, string[]> = {};
for (const [tag, paths] of m) out[tag] = paths;
return out;
}
}
@@ -1,13 +1,13 @@
import { useStore } from '../../store';
import type { TreeNode } from '@hunter/shared';
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { invoke } from '../../ipc-client';
import { IPC } from '@hunter/shared';
import { useDialog } from '../dialog/dialog-store';
import { useTabs } from '../tabs/tab-store';
export function FileTree() {
const { tree, loadTree, treeLoading, openDoc, currentPath, syncCurrentPath } = useStore();
const { tree, loadTree, treeLoading, openDoc, currentPath, syncCurrentPath, tagFilter } = useStore();
const { showInput, showConfirm } = useDialog();
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [renaming, setRenaming] = useState<string | null>(null);
@@ -17,7 +17,34 @@ export function FileTree() {
const [menu, setMenu] = useState<{ path: string; x: number; y: number } | null>(null);
const toggle = (p: string) => { const n = new Set(collapsed); if (n.has(p)) n.delete(p); else n.add(p); setCollapsed(n); };
const byParent = useMemo(() => { const m = new Map<string, TreeNode[]>(); for (const n of tree) { const a = m.get(n.parent) ?? []; a.push(n); m.set(n.parent, a); } return m; }, [tree]);
// FR-R7:标签 AND 过滤——选中标签时仅保留命中全部标签的 .md 文件(及其祖先目录)
const [tagPaths, setTagPaths] = useState<Set<string> | null>(null);
useEffect(() => {
if (tagFilter.length === 0) { setTagPaths(null); return; }
invoke(IPC.tagPaths, undefined as never).then((all: Record<string, string[]>) => {
const sets = tagFilter.map(t => new Set(all[t] ?? []));
if (sets.length === 0) { setTagPaths(null); return; }
const inter = new Set<string>();
for (const p of sets[0]) { if (sets.every(s => s.has(p))) inter.add(p); }
setTagPaths(inter);
}).catch(() => setTagPaths(null));
}, [tagFilter]);
const filteredTree = useMemo(() => {
if (!tagPaths || tagFilter.length === 0) return tree;
const keep = new Set<string>();
for (const n of tree) {
if (n.type === 'doc' && tagPaths.has(n.path)) {
let p: string | undefined = n.parent;
while (p !== undefined && p !== '' && !keep.has(p)) { keep.add(p); const parent = tree.find(x => x.path === p); p = parent?.parent; }
keep.add(n.path);
}
if (n.type === 'folder' && keep.has(n.path)) keep.add(n.path);
}
return tree.filter(n => keep.has(n.path) || tagPaths.has(n.path));
}, [tree, tagPaths, tagFilter]);
const byParent = useMemo(() => { const m = new Map<string, TreeNode[]>(); for (const n of filteredTree) { const a = m.get(n.parent) ?? []; a.push(n); m.set(n.parent, a); } return m; }, [filteredTree]);
const doCreate = async (basePath: string, isFolder: boolean) => {
const name = await showInput(isFolder ? '新建文件夹' : '新建文档', isFolder ? '文件夹名称' : '文档标题');
@@ -7,15 +7,26 @@ export function TagFilter() {
const { tree } = useStore();
const [tags, setTags] = useState<TagStat[]>([]);
const [selected, setSelected] = useState<string[]>([]);
const [collapsed, setCollapsed] = useState(false);
useEffect(() => { invoke(IPC.tagStats, undefined as never).then(setTags).catch(() => {}); }, [tree.length]);
// FR-R7:选中标签同步到 store 供 FileTree AND 过滤
useEffect(() => { useStore.setState({ tagFilter: selected }); }, [selected]);
if (tags.length === 0) return null;
const toggle = (t: string) => setSelected(s => s.includes(t) ? s.filter(x => x !== t) : [...s, t]);
const label = '标签(' + tags.length + ')' + (selected.length > 0 ? ' · 已选' + selected.length : '');
return (
<div style={{ padding: '6px 8px', borderTop: '1px solid var(--control-border)', maxHeight: 120, overflow: 'auto' }}>
<div style={{ opacity: 0.5, fontSize: 11, marginBottom: 4 }}>标签({tags.length})</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>{tags.map(t => (
<span key={t.tag} onClick={() => toggle(t.tag)} style={{ cursor: 'pointer', padding: '1px 7px', borderRadius: 10, fontSize: 11, background: selected.includes(t.tag) ? 'var(--accent)' : 'var(--control-border)', color: selected.includes(t.tag) ? '#fff' : 'var(--text-secondary)' }}>{t.tag} {t.count}</span>
))}</div>
<div style={{ padding: '6px 8px', borderTop: '1px solid var(--control-border)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<div style={{ opacity: 0.5, fontSize: 11, cursor: 'pointer', userSelect: 'none' }} onClick={() => setCollapsed(c => !c)}>{label}</div>
<button onClick={() => setCollapsed(c => !c)} style={{ background: 'none', border: 'none', color: 'var(--text-tertiary)', cursor: 'pointer', fontSize: 10, padding: 0 }}>{collapsed ? '▾' : '▴'}</button>
</div>
{!collapsed && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 100, overflow: 'auto' }}>
{tags.map(t => (
<span key={t.tag} onClick={() => toggle(t.tag)} style={{ cursor: 'pointer', padding: '1px 7px', borderRadius: 10, fontSize: 11, background: selected.includes(t.tag) ? 'var(--accent)' : 'var(--control-border)', color: selected.includes(t.tag) ? '#fff' : 'var(--text-secondary)' }}>{t.tag} {t.count}</span>
))}
</div>
)}
</div>
);
}
}
+2
View File
@@ -15,6 +15,7 @@ interface EditorState {
body: string;
meta: DocMeta;
saveState: SaveState;
tagFilter: string[]; // FR-R7:选中标签列表,FileTree 据此 AND 过滤
// actions
loadVault: () => Promise<void>;
loadTree: () => Promise<void>;
@@ -43,6 +44,7 @@ export const useStore = create<EditorState>((set, get) => ({
body: '',
meta: {},
saveState: 'saved',
tagFilter: [],
loadVault: async () => {
const vault = await invoke(IPC.vaultList, undefined as never);