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
@@ -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; }