From dc8d844a1f875e7245209df17d1b543f6e8295ca Mon Sep 17 00:00:00 2001 From: hunter-dev Date: Wed, 9 Sep 2026 20:11:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(info):=20=E4=BF=A1=E6=81=AF=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=20UI=20=E9=87=8D=E5=81=9A=20+=20=E5=8F=8C=E5=90=91?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=85=B3=E8=81=94=20+=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=9D=97=E5=86=85=E5=85=A8=E9=80=89=E5=A4=8D=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 信息面板 UI 重做:设计 token 统一、lucide 图标、分区小节、星标/标签/关联 chip 化 2. 关联文档升级:新增 doc:link-batch IPC,A↔B 双向写入 links;面板支持快速双向关联 + 批量勾选关联/解除 3. 代码块内全选复制:Ctrl+A 在代码块内仅全选该代码块(TyporaKeys selectInsideCodeBlock),Ctrl+C 复制纯代码;代码块内 Ctrl+V 按纯文本粘贴 4. 修复 React error #62:btn 改为函数后遗漏 style={btn} 裸用,导致 render 期间更新 --- apps/desktop/src/main/ipc/handlers.ts | 3 + .../desktop/src/main/services/file-service.ts | 19 +++ .../features/doc-info/DocInfoPanel.tsx | 153 +++++++++++++----- .../renderer/features/editor/TiptapEditor.tsx | 22 ++- .../renderer/features/editor/TyporaKeys.ts | 18 +++ apps/desktop/src/renderer/store.ts | 24 +-- apps/desktop/tests/e2e/button-all.spec.ts | 8 +- .../tests/e2e/codeblock-selectall.spec.ts | 68 ++++++++ .../tests/e2e/info-association.spec.ts | 94 +++++++++++ packages/shared/src/types/ipc.ts | 2 + 10 files changed, 353 insertions(+), 58 deletions(-) create mode 100644 apps/desktop/tests/e2e/codeblock-selectall.spec.ts create mode 100644 apps/desktop/tests/e2e/info-association.spec.ts diff --git a/apps/desktop/src/main/ipc/handlers.ts b/apps/desktop/src/main/ipc/handlers.ts index 04a31af..2652a28 100644 --- a/apps/desktop/src/main/ipc/handlers.ts +++ b/apps/desktop/src/main/ipc/handlers.ts @@ -63,6 +63,9 @@ export function registerIpc(vault: VaultService) { ipcMain.handle(IPC.docRead, async (_e, p: { path: string }) => fs2().readDoc(p.path)); ipcMain.handle(IPC.docWrite, async (_e, p: { path: string; meta: any; body: string }) => fs2().writeDoc({ path: p.path, meta: p.meta, body: p.body })); + // 双向批量关联:当前文档与多个目标文档互相写入 links + ipcMain.handle(IPC.docLinkBatch, async (_e, p: { path: string; targets: string[]; remove: boolean }) => + fs2().linkBatch(p.path, p.targets, p.remove)); 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() ?? {}); diff --git a/apps/desktop/src/main/services/file-service.ts b/apps/desktop/src/main/services/file-service.ts index 76099de..3d215fd 100644 --- a/apps/desktop/src/main/services/file-service.ts +++ b/apps/desktop/src/main/services/file-service.ts @@ -154,4 +154,23 @@ export class FileService { const content = joinFrontmatter(payload.meta, payload.body); return withFileLock(abs, () => writeFileAtomic(abs, content)); } + + // 双向批量关联:当前文档与每个目标文档互相写入 links(关联/解除),返回当前文档最新 links + async linkBatch(path: string, targets: string[], remove: boolean): Promise<{ links: string[] }> { + const apply = (cur: string[], t: string, rem: boolean) => rem ? cur.filter(x => x !== t) : (cur.includes(t) ? cur : [...cur, t]); + const cur = await this.readDoc(path); + let links = [...(cur.meta.links ?? [])]; + for (const t of targets) links = apply(links, t, remove); + await this.writeDoc({ path, meta: { ...cur.meta, links }, body: cur.body }); + // 反向:把当前文档路径写入每个目标文档的 links(双向关联的核心) + for (const t of targets) { + try { + const td = await this.readDoc(t); + let tLinks = [...(td.meta.links ?? [])]; + tLinks = apply(tLinks, path, remove); + await this.writeDoc({ path: t, meta: { ...td.meta, links: tLinks }, body: td.body }); + } catch { /* 目标文档不存在或不可读则跳过 */ } + } + return { links }; + } } diff --git a/apps/desktop/src/renderer/features/doc-info/DocInfoPanel.tsx b/apps/desktop/src/renderer/features/doc-info/DocInfoPanel.tsx index 57a4c93..ebeb5f6 100644 --- a/apps/desktop/src/renderer/features/doc-info/DocInfoPanel.tsx +++ b/apps/desktop/src/renderer/features/doc-info/DocInfoPanel.tsx @@ -1,9 +1,8 @@ import { useState } from 'react'; import { useStore } from '../../store'; -import { invoke } from '../../ipc-client'; -import { IPC } from '@hunter/shared'; -import type { TreeNode } from '@hunter/shared'; +import { Star, Tag, Link2, FileText, Clock, Plus, X, Check, ListChecks, ArrowLeftRight, Folder, ChevronDown } from 'lucide-react'; +// 信息面板:路径/标签/关联文档(双向+批量)/时间。样式统一走设计 token export function DocInfoPanel() { const currentPath = useStore(s => s.currentPath); const title = useStore(s => s.title); @@ -12,76 +11,142 @@ export function DocInfoPanel() { const toggleStar = useStore(s => s.toggleStar); const addTag = useStore(s => s.addTag); const removeTag = useStore(s => s.removeTag); - const addLink = useStore(s => s.addLink); - const removeLink = useStore(s => s.removeLink); + const linkBatch = useStore(s => s.linkBatch); + const openDoc = useStore(s => s.openDoc); const [tagInput, setTagInput] = useState(''); - const [linkInput, setLinkInput] = useState(''); + const [quickSel, setQuickSel] = useState(''); + const [batchOpen, setBatchOpen] = useState(false); + const [checked, setChecked] = useState>(new Set()); if (!currentPath) { return
未打开文档
; } - // 库内 .md 文件供关联链接选择 + // 库内 .md 文件供关联选择(排除当前文档与已关联项) const docs = tree.filter(n => n.type === 'doc'); const mtime = tree.find(n => n.path === currentPath)?.mtime; + const links = meta.links ?? []; + const linkNames = new Map(); + for (const d of docs) linkNames.set(d.path, d.name); + const quickCandidates = docs.filter(d => d.path !== currentPath && !links.includes(d.path)); + + const toggleCheck = (p: string) => { + const n = new Set(checked); if (n.has(p)) n.delete(p); else n.add(p); setChecked(n); + }; return ( -
-
- {title} -
-
-
{currentPath}
+ {/* 路径 */} +
} title="路径"> +
{currentPath}
-
-
+ {/* 标签 */} +
} title="标签"> +
{(meta.tags ?? []).map(t => ( - - {t} removeTag(t)}>× + + {t} + ))} - {(meta.tags ?? []).length === 0 && 无标签} + {(meta.tags ?? []).length === 0 && 暂无标签}
-
- setTagInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && tagInput.trim()) { addTag(tagInput.trim()); setTagInput(''); } }} placeholder="添加标签" style={inp} /> - +
+ setTagInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && tagInput.trim()) { addTag(tagInput.trim()); setTagInput(''); } }} placeholder="添加标签,回车确认" style={inp} /> +
-
-
- {(meta.links ?? []).map(l => ( -
- {l} - removeLink(l)}>× + {/* 关联文档:双向 + 批量 */} +
} title={"关联文档" + (links.length ? '(' + links.length + ')' : '')}> + {links.length === 0 ? ( +
暂无关联,可在下方快速关联或批量关联
+ ) : ( +
+ {links.map(l => ( +
+ {linkNames.get(l) ?? l} + + +
+ ))} +
+ )} + + {/* 快速双向关联 */} +
+ + + +
+ + {/* 批量关联 */} +
+ + {batchOpen && ( +
+
+ {quickCandidates.length === 0 ? ( +
没有可关联的文档
+ ) : quickCandidates.map(d => ( + + ))} +
- ))} - {(meta.links ?? []).length === 0 && 无关联} + )} + {batchOpen && ( +
+ + +
+ )}
- -
-
-
{mtime ? new Date(mtime).toLocaleString('zh-CN') : '—'}
-
创建时间:{meta.createdAt ? new Date(meta.createdAt).toLocaleString('zh-CN') : '—'}
+ {/* 时间 */} +
} title="时间"> +
+
修改:{mtime ? new Date(mtime).toLocaleString('zh-CN') : '—'}
+
创建:{meta.createdAt ? new Date(meta.createdAt).toLocaleString('zh-CN') : '—'}
+
); } -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return (
{title}
{children}
); +function Section({ icon, title, children }: { icon: React.ReactNode; title: string; children: React.ReactNode }) { + return ( +
+
{icon}{title}
+ {children} +
+ ); } -const inp: React.CSSProperties = { background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', color: 'var(--text-primary)', borderRadius: 4, padding: '3px 8px', fontSize: 13, flex: 1 }; -const btn: React.CSSProperties = { minWidth: 28, height: 26, border: '1px solid var(--control-border)', background: 'transparent', color: 'var(--text-primary)', cursor: 'pointer', borderRadius: 4 }; \ No newline at end of file +const chip: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 4, background: 'var(--control-border)', color: 'var(--text-secondary)', borderRadius: 999, padding: '2px 8px', fontSize: 12 }; +const chipX: React.CSSProperties = { border: 'none', background: 'transparent', color: 'var(--text-tertiary)', cursor: 'pointer', display: 'inline-flex', padding: 0, borderRadius: 4 }; +const inp: React.CSSProperties = { background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', color: 'var(--text-primary)', borderRadius: 6, padding: '5px 8px', fontSize: 13, flex: 1, outline: 'none' }; +const btn = (disabled?: boolean): React.CSSProperties => ({ minWidth: 30, height: 28, border: '1px solid var(--control-border)', background: 'transparent', color: 'var(--text-secondary)', cursor: disabled ? 'not-allowed' : 'pointer', borderRadius: 6, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 4, padding: '0 8px', fontSize: 12, opacity: disabled ? 0.4 : 1 }); +const btnPrimary = (disabled?: boolean): React.CSSProperties => ({ minWidth: 52, height: 28, border: 'none', background: 'var(--accent)', color: '#fff', cursor: disabled ? 'not-allowed' : 'pointer', borderRadius: 6, padding: '0 10px', fontSize: 12, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', opacity: disabled ? 0.4 : 1 }); +const iconBtn: React.CSSProperties = { border: 'none', background: 'transparent', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 4, borderRadius: 6 }; +const iconSmall: React.CSSProperties = { border: 'none', background: 'transparent', color: 'var(--text-tertiary)', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 2, borderRadius: 4 }; \ No newline at end of file diff --git a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx index d638116..b214aef 100644 --- a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx +++ b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx @@ -94,9 +94,29 @@ export function TiptapEditor() { return true; }, }, - // FR-E6:剪贴板粘贴图片 -> 入库 .attachments -> 插入相对路径 + // 剪贴板粘贴:代码块内粘贴按纯文本插入(避免 markdown 转换/围栏),其余走图片入库逻辑 handlePaste: (_view, event) => { const ev = event as ClipboardEvent; + // 若选区位于代码块内:直接插入纯文本,不触发 markdown 转换 + const { selection, doc } = _view.state; + if (!selection.empty) { + let inCode = false; + doc.descendants((node, pos) => { + if (node.type.name === 'codeBlock') { + const start = pos + 1, end = pos + node.nodeSize - 1; + if (selection.from >= start && selection.to <= end) { inCode = true; return false; } + } + return true; + }); + if (inCode) { + const text = ev.clipboardData?.getData('text/plain') ?? ''; + if (text) { + ev.preventDefault(); + _view.dispatch(_view.state.tr.insertText(text, selection.from, selection.to)); + return true; + } + } + } const items = ev.clipboardData?.items; if (!items) return false; for (let i = 0; i < items.length; i++) { diff --git a/apps/desktop/src/renderer/features/editor/TyporaKeys.ts b/apps/desktop/src/renderer/features/editor/TyporaKeys.ts index baf5a68..5170767 100644 --- a/apps/desktop/src/renderer/features/editor/TyporaKeys.ts +++ b/apps/desktop/src/renderer/features/editor/TyporaKeys.ts @@ -41,6 +41,8 @@ export const TyporaKeys = Extension.create({ 'Mod-Shift-d': () => deleteWord(ed()), 'Mod-Home': () => docJump(ed(), 'start'), 'Mod-End': () => docJump(ed(), 'end'), + // 代码块内 Ctrl+A 全选:仅选中该代码块内容(而非整篇文档),保证 Ctrl+C 复制纯代码 + 'Mod-a': () => selectInsideCodeBlock(ed()), }; }, }); @@ -138,3 +140,19 @@ function docJump(editor: Editor, where: 'start' | 'end'): boolean { if (where === 'start') return editor.commands.setTextSelection(0); return editor.commands.setTextSelection(editor.state.doc.content.size); } + +// Ctrl+A:若光标/选区位于代码块内,则仅全选该代码块内容(便于 Ctrl+C 复制纯代码);否则退回默认全选整篇 +function selectInsideCodeBlock(editor: Editor): boolean { + const { state } = editor; + const $pos = state.doc.resolve(state.selection.from); + for (let d = $pos.depth; d > 0; d--) { + if ($pos.node(d).type.name === 'codeBlock') { + const start = $pos.start(d); + const end = $pos.end(d); + if (end > start) return editor.commands.setTextSelection({ from: start, to: end }); + return true; + } + } + // 不在代码块内:走默认全选 + return editor.commands.selectAll(); +} diff --git a/apps/desktop/src/renderer/store.ts b/apps/desktop/src/renderer/store.ts index f51b7ba..cae3cda 100644 --- a/apps/desktop/src/renderer/store.ts +++ b/apps/desktop/src/renderer/store.ts @@ -28,6 +28,7 @@ interface EditorState { toggleStar: () => void; addLink: (link: string) => void; removeLink: (link: string) => void; + linkBatch: (targets: string[], remove: boolean) => Promise; saveNow: () => Promise; reloadDoc: () => Promise; // FR-S6:重新加载——检测 mtime 变化,有未保存改动时返回 false 需确认 resetEditor: () => void; // FR-V6: 切库时重置编辑区 @@ -90,15 +91,20 @@ export const useStore = create((set, get) => ({ set({ meta: { ...get().meta, star: !get().meta.star } }); get().saveNow(); }, - addLink: (link: string) => { - const links = [...(get().meta.links ?? []), link].filter((l, i, a) => a.indexOf(l) === i); - set({ meta: { ...get().meta, links } }); - get().saveNow(); - }, - removeLink: (link: string) => { - const links = (get().meta.links ?? []).filter(l => l !== link); - set({ meta: { ...get().meta, links } }); - get().saveNow(); + addLink: (link: string) => { void get().linkBatch([link], false); }, + removeLink: (link: string) => { void get().linkBatch([link], true); }, + // 双向批量关联:先冲刷编辑器内容落盘,再由主进程把当前文档与每个目标文档互相写入 links + linkBatch: async (targets: string[], remove: boolean) => { + const { currentPath } = get(); + if (!currentPath || targets.length === 0) return; + window.dispatchEvent(new CustomEvent('dsh-flush-body')); + await get().saveNow(); + try { + const res = await invoke(IPC.docLinkBatch, { path: currentPath, targets, remove }); + set({ meta: { ...get().meta, links: res.links } }); + } catch (e) { + console.error('linkBatch failed', e); + } }, setBody: (body: string) => { set({ body, saveState: 'saving' }); diff --git a/apps/desktop/tests/e2e/button-all.spec.ts b/apps/desktop/tests/e2e/button-all.spec.ts index b0699a6..ea1fb45 100644 --- a/apps/desktop/tests/e2e/button-all.spec.ts +++ b/apps/desktop/tests/e2e/button-all.spec.ts @@ -200,12 +200,12 @@ test('DocInfoPanel: 星标+标签+关联可用', async () => { await a.page.getByPlaceholder('添加标签').press('Enter'); await a.page.waitForTimeout(300); await expect(a.page.getByText('testtag')).toBeVisible({ timeout: 3000 }); - // 关联文档 + // 关联文档(快速双向关联:下拉为文档名,按钮 title 建立双向关联) const select = a.page.locator('select').first(); - await select.selectOption('DocB.md'); - await a.page.getByRole('button', { name: '关联' }).click(); + await select.selectOption('DocB'); + await a.page.getByRole('button', { name: '关联', exact: true }).click(); await a.page.waitForTimeout(300); - await expect(a.page.getByText('DocB.md')).toBeVisible({ timeout: 3000 }); + await expect(a.page.getByTitle('DocB.md')).toBeVisible({ timeout: 3000 }); await a.app.close(); }); diff --git a/apps/desktop/tests/e2e/codeblock-selectall.spec.ts b/apps/desktop/tests/e2e/codeblock-selectall.spec.ts new file mode 100644 index 0000000..1c9a253 --- /dev/null +++ b/apps/desktop/tests/e2e/codeblock-selectall.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; +import { _electron as electron } from 'playwright'; +import { promises as fs } from 'node:fs'; +import nodePath from 'node:path'; +import os from 'node:os'; + +const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js'); + +async function launch(userData: string) { + const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] }); + const page = await app.firstWindow(); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(800); + const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args }); + const clip = () => app.evaluate(({ clipboard }) => clipboard.readText()); + return { app, page, invoke, clip }; +} + +// 代码块内 Ctrl+A 全选后 Ctrl+C:应只复制代码块内容(纯代码无围栏),即使文档有其他内容 +test('代码块内 Ctrl+A 全选复制纯代码(不受文档其他内容影响)', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-sel1-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + // 文档:代码块上下都有其他内容,确保 Ctrl+A 只选代码块而非整篇 + const body = '# 标题\n\n上方段落\n\n```bash\nsudo apt-get update\nls -la\n```\n\n下方段落'; + await a.invoke('doc:write', { path: 'code.md', meta: {}, body }); + await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'code.md'); + await a.page.waitForTimeout(1500); + const code = a.page.locator('.ProseMirror pre code'); + await code.click(); + await a.page.keyboard.press('Control+a'); + await a.page.waitForTimeout(200); + // 校验选中范围只是代码块内容(可通过 selection anchor 判断,或直接 Ctrl+C 后看剪贴板) + await a.page.keyboard.press('Control+c'); + await a.page.waitForTimeout(300); + const text = await a.clip(); + console.log('SELALL_CLIP', JSON.stringify(text)); + expect(text.trim().replace(/\r\n/g, '\n')).toBe('sudo apt-get update\nls -la'); + await a.app.close(); +}); + +// 代码块内 Ctrl+V 粘贴按纯文本插入(不产生 markdown 转换/围栏) +test('代码块内 Ctrl+V 粘贴纯文本', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-sel2-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + await a.invoke('doc:write', { path: 'code2.md', meta: {}, body: '```js\nlet x = 1;\n```' }); + await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'code2.md'); + await a.page.waitForTimeout(1500); + const code = a.page.locator('.ProseMirror pre code'); + await code.click(); + // 全选代码块,然后粘贴纯文本 + await a.page.keyboard.press('Control+a'); + await a.page.evaluate(() => navigator.clipboard.writeText('# not a heading\n- not a list')); + await a.page.waitForTimeout(200); + await a.page.keyboard.press('Control+v'); + await a.page.waitForTimeout(500); + const inner = await a.page.evaluate(() => (document.querySelector('.ProseMirror pre code') as HTMLElement)?.innerText ?? ''); + console.log('PASTE_INNER', JSON.stringify(inner)); + // 粘贴内容按纯文本插入(不出现标题/列表节点结构变化) + expect(inner).toContain('# not a heading'); + expect(inner).toContain('- not a list'); + await a.app.close(); +}); \ No newline at end of file diff --git a/apps/desktop/tests/e2e/info-association.spec.ts b/apps/desktop/tests/e2e/info-association.spec.ts new file mode 100644 index 0000000..4a16e4e --- /dev/null +++ b/apps/desktop/tests/e2e/info-association.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from '@playwright/test'; +import { _electron as electron } from 'playwright'; +import { promises as fs } from 'node:fs'; +import nodePath from 'node:path'; +import os from 'node:os'; + +const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js'); + +async function launch(userData: string) { + const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] }); + const page = await app.firstWindow(); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(800); + const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args }); + return { app, page, invoke }; +} + +async function setupVault(a: { invoke: any; page: any }, names: string[]) { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-assoc-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + await a.invoke('vault:switch', { root: v }); + for (const n of names) { + await a.invoke('doc:write', { path: n + '.md', meta: { title: n }, body: '# ' + n }); + } + await a.page.evaluate(() => (globalThis as any).__STORE__?.getState?.().loadTree()); + await a.page.waitForTimeout(500); + return { v }; +} + +async function openInfo(a: { page: any }, docName: string) { + await a.page.getByTestId('filetree').getByText(docName, { exact: true }).click(); + await a.page.waitForTimeout(500); + await a.page.getByRole('button', { name: '信息', exact: true }).click(); + await a.page.waitForTimeout(300); +} + +// 双向关联:当前文档关联目标后,目标文档的 links 也包含当前文档 +test('关联文档:快速双向关联互相写入 links', async () => { + const a = await launch(nodePath.join(os.tmpdir(), 'hn-a1-')); + await setupVault(a, ['DocA', 'DocB', 'DocC']); + await openInfo(a, 'DocA'); + const select = a.page.locator('select').first(); + await select.selectOption('DocB'); + await a.page.getByRole('button', { name: '关联', exact: true }).click(); + await a.page.waitForTimeout(500); + await expect(a.page.getByTitle('DocB.md')).toBeVisible({ timeout: 3000 }); + const docb = await a.invoke('doc:read', { path: 'DocB.md' }); + const doca = await a.invoke('doc:read', { path: 'DocA.md' }); + expect(docb.meta.links).toContain('DocA.md'); + expect(doca.meta.links).toContain('DocB.md'); + await a.app.close(); +}); + +// 批量关联:勾选多个文档一次性建立双向关联 +test('关联文档:批量勾选多文档双向关联', async () => { + const a = await launch(nodePath.join(os.tmpdir(), 'hn-a2-')); + await setupVault(a, ['DocA', 'DocB', 'DocC']); + await openInfo(a, 'DocA'); + await a.page.getByRole('button', { name: /批量关联/ }).click(); + await a.page.waitForTimeout(200); + const boxes = a.page.locator('input[type=checkbox]'); + const count = await boxes.count(); + expect(count).toBeGreaterThanOrEqual(2); + await boxes.nth(0).check(); + await boxes.nth(1).check(); + await a.page.getByRole('button', { name: /关联选中/ }).click(); + await a.page.waitForTimeout(600); + const doca = await a.invoke('doc:read', { path: 'DocA.md' }); + const docb = await a.invoke('doc:read', { path: 'DocB.md' }); + const docc = await a.invoke('doc:read', { path: 'DocC.md' }); + expect(doca.meta.links).toContain('DocB.md'); + expect(doca.meta.links).toContain('DocC.md'); + expect(docb.meta.links).toContain('DocA.md'); + expect(docc.meta.links).toContain('DocA.md'); + await a.app.close(); +}); + +// 解除关联为双向:移除后两侧 links 均清除 +test('关联文档:解除关联双向清除', async () => { + const a = await launch(nodePath.join(os.tmpdir(), 'hn-a3-')); + await setupVault(a, ['DocA', 'DocB']); + await a.invoke('doc:link-batch', { path: 'DocA.md', targets: ['DocB.md'], remove: true }); + await a.invoke('doc:link-batch', { path: 'DocA.md', targets: ['DocB.md'], remove: false }); + await openInfo(a, 'DocA'); + await expect(a.page.getByTitle('DocB.md')).toBeVisible({ timeout: 3000 }); + await a.page.getByTitle('解除关联(双向)').click(); + await a.page.waitForTimeout(500); + const doca = await a.invoke('doc:read', { path: 'DocA.md' }); + const docb = await a.invoke('doc:read', { path: 'DocB.md' }); + expect(doca.meta.links ?? []).not.toContain('DocB.md'); + expect(docb.meta.links ?? []).not.toContain('DocA.md'); + await a.app.close(); +}); \ No newline at end of file diff --git a/packages/shared/src/types/ipc.ts b/packages/shared/src/types/ipc.ts index 7534a18..6c0120a 100644 --- a/packages/shared/src/types/ipc.ts +++ b/packages/shared/src/types/ipc.ts @@ -23,6 +23,7 @@ export const IPC = { docRead: 'doc:read', docWrite: 'doc:write', docReveal: 'doc:reveal', + docLinkBatch: 'doc:link-batch', search: 'search', tagStats: 'tag:stats', tagPaths: 'tag:paths', @@ -65,6 +66,7 @@ export interface IpcMap { [IPC.docRead]: { args: { path: string }; result: ReadDocResult }; [IPC.docWrite]: { args: WriteDocPayload; result: void }; [IPC.docReveal]: { args: { path: string }; result: void }; + [IPC.docLinkBatch]: { args: { path: string; targets: string[]; remove: boolean }; result: { links: string[] } }; [IPC.search]: { args: { query: string }; result: SearchHit[] }; [IPC.tagStats]: { args: void; result: TagStat[] }; [IPC.tagPaths]: { args: void; result: Record };