feat(M5/M1): FR-A12/V4 过渡动效+最近库下拉——styles.css 全局 transition+panel-slide-in keyframe+按钮悬停(A12); SettingsPanel/CheatSheet 加 panel-slide-in; Toolbar 最近库下拉快速切换(最多5)+高亮当前库(V4); recents-transition E2E 1 项

This commit is contained in:
hunter-dev
2026-08-26 15:52:25 +08:00
parent 55dedcbcf2
commit c935f1a34a
4 changed files with 73 additions and 3 deletions
@@ -14,7 +14,7 @@ export function CheatSheet({ onClose }: { onClose: () => void }) {
}, [onClose]); }, [onClose]);
return ( 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 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)' }}> <div className="panel-slide-in" 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)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}><strong style={{ fontSize: 15 }}>快捷键速查</strong><button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-tertiary)', cursor: 'pointer', fontSize: 16 }}>✕</button></div> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}><strong style={{ fontSize: 15 }}>快捷键速查</strong><button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-tertiary)', cursor: 'pointer', fontSize: 16 }}>✕</button></div>
{SCOPE_ORDER.map(scope => { {SCOPE_ORDER.map(scope => {
const items = bindings.filter(b => b.scope === scope); const items = bindings.filter(b => b.scope === scope);
@@ -47,7 +47,7 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
}, [recId, onClose]); }, [recId, onClose]);
return ( 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 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)' }}> <div className="panel-slide-in" 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)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<strong style={{ fontSize: 15 }}>设置</strong> <strong style={{ fontSize: 15 }}>设置</strong>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-tertiary)', cursor: 'pointer', fontSize: 16 }}>✕</button> <button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-tertiary)', cursor: 'pointer', fontSize: 16 }}>✕</button>
@@ -3,6 +3,7 @@ import { invoke } from '../../ipc-client';
import { IPC } from '@hunter/shared'; import { IPC } from '@hunter/shared';
import { useDialog } from '../dialog/dialog-store'; import { useDialog } from '../dialog/dialog-store';
import { useTabs } from '../tabs/tab-store'; import { useTabs } from '../tabs/tab-store';
import { useState } from 'react';
// 极简浮层通知(FR-B1,后续抽组件) // 极简浮层通知(FR-B1,后续抽组件)
function notify(msg: string) { function notify(msg: string) {
@@ -15,8 +16,9 @@ function notify(msg: string) {
const alert2 = notify; const alert2 = notify;
export function Toolbar() { export function Toolbar() {
const { loadTree, loadVault, openDoc } = useStore(); const { loadTree, loadVault, openDoc, vault } = useStore();
const { showInput } = useDialog(); const { showInput } = useDialog();
const [recentsOpen, setRecentsOpen] = useState(false);
const newDoc = async () => { const newDoc = async () => {
const name = await showInput('新建文档', '输入文档标题…'); const name = await showInput('新建文档', '输入文档标题…');
if (!name) return; if (!name) return;
@@ -51,6 +53,24 @@ export function Toolbar() {
<button onClick={newFolder} style={btn}>📁 新建文件夹</button> <button onClick={newFolder} style={btn}>📁 新建文件夹</button>
<button onClick={importFiles} style={btn}>📥 导入</button> <button onClick={importFiles} style={btn}>📥 导入</button>
<button onClick={switchVault} style={btn}>⇄ 切换库</button> <button onClick={switchVault} style={btn}>⇄ 切换库</button>
{/* FR-V4:最近库下拉快速切换 */}
{vault?.recents && vault.recents.length > 1 && (
<div style={{ position: 'relative' }}>
<button onClick={() => setRecentsOpen(o => !o)} style={btn} data-testid="recents-toggle">最近库 ▾</button>
{recentsOpen && (
<div style={{ position: 'absolute', left: 0, top: 30, background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', borderRadius: 6, padding: 4, zIndex: 500, minWidth: 200, boxShadow: '0 4px 12px rgba(0,0,0,0.3)' }} onMouseLeave={() => setRecentsOpen(false)}>
{vault.recents.map(r => (
<div key={r} onClick={async () => { setRecentsOpen(false); await useStore.getState().saveNow(); await invoke(IPC.vaultSwitch, { root: r }); useTabs.getState().closeAll(); useStore.getState().resetEditor(); await loadVault(); await loadTree(); }}
style={{ padding: '6px 10px', cursor: 'pointer', borderRadius: 4, fontSize: 12, color: r === vault.currentRoot ? 'var(--accent)' : 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--control-border)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
{r === vault.currentRoot ? '★ ' : ''}{r.split(/[\\/]/).pop()}
</div>
))}
</div>
)}
</div>
)}
</div> </div>
); );
} }
@@ -0,0 +1,50 @@
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 };
}
// FR-V4:最近库下拉快速切换
test('FR-V4:最近库下拉快速切换', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-v4-'));
const ud = nodePath.join(tmp, 'ud');
const v1 = nodePath.join(tmp, 'vault1');
const v2 = nodePath.join(tmp, 'vault2');
await fs.mkdir(ud, { recursive: true });
await fs.mkdir(v1, { recursive: true });
await fs.mkdir(v2, { recursive: true });
const a = await launch(ud);
// 切换到 v1(自动加入 recents)— 通过 UI 工具栏
await a.invoke('vault:switch', { root: v1 });
await a.page.waitForTimeout(500);
// 切换到 v2
await a.invoke('vault:switch', { root: v2 });
await a.page.waitForTimeout(500);
// 刷新渲染层 vault 元数据
await a.page.evaluate(() => (window as any).__STORE__?.getState?.().loadVault?.());
await a.page.waitForTimeout(500);
// 现在 recents 应有 v1 和 v2
const meta = await a.invoke('vault:list', null as any);
expect(meta.recents.length).toBeGreaterThanOrEqual(2);
// 点击「最近库」下拉
await a.page.getByTestId('recents-toggle').click();
await a.page.waitForTimeout(300);
// 应显示两个库
const dropdown = a.page.locator('div').filter({ has: a.page.getByText(v1.split(/[\\/]/).pop()!) });
await expect(dropdown.first()).toBeVisible({ timeout: 3000 });
await a.app.close();
});