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,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);