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:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user