From 55dedcbcf22b86d138479fcc2116a53c2cf96ef6 Mon Sep 17 00:00:00 2001 From: hunter-dev Date: Wed, 26 Aug 2026 15:48:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(M10/M3/M2):=20FR-M4/M6/X1/X2/B6/D1/D3=20?= =?UTF-8?q?=E5=85=83=E6=95=B0=E6=8D=AE/=E5=AF=BC=E5=87=BA/=E5=BC=B9?= =?UTF-8?q?=E5=B1=82/=E8=BF=81=E7=A7=BB=E2=80=94=E2=80=94EditorToolbar=20?= =?UTF-8?q?=E5=8A=A0=E6=98=9F=E6=A0=87=E6=8C=89=E9=92=AE(M4);=20DocMeta=20?= =?UTF-8?q?=E5=8A=A0=20createdAt+frontmatter=20=E8=AF=BB=E5=86=99+DocInfoP?= =?UTF-8?q?anel=20=E6=98=BE=E7=A4=BA+=E5=88=9B=E5=BB=BA=E6=97=B6=E5=86=99?= =?UTF-8?q?=E5=85=A5(M6);=20copyMd=20=E5=A4=B1=E8=B4=A5=20catch+=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=8F=90=E7=A4=BA(X1);=20@media=20print=20=E5=B9=B2?= =?UTF-8?q?=E5=87=80=E6=8E=92=E7=89=88+hljs=E9=85=8D=E8=89=B2(X2);=20Setti?= =?UTF-8?q?ngsPanel/CheatSheet=20Esc=20=E5=85=B3=E9=97=AD(B6);=20migration?= =?UTF-8?q?-service=20=E6=97=A7=E7=89=88=E6=9D=BF=E5=9D=97=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2+init+switch=20=E6=A3=80=E6=B5=8B(D1/D3);=20meta-expor?= =?UTF-8?q?t=20E2E=203=20=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/main/index.ts | 6 -- .../src/main/safe/frontmatter-codec.ts | 2 + .../desktop/src/main/services/file-service.ts | 6 +- .../src/main/services/migration-service.ts | 91 ++++++++++++------- .../src/main/services/vault-service.ts | 19 +++- .../features/doc-info/DocInfoPanel.tsx | 2 +- .../features/editor/EditorToolbar.tsx | 7 ++ .../renderer/features/editor/MoreActions.tsx | 9 +- .../renderer/features/keymap/CheatSheet.tsx | 7 ++ .../features/settings/SettingsPanel.tsx | 7 ++ apps/desktop/src/renderer/styles.css | 24 ++++- apps/desktop/tests/e2e/meta-export.spec.ts | 72 +++++++++++++++ packages/shared/src/types/doc.ts | 1 + 13 files changed, 206 insertions(+), 47 deletions(-) create mode 100644 apps/desktop/tests/e2e/meta-export.spec.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index cc39066..126bcf3 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -4,7 +4,6 @@ import nodePath from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { VaultService } from './services/vault-service.js'; import { registerIpc } from './ipc/handlers.js'; -import { MigrationService } from './services/migration-service.js'; import { IPC } from '@hunter/shared'; const __dirname = nodePath.dirname(fileURLToPath(import.meta.url)); @@ -56,11 +55,6 @@ app.whenReady().then(async () => { return new Response('not found', { status: 404 }); } }); - // 旧版数据迁移检测(FR-D1/D2/D3):幂等,无旧数据则跳过 - const migrator = new MigrationService(app.getPath('userData')); - const oldDataDir = nodePath.join(app.getPath('userData'), 'legacy-data'); - const r = await migrator.migrate(oldDataDir, vault.fileService?.getRoot() ?? '').catch(() => null); - if (r?.migrated) console.log('[MIGRATION] migrated', r.count, 'items'); // FR-A5:GPU 探测——软渲染时自动禁用实时模糊 let gpuStatus = { softwareRendering: false, vendor: '', renderer: '' }; try { diff --git a/apps/desktop/src/main/safe/frontmatter-codec.ts b/apps/desktop/src/main/safe/frontmatter-codec.ts index a54456b..8e57db9 100644 --- a/apps/desktop/src/main/safe/frontmatter-codec.ts +++ b/apps/desktop/src/main/safe/frontmatter-codec.ts @@ -33,6 +33,7 @@ function parseYaml(yaml: string): DocMeta { const v = val.trim(); if (key === 'title') meta.title = unquote(v); else if (key === 'star') meta.star = v === 'true'; + else if (key === 'createdAt') { const n = Number(v); if (!isNaN(n)) meta.createdAt = n; } else if (key === 'tags') meta.tags = parseList(v); else if (key === 'links') meta.links = parseList(v); } @@ -59,6 +60,7 @@ function serializeYaml(meta: DocMeta): string { if (meta.title !== undefined && meta.title !== '') lines.push(`title: ${quoteIfNeeded(meta.title)}`); if (meta.tags && meta.tags.length > 0) lines.push(`tags: [${meta.tags.map(quoteIfNeeded).join(', ')}]`); if (meta.star === true) lines.push('star: true'); + if (meta.createdAt !== undefined) lines.push('createdAt: ' + meta.createdAt); if (meta.links && meta.links.length > 0) lines.push(`links: [${meta.links.map(quoteIfNeeded).join(', ')}]`); return lines.length ? lines.join('\n') + '\n' : ''; } diff --git a/apps/desktop/src/main/services/file-service.ts b/apps/desktop/src/main/services/file-service.ts index 4ea5e10..76099de 100644 --- a/apps/desktop/src/main/services/file-service.ts +++ b/apps/desktop/src/main/services/file-service.ts @@ -49,7 +49,11 @@ export class FileService { const rel = basePath ? joinRel(basePath, finalName) : finalName; const abs = this.abs(rel); if (isFolder) await fs.mkdir(abs, { recursive: true }); - else await writeFileAtomic(abs, ''); + else { + // FR-M6:新建文档时写入 createdAt frontmatter + const content = joinFrontmatter({ createdAt: Date.now() }, ''); + await writeFileAtomic(abs, content); + } return { path: rel }; } diff --git a/apps/desktop/src/main/services/migration-service.ts b/apps/desktop/src/main/services/migration-service.ts index 4538746..e8ad836 100644 --- a/apps/desktop/src/main/services/migration-service.ts +++ b/apps/desktop/src/main/services/migration-service.ts @@ -1,47 +1,68 @@ -// MigrationService — 旧版数据迁移判定与幂等保护(FR-D1/D2/D3) -import { promises as fs } from 'node:fs'; +// MigrationService — 旧版数据迁移(FR-D1/D3) import nodePath from 'node:path'; +import { promises as fs } from 'node:fs'; +import { MD_EXT } from '@hunter/shared'; +import { joinFrontmatter } from '../safe/frontmatter-codec.js'; -export interface MigrationResult { migrated: boolean; count: number; reason?: string; } +export interface MigrationResult { + migrated: boolean; + count: number; + errors: { path: string; reason: string }[]; +} export class MigrationService { - constructor(private userDataDir: string) {} + // FR-D1:检测旧版板块三级结构数据(.hunter/boards/),转换为文件库结构 + async detectAndMigrate(root: string): Promise { + const oldDataDir = nodePath.join(root, '.hunter', 'boards'); + const exists = await fs.access(oldDataDir).then(() => true).catch(() => false); + if (!exists) return { migrated: false, count: 0, errors: [] }; - private flagFile() { return nodePath.join(this.userDataDir, 'migrated.flag'); } - - async isMigrated(): Promise { - try { await fs.access(this.flagFile()); return true; } catch { return false; } - } - - // 判定是否应迁移:旧数据存在 + 未迁移过 + 新库为空(三者同时满足,FR-D2) - async shouldMigrate(oldDataDir: string, newVaultRoot: string): Promise { - if (await this.isMigrated()) return false; - const oldExists = await fs.stat(oldDataDir).then(s => s.isDirectory()).catch(() => false); - if (!oldExists) return false; - // 新库为空(无 .md 文件) - const entries = await fs.readdir(newVaultRoot).catch(() => []); - const hasMd = entries.some(e => e.endsWith('.md')); - return !hasMd; - } - - // 执行迁移(骨架:实际板块→文件夹映射在集成时落地) - async migrate(oldDataDir: string, newVaultRoot: string): Promise { - if (!(await this.shouldMigrate(oldDataDir, newVaultRoot))) { - return { migrated: false, count: 0, reason: '条件不满足(已迁移/无旧数据/新库非空)' }; - } + const errors: { path: string; reason: string }[] = []; + let count = 0; try { - // TODO: 旧板块三级结构 → 文件夹嵌套;旧富文本 → Markdown;附件 → .attachments;标签与关联映射 - // 骨架阶段:仅写迁移标志,count=0 - await this.markMigrated(); - return { migrated: true, count: 0 }; + const boards = await fs.readdir(oldDataDir, { withFileTypes: true }); + for (const board of boards) { + if (!board.isDirectory()) continue; + const boardDir = nodePath.join(oldDataDir, board.name); + try { + const targetDir = nodePath.join(root, board.name); + await fs.mkdir(targetDir, { recursive: true }); + await this.convertDir(boardDir, targetDir); + count++; + } catch (e) { + errors.push({ path: board.name, reason: e instanceof Error ? e.message : String(e) }); + } + } + if (errors.length === 0) { + await fs.rename(oldDataDir, nodePath.join(root, '.hunter', 'boards.migrated')); + } } catch (e) { - // 失败不记标志(下次可重试,FR-D3) - return { migrated: false, count: 0, reason: e instanceof Error ? e.message : String(e) }; + errors.push({ path: oldDataDir, reason: e instanceof Error ? e.message : String(e) }); } + return { migrated: count > 0, count, errors }; } - async markMigrated() { - await fs.mkdir(nodePath.dirname(this.flagFile()), { recursive: true }); - await fs.writeFile(this.flagFile(), new Date().toISOString(), 'utf8'); + private async convertDir(srcAbs: string, destAbs: string): Promise { + const entries = await fs.readdir(srcAbs, { withFileTypes: true }); + for (const e of entries) { + const srcPath = nodePath.join(srcAbs, e.name); + if (e.isDirectory()) { + const destPath = nodePath.join(destAbs, e.name); + await fs.mkdir(destPath, { recursive: true }); + await this.convertDir(srcPath, destPath); + } else if (e.isFile() && e.name.endsWith('.json')) { + const raw = await fs.readFile(srcPath, 'utf8'); + const data = JSON.parse(raw); + const title = data.title ?? e.name.slice(0, -5); + const body = data.content ?? ''; + const meta: Record = { title, createdAt: data.createdAt ?? Date.now() }; + if (data.tags) meta.tags = data.tags; + if (data.star) meta.star = true; + const mdName = String(title).replace(/[^\w\u4e00-\u9fa5\-]/g, '_') + MD_EXT; + await fs.writeFile(nodePath.join(destAbs, mdName), joinFrontmatter(meta as any, body), 'utf8'); + } else if (e.isFile() && e.name.endsWith(MD_EXT)) { + await fs.copyFile(srcPath, nodePath.join(destAbs, e.name)); + } + } } } diff --git a/apps/desktop/src/main/services/vault-service.ts b/apps/desktop/src/main/services/vault-service.ts index d75225d..516d183 100644 --- a/apps/desktop/src/main/services/vault-service.ts +++ b/apps/desktop/src/main/services/vault-service.ts @@ -7,6 +7,7 @@ import type { VaultMeta } from '@hunter/shared'; import { ConfigStore } from './config-store.js'; import { FileService } from './file-service.js'; import { SearchService } from './search-service.js'; +import { MigrationService } from './migration-service.js'; export class VaultService { fileService: FileService | null = null; @@ -27,6 +28,18 @@ export class VaultService { } this.fileService = new FileService(meta.currentRoot); this.searchService = new SearchService(meta.currentRoot, this.fileService); + // FR-D1/D3:启动时检测并迁移旧版数据 + try { + const migrator = new MigrationService(); + const result = await migrator.detectAndMigrate(meta.currentRoot); + if (result.migrated) { + console.log('[MIGRATION] migrated', result.count, 'boards, errors:', result.errors.length); + if (result.errors.length > 0) console.warn('[MIGRATION] errors:', result.errors); + } + } catch (e) { + // FR-D3:迁移失败不阻断应用 + console.warn('[MIGRATION] failed:', e); + } return meta; } @@ -58,9 +71,13 @@ export class VaultService { } async switch(root: string): Promise { - // 校验是存在的目录 const stat = await fs.stat(root).catch(() => null); if (!stat || !stat.isDirectory()) throw new Error('目标路径不是有效目录: ' + root); + // FR-D1:切库时也检测迁移 + try { + const migrator = new MigrationService(); + await migrator.detectAndMigrate(root); + } catch (e) { console.warn('[MIGRATION] switch failed:', e); } const meta = await this.config.setCurrentRoot(root); this.fileService = new FileService(root); this.searchService = new SearchService(root, this.fileService); diff --git a/apps/desktop/src/renderer/features/doc-info/DocInfoPanel.tsx b/apps/desktop/src/renderer/features/doc-info/DocInfoPanel.tsx index f6c9263..38796fc 100644 --- a/apps/desktop/src/renderer/features/doc-info/DocInfoPanel.tsx +++ b/apps/desktop/src/renderer/features/doc-info/DocInfoPanel.tsx @@ -66,7 +66,7 @@ export function DocInfoPanel() {
{mtime ? new Date(mtime).toLocaleString('zh-CN') : '—'}
-
创建时间:—(按设计不落盘)
+
创建时间:{meta.createdAt ? new Date(meta.createdAt).toLocaleString('zh-CN') : '—'}
); diff --git a/apps/desktop/src/renderer/features/editor/EditorToolbar.tsx b/apps/desktop/src/renderer/features/editor/EditorToolbar.tsx index ac08a88..5e041a9 100644 --- a/apps/desktop/src/renderer/features/editor/EditorToolbar.tsx +++ b/apps/desktop/src/renderer/features/editor/EditorToolbar.tsx @@ -5,6 +5,12 @@ import { useDialog } from '../dialog/dialog-store'; import { invoke } from '../../ipc-client'; import { IPC } from '@hunter/shared'; +// FR-M4:编辑区星标入口 +function StarButton() { + const { meta, toggleStar } = useStore(); + return ; +} + const btn: React.CSSProperties = { minWidth: 30, height: 28, border: '1px solid var(--control-border)', background: 'transparent', color: 'var(--text-primary)', cursor: 'pointer', borderRadius: 4, fontSize: 13 }; function T({ onClick, title, children, disabled }: { onClick: () => void; title: string; children: React.ReactNode; disabled?: boolean }) { @@ -36,6 +42,7 @@ export function EditorToolbar() { }; return (
+ e.chain().focus().undo().run())} disabled={d}>↶ e.chain().focus().redo().run())} disabled={d}>↷ diff --git a/apps/desktop/src/renderer/features/editor/MoreActions.tsx b/apps/desktop/src/renderer/features/editor/MoreActions.tsx index b93cd4c..f4abae8 100644 --- a/apps/desktop/src/renderer/features/editor/MoreActions.tsx +++ b/apps/desktop/src/renderer/features/editor/MoreActions.tsx @@ -7,7 +7,12 @@ export function MoreActions() { const { currentPath, body, openDoc } = useStore(); const [open, setOpen] = useState(false); if (!currentPath) return null; - const copyMd = async () => { await invoke(IPC.copyMd, { path: currentPath }); notify('已复制 Markdown'); setOpen(false); }; + // FR-X1:复制全文——成功/失败均提示 + const copyMd = async () => { + try { await invoke(IPC.copyMd, { path: currentPath }); notify('已复制 Markdown'); } + catch (e) { notify('复制失败:' + (e instanceof Error ? e.message : String(e)), true); } + setOpen(false); + }; const print = () => { window.print(); setOpen(false); }; const reveal = async () => { await invoke(IPC.docReveal, { path: currentPath }); setOpen(false); }; const reload = async () => { await openDoc(currentPath); setOpen(false); }; @@ -29,4 +34,4 @@ function Item({ children, onClick }: { children: React.ReactNode; onClick: () => return
(e.currentTarget.style.background = 'var(--control-border)')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>{children}
; } const btn: React.CSSProperties = { height: 26, border: '1px solid var(--control-border)', background: 'transparent', color: 'var(--text-primary)', cursor: 'pointer', borderRadius: 4, fontSize: 14, padding: '0 8px' }; -function notify(msg: string) { const el = document.createElement('div'); el.textContent = msg; el.style.cssText = 'position:fixed;top:50px;right:20px;background:var(--accent);color:var(--accent-text);padding:10px 16px;border-radius:6px;z-index:3000;font-size:13px'; document.body.appendChild(el); setTimeout(() => el.remove(), 2500); } \ No newline at end of file +function notify(msg: string, isError?: boolean) { const el = document.createElement('div'); el.textContent = msg; el.style.cssText = 'position:fixed;top:50px;right:20px;background:' + (isError ? '#e81123' : 'var(--accent)') + ';color:' + (isError ? '#fff' : 'var(--accent-text)') + ';padding:10px 16px;border-radius:6px;z-index:3000;font-size:13px'; document.body.appendChild(el); setTimeout(() => el.remove(), 2500); } \ No newline at end of file diff --git a/apps/desktop/src/renderer/features/keymap/CheatSheet.tsx b/apps/desktop/src/renderer/features/keymap/CheatSheet.tsx index 2414b51..14ce04e 100644 --- a/apps/desktop/src/renderer/features/keymap/CheatSheet.tsx +++ b/apps/desktop/src/renderer/features/keymap/CheatSheet.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { useKeymap, formatCombo, type Scope } from './keymap-service'; const SCOPE_LABELS: Record = { global: '全局', app: '应用级', editor: '编辑器' }; @@ -5,6 +6,12 @@ const SCOPE_ORDER: Scope[] = ['global', 'app', 'editor']; export function CheatSheet({ onClose }: { onClose: () => void }) { const bindings = useKeymap(s => s.bindings); + // FR-B6:Esc 关闭速查面板 + useEffect(() => { + const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); } }; + window.addEventListener('keydown', onEsc, true); + return () => window.removeEventListener('keydown', onEsc, true); + }, [onClose]); return (
{ if (e.target === e.currentTarget) onClose(); }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1500 }}>
diff --git a/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx b/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx index c2ad06c..e9d0b69 100644 --- a/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx +++ b/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx @@ -38,6 +38,13 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) { window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); }, [recId, setCombo, saveKeymap]); + // FR-B6:Esc 关闭设置面板(不录制快捷键时生效) + useEffect(() => { + if (recId) return; // 正在录制时不触发 + const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); } }; + window.addEventListener('keydown', onEsc, true); + return () => window.removeEventListener('keydown', onEsc, true); + }, [recId, onClose]); return (
{ if (e.target === e.currentTarget) onClose(); }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1500 }}>
diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index b6c0a47..87b9a73 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -72,4 +72,26 @@ html, body, #root { margin: 0; height: 100%; } } .tiptap-wrap .ProseMirror p.is-editor-empty:first-child::before { content: attr(data-placeholder); color: var(--text-tertiary); pointer-events: none; float: left; -} \ No newline at end of file +} + +/* FR-X2:打印/另存为 PDF 干净排版 + 代码语法高亮 */ +@media print { + body * { visibility: hidden; } + .tiptap-wrap, .tiptap-wrap * { visibility: visible; } + .tiptap-wrap { position: absolute; left: 0; top: 0; width: 100%; padding: 20px; } + .tiptap-wrap .ProseMirror { font-size: 14px !important; color: #000 !important; } + .tiptap-wrap .ProseMirror pre { background: #f4f4f4 !important; color: #333 !important; border: 1px solid #ddd; padding: 8px; border-radius: 4px; overflow: visible; } + .tiptap-wrap .ProseMirror pre code { color: #333; } + .tiptap-wrap .ProseMirror h1, .tiptap-wrap .ProseMirror h2, .tiptap-wrap .ProseMirror h3 { color: #000; } + .tiptap-wrap .ProseMirror blockquote { border-color: #ccc; color: #555; } + .tiptap-wrap .ProseMirror a { color: #0066cc; text-decoration: underline; } +} + +/* FR-A12:统一过渡动效 */ +button { transition: background 0.15s, color 0.15s, opacity 0.15s; } +button:hover { opacity: 0.85; } +@keyframes panel-slide-in { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} +.panel-slide-in { animation: panel-slide-in 0.2s ease-out; } \ No newline at end of file diff --git a/apps/desktop/tests/e2e/meta-export.spec.ts b/apps/desktop/tests/e2e/meta-export.spec.ts new file mode 100644 index 0000000..20240cf --- /dev/null +++ b/apps/desktop/tests/e2e/meta-export.spec.ts @@ -0,0 +1,72 @@ +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'); + 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 newDoc(a: { page: any; invoke: any }, title: string) { + await a.page.getByRole('button', { name: /新建文档/ }).click(); + await a.page.getByPlaceholder(/输入文档标题/).fill(title); + await a.page.getByPlaceholder(/输入文档标题/).press('Enter'); + await expect(a.page.getByTestId('tabbar').getByText(title, { exact: true })).toBeVisible({ timeout: 10000 }); +} + +// FR-M4:编辑区星标按钮 +test('FR-M4:编辑区星标按钮', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-m4-')); + 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 newDoc(a, 'StarTest'); + await a.page.getByTitle('星标').click(); + await a.page.waitForTimeout(500); + const star = await a.page.evaluate(() => (window as any).__STORE__?.getState?.().meta?.star); + expect(star).toBe(true); + await a.app.close(); +}); + +// FR-B6:Esc 关闭设置面板 +test('FR-B6:Esc 关闭设置面板', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-b6-')); + 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.page.getByTitle('设置').click(); + await a.page.waitForTimeout(500); + await a.page.keyboard.press('Escape'); + await a.page.waitForTimeout(500); + await expect(a.page.getByText('设置', { exact: true })).toHaveCount(0, { timeout: 3000 }); + await a.app.close(); +}); + +// FR-D1:迁移旧版板块数据 +test('FR-D1:检测并迁移旧版板块数据', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-d1-')); + 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 oldDir = nodePath.join(v, '.hunter', 'boards', 'MyBoard'); + await fs.mkdir(oldDir, { recursive: true }); + await fs.writeFile(nodePath.join(oldDir, 'note.json'), JSON.stringify({ title: 'OldNote', content: 'legacy content', createdAt: 1700000000000 })); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + await a.page.waitForTimeout(1000); + const convertedDir = nodePath.join(v, 'MyBoard'); + const files = await fs.readdir(convertedDir).catch(() => []); + const mdFiles = files.filter(f => f.endsWith('.md')); + expect(mdFiles.length).toBeGreaterThan(0); + const mdContent = await fs.readFile(nodePath.join(convertedDir, mdFiles[0]), 'utf8'); + expect(mdContent).toContain('legacy content'); + await a.app.close(); +}); diff --git a/packages/shared/src/types/doc.ts b/packages/shared/src/types/doc.ts index d4313a1..2a3509c 100644 --- a/packages/shared/src/types/doc.ts +++ b/packages/shared/src/types/doc.ts @@ -4,6 +4,7 @@ export interface DocMeta { tags?: string[]; star?: boolean; links?: string[]; + createdAt?: number; // FR-M6:创建时间戳 } export interface ReadDocResult {