feat: M5 外观特效选择+持久化(appearance:load/save IPC+config.json,鼠效 6 选格接 EffectEngine,重开恢复并接线)

This commit is contained in:
hunter-dev
2026-08-24 23:42:46 +08:00
parent 28755547c1
commit cf2600901b
9 changed files with 111 additions and 21 deletions
+3
View File
@@ -50,6 +50,9 @@ export function registerIpc(vault: VaultService) {
const gd = await buildGraphData(fs2.getRoot(), tree); return gd;
});
ipcMain.handle(IPC.appearanceLoad, async () => vault.getAppearance());
ipcMain.handle(IPC.appearanceSave, async (_e, p: { themeId: string; params: Record<string, unknown>; effect: string }) => { await vault.saveAppearance(p); });
ipcMain.handle(IPC.copyMd, async (_e, p: { path: string }) => {
const fs2 = vault.fileService; if (!fs2) return '';
const { body } = await fs2.readDoc(p.path);
+11 -2
View File
@@ -6,13 +6,13 @@ import { RECENT_VAULT_MAX } from '@hunter/shared';
export class ConfigStore {
private file: string;
private cache: { vault?: VaultMeta } | null = null;
private cache: { vault?: VaultMeta; appearance?: { themeId: string; params: Record<string, unknown>; effect: string } } | null = null;
constructor(userDataDir: string) {
this.file = nodePath.join(userDataDir, 'config.json');
}
private async read(): Promise<{ vault?: VaultMeta }> {
private async read(): Promise<{ vault?: VaultMeta; appearance?: { themeId: string; params: Record<string, unknown>; effect: string } }> {
if (this.cache) return this.cache;
try {
const raw = await fs.readFile(this.file, 'utf8');
@@ -46,6 +46,15 @@ export class ConfigStore {
return meta;
}
async getAppearance(): Promise<{ themeId: string; params: Record<string, unknown>; effect: string } | null> {
const c = await this.read();
return c.appearance ?? null;
}
async saveAppearance(data: { themeId: string; params: Record<string, unknown>; effect: string }): Promise<void> {
(await this.read()).appearance = data;
await this.write();
}
async setDefaultRoot(root: string): Promise<VaultMeta> {
const meta = await this.getVaultMeta();
meta.defaultRoot = root;
@@ -50,4 +50,6 @@ export class VaultService {
}
async setDefaultRoot(root: string): Promise<void> { await this.config.setDefaultRoot(root); }
getAppearance() { return this.config.getAppearance(); }
saveAppearance(data: { themeId: string; params: Record<string, unknown>; effect: string }) { return this.config.saveAppearance(data); }
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function App() {
const settings = useSettings();
const layout = useLayout();
useEffect(() => { loadVault(); loadTree(); restore(); appearance.preview(); }, []);
useEffect(() => { loadVault(); loadTree(); restore(); appearance.load(); }, []);
useEffect(() => { if (activeId && tabs.length > 0) { const t = tabs.find(x => x.id === activeId); if (t) openDoc(t.path).catch(() => {}); } }, []);
useGlobalHotkeys({ onNewDoc: () => {}, onSave: () => useStore.getState().saveNow(), onFocusSearch: () => { const el = document.querySelector('input[placeholder*="搜索"]') as HTMLInputElement; el?.focus(); }, onToggleOutline: () => layout.toggleOutline(), onCheat: () => setShowCheat(true), onSettings: () => setShowSettings(true) });
@@ -1,9 +1,12 @@
import { useAppearance } from './appearance-store';
import { THEMES } from './themes';
import type { ThemeDef } from './themes';
import type { EffectType } from '../effects/effect-engine';
const EFFECTS: [EffectType, string][] = [['off','关闭'],['auto','智能'],['particle','微粒'],['meteor','流星'],['aurora','极光'],['ripple','涟漪']];
export function AppearancePanel({ onClose }: { onClose: () => void }) {
const { themeId, draft, selectTheme, override, save, revert, isDirty } = useAppearance();
const { themeId, draft, effect, selectTheme, override, setEffect, save, revert, isDirty } = useAppearance();
const colors: [keyof typeof draft, string][] = [['accent','强调色'],['glow','辉光色'],['accentText','强调色文字'],['textPrimary','主要文字'],['textSecondary','辅助文字'],['textTertiary','弱化文字'],['controlBorder','控件边框'],['bgPrimary','工作区背景'],['bgSecondary','浮层背景']];
return (
<div onClick={e => { if (e.target === e.currentTarget) { revert(); onClose(); } }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1500 }}>
@@ -12,7 +15,7 @@ export function AppearancePanel({ onClose }: { onClose: () => void }) {
<strong style={{ fontSize: 15 }}>外观定制</strong>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => { revert(); onClose(); }} style={btn}>取消</button>
<button onClick={() => { save(); onClose(); }} style={{ ...btn, background: '#2a4a6a', color: '#9cf' }}>保存外观</button>
<button onClick={async () => { await save(); onClose(); }} style={{ ...btn, background: '#2a4a6a', color: '#9cf' }}>保存外观</button>
</div>
</div>
<div style={{ marginBottom: 12, fontSize: 12, color: isDirty() ? '#e8a' : '#6a8' }}>{isDirty() ? '更改尚未保存' : '所有设置已保存'}</div>
@@ -34,6 +37,12 @@ export function AppearancePanel({ onClose }: { onClose: () => void }) {
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>背景模糊 {draft.blurPx}px<input type="range" min={0} max={40} value={draft.blurPx} onChange={e => override('blurPx', +e.target.value)} /></label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>表面不透明 {Math.round(draft.surfaceOpacity*100)}%<input type="range" min={20} max={100} value={draft.surfaceOpacity*100} onChange={e => override('surfaceOpacity', +e.target.value/100)} /></label>
</div>
<div style={{ opacity: 0.5, fontSize: 12, marginBottom: 6 }}>鼠标特效</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6, marginBottom: 16 }}>
{EFFECTS.map(([t, label]) => (
<button key={t} onClick={() => setEffect(t)} style={{ padding: '8px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: effect === t ? '2px solid #4a9eff' : '1px solid #3a3a4a', background: effect === t ? '#2a2a3a' : 'transparent', color: effect === t ? '#9cf' : '#aaa' }}>{label}</button>
))}
</div>
<button onClick={() => revert()} style={btn}>恢复(取消改动)</button>
</div>
</div>
@@ -1,16 +1,21 @@
import { create } from 'zustand';
import { IPC } from '@hunter/shared';
import { THEMES, applyTheme, type ThemeDef, type ThemeParams } from './themes';
import type { EffectType } from '../effects/effect-engine';
interface AppearanceState {
themeId: string;
draft: ThemeParams; // 工作副本
saved: ThemeParams; // 已保存快照(回滚基准)
effect: EffectType; // 鼠标特效(FR-A11)
manualFlags: Set<keyof ThemeParams>; // 用户手动改过的项(智能取色不覆盖)
// actions
selectTheme: (id: string) => void;
override: (key: keyof ThemeParams, value: any) => void;
setEffect: (t: EffectType) => void;
preview: () => void; // 实时预览(应用到 DOM)
save: () => void; // 显式保存:持久化 + 更新 saved 快照
load: () => Promise<void>; // 从 config.json 恢复(FR-A 持久化)
save: () => Promise<void>; // 持久化 + 更新 saved 快照
revert: () => void; // Esc/取消:回滚到 saved
isDirty: () => boolean;
}
@@ -21,6 +26,7 @@ export const useAppearance = create<AppearanceState>((set, get) => ({
themeId: defaultTheme.id,
draft: { ...defaultTheme.params },
saved: { ...defaultTheme.params },
effect: 'off',
manualFlags: new Set(),
selectTheme: (id) => {
@@ -34,18 +40,26 @@ export const useAppearance = create<AppearanceState>((set, get) => ({
get().preview();
},
setEffect: (t) => set({ effect: t }),
preview: () => applyTheme(get().draft),
save: () => {
const draft = get().draft;
// 与主题默认值相同的调整自动丢弃(保持配置干净)
const t = THEMES.find(x => x.id === get().themeId);
const cleaned = { ...draft };
if (t) for (const k of Object.keys(t.params) as (keyof ThemeParams)[]) {
if (cleaned[k] === t.params[k] && !get().manualFlags.has(k)) { /* 保留默认即可 */ }
}
set({ saved: { ...cleaned } });
// M5 暂存内存;M9 接 electron-store 持久化
load: async () => {
try {
const data = (await (globalThis as any).dsh.invoke(IPC.appearanceLoad)) as { themeId: string; params: Record<string, unknown>; effect: string } | null;
if (data) {
const t = THEMES.find(x => x.id === data.themeId) ?? defaultTheme;
const draft = { ...t.params, ...(data.params as Partial<ThemeParams>) };
set({ themeId: data.themeId, draft, saved: { ...draft }, effect: (data.effect as EffectType) ?? 'off' });
}
} catch { /* 首次启动无配置 */ }
get().preview();
},
save: async () => {
const { themeId, draft, effect } = get();
await (globalThis as any).dsh.invoke(IPC.appearanceSave, { themeId, params: draft as unknown as Record<string, unknown>, effect });
set({ saved: { ...draft } });
},
revert: () => { set({ draft: { ...get().saved } }); get().preview(); },
@@ -5,16 +5,25 @@ import { useAppearance } from '../appearance/appearance-store';
export function EffectLayer() {
const ref = useRef<HTMLCanvasElement>(null);
const engineRef = useRef<EffectEngine | null>(null);
const appearance = useAppearance();
// M6 骨架:默认关闭,外观面板后续接入选择
const effect = useAppearance(s => s.effect);
const accent = useAppearance(s => s.draft.accent);
// 建引擎一次
useEffect(() => {
if (!ref.current) return;
const eng = new EffectEngine();
eng.attach(ref.current);
eng.setColor(appearance.draft.accent);
eng.setType('off'); // 默认关闭,待外观面板接入开启
engineRef.current = eng;
return () => eng.detach();
}, []);
return <canvas ref={ref} style={{ position: 'fixed', inset: 0, pointerEvents: 'none', zIndex: 9999 }} />;
// effect/accent 变化时同步引擎(FR-A11 接线)
useEffect(() => {
const eng = engineRef.current;
if (!eng) return;
eng.setColor(accent);
eng.setType(effect);
}, [effect, accent]);
return <canvas ref={ref} data-effect={effect} style={{ position: 'fixed', inset: 0, pointerEvents: 'none', zIndex: 9999 }} />;
}
+40
View File
@@ -0,0 +1,40 @@
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 };
}
test('外观:特效选择 + 持久化(重开恢复并接线 EffectLayer)', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-appearance-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
// 选特效 + 保存
const a1 = await launch(userData);
await a1.invoke('vault:switch', { root: vault });
await a1.page.getByTitle('外观定制').click();
await expect(a1.page.getByText('外观定制').first()).toBeVisible();
await a1.page.getByRole('button', { name: '微粒' }).click();
await a1.page.getByRole('button', { name: '保存外观' }).click();
await expect(a1.page.getByText('外观定制').first()).toBeHidden();
await a1.app.close();
// 重开:恢复 + 接线
const a2 = await launch(userData);
const cfg: { effect: string } | null = await a2.invoke('appearance:load', null);
expect(cfg?.effect).toBe('particle');
await expect(a2.page.locator('canvas[data-effect="particle"]')).toBeVisible({ timeout: 10000 });
await a2.app.close();
});