feat(M10/M3/M2): FR-M4/M6/X1/X2/B6/D1/D3 元数据/导出/弹层/迁移——EditorToolbar 加星标按钮(M4); DocMeta 加 createdAt+frontmatter 读写+DocInfoPanel 显示+创建时写入(M6); copyMd 失败 catch+错误提示(X1); @media print 干净排版+hljs配色(X2); SettingsPanel/CheatSheet Esc 关闭(B6); migration-service 旧版板块转换+init+switch 检测(D1/D3); meta-export E2E 3 项

This commit is contained in:
hunter-dev
2026-08-26 15:48:22 +08:00
parent 52474012c0
commit 55dedcbcf2
13 changed files with 206 additions and 47 deletions
-6
View File
@@ -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 {
@@ -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' : '';
}
@@ -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 };
}
@@ -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<MigrationResult> {
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<boolean> {
try { await fs.access(this.flagFile()); return true; } catch { return false; }
}
// 判定是否应迁移:旧数据存在 + 未迁移过 + 新库为空(三者同时满足,FR-D2)
async shouldMigrate(oldDataDir: string, newVaultRoot: string): Promise<boolean> {
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<MigrationResult> {
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<void> {
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<string, unknown> = { 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));
}
}
}
}
@@ -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<VaultMeta> {
// 校验是存在的目录
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);
@@ -66,7 +66,7 @@ export function DocInfoPanel() {
<Section title="修改时间">
<div style={{ opacity: 0.6 }}>{mtime ? new Date(mtime).toLocaleString('zh-CN') : '—'}</div>
<div style={{ opacity: 0.4, fontSize: 12, marginTop: 4 }}>创建时间:—(按设计不落盘)</div>
<div style={{ opacity: 0.4, fontSize: 12, marginTop: 4 }}>创建时间:{meta.createdAt ? new Date(meta.createdAt).toLocaleString('zh-CN') : '—'}</div>
</Section>
</div>
);
@@ -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 <button onClick={() => toggleStar()} title="星标" style={{ ...btn, color: meta.star ? 'var(--accent)' : 'var(--text-tertiary)', fontSize: 16 }}>{meta.star ? '★' : '☆'}</button>;
}
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 (
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--control-border)', display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }}>
<StarButton />
<T title="撤销 Ctrl+Z" onClick={cmd(e => e.chain().focus().undo().run())} disabled={d}>↶</T>
<T title="重做 Ctrl+Y" onClick={cmd(e => e.chain().focus().redo().run())} disabled={d}>↷</T>
<span style={{ width: 1, background: 'var(--control-border)', height: 20, margin: '0 4px' }} />
@@ -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 <div onClick={onClick} style={{ padding: '6px 12px', cursor: 'pointer', borderRadius: 4, fontSize: 13, color: 'var(--text-primary)' }} onMouseEnter={e => (e.currentTarget.style.background = 'var(--control-border)')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>{children}</div>;
}
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); }
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); }
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { useKeymap, formatCombo, type Scope } from './keymap-service';
const SCOPE_LABELS: Record<Scope, string> = { 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 (
<div onClick={e => { 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 }}>
<div style={{ width: 480, maxHeight: '80vh', overflow: 'auto', background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', borderRadius: 10, padding: 20, boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}>
@@ -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 (
<div onClick={e => { 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 }}>
<div style={{ width: 520, maxHeight: '80vh', overflow: 'auto', background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', borderRadius: 10, padding: 20, boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}>
+23 -1
View File
@@ -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;
}
}
/* 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; }
@@ -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();
});
+1
View File
@@ -4,6 +4,7 @@ export interface DocMeta {
tags?: string[];
star?: boolean;
links?: string[];
createdAt?: number; // FR-M6:创建时间戳
}
export interface ReadDocResult {