diff --git a/apps/desktop/src/main/ipc/handlers.ts b/apps/desktop/src/main/ipc/handlers.ts index 9b70879..7bb6813 100644 --- a/apps/desktop/src/main/ipc/handlers.ts +++ b/apps/desktop/src/main/ipc/handlers.ts @@ -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; 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); diff --git a/apps/desktop/src/main/services/config-store.ts b/apps/desktop/src/main/services/config-store.ts index e3e5bd0..614b197 100644 --- a/apps/desktop/src/main/services/config-store.ts +++ b/apps/desktop/src/main/services/config-store.ts @@ -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; 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; 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; effect: string } | null> { + const c = await this.read(); + return c.appearance ?? null; + } + async saveAppearance(data: { themeId: string; params: Record; effect: string }): Promise { + (await this.read()).appearance = data; + await this.write(); + } + async setDefaultRoot(root: string): Promise { const meta = await this.getVaultMeta(); meta.defaultRoot = root; diff --git a/apps/desktop/src/main/services/vault-service.ts b/apps/desktop/src/main/services/vault-service.ts index 1754e3e..e4256cd 100644 --- a/apps/desktop/src/main/services/vault-service.ts +++ b/apps/desktop/src/main/services/vault-service.ts @@ -50,4 +50,6 @@ export class VaultService { } async setDefaultRoot(root: string): Promise { await this.config.setDefaultRoot(root); } + getAppearance() { return this.config.getAppearance(); } + saveAppearance(data: { themeId: string; params: Record; effect: string }) { return this.config.saveAppearance(data); } } \ No newline at end of file diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index bfd7cc5..218fe92 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -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) }); diff --git a/apps/desktop/src/renderer/features/appearance/AppearancePanel.tsx b/apps/desktop/src/renderer/features/appearance/AppearancePanel.tsx index 32f95cb..1b75bc4 100644 --- a/apps/desktop/src/renderer/features/appearance/AppearancePanel.tsx +++ b/apps/desktop/src/renderer/features/appearance/AppearancePanel.tsx @@ -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 (
{ 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 }) { 外观定制
- +
{isDirty() ? '更改尚未保存' : '所有设置已保存'}
@@ -34,6 +37,12 @@ export function AppearancePanel({ onClose }: { onClose: () => void }) { +
鼠标特效
+
+ {EFFECTS.map(([t, label]) => ( + + ))} +
diff --git a/apps/desktop/src/renderer/features/appearance/appearance-store.ts b/apps/desktop/src/renderer/features/appearance/appearance-store.ts index ecf058c..b6301bd 100644 --- a/apps/desktop/src/renderer/features/appearance/appearance-store.ts +++ b/apps/desktop/src/renderer/features/appearance/appearance-store.ts @@ -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; // 用户手动改过的项(智能取色不覆盖) // actions selectTheme: (id: string) => void; override: (key: keyof ThemeParams, value: any) => void; + setEffect: (t: EffectType) => void; preview: () => void; // 实时预览(应用到 DOM) - save: () => void; // 显式保存:持久化 + 更新 saved 快照 + load: () => Promise; // 从 config.json 恢复(FR-A 持久化) + save: () => Promise; // 持久化 + 更新 saved 快照 revert: () => void; // Esc/取消:回滚到 saved isDirty: () => boolean; } @@ -21,6 +26,7 @@ export const useAppearance = create((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((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; effect: string } | null; + if (data) { + const t = THEMES.find(x => x.id === data.themeId) ?? defaultTheme; + const draft = { ...t.params, ...(data.params as Partial) }; + 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, effect }); + set({ saved: { ...draft } }); }, revert: () => { set({ draft: { ...get().saved } }); get().preview(); }, diff --git a/apps/desktop/src/renderer/features/effects/EffectLayer.tsx b/apps/desktop/src/renderer/features/effects/EffectLayer.tsx index 03b13aa..9462e5a 100644 --- a/apps/desktop/src/renderer/features/effects/EffectLayer.tsx +++ b/apps/desktop/src/renderer/features/effects/EffectLayer.tsx @@ -5,16 +5,25 @@ import { useAppearance } from '../appearance/appearance-store'; export function EffectLayer() { const ref = useRef(null); const engineRef = useRef(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 ; + + // effect/accent 变化时同步引擎(FR-A11 接线) + useEffect(() => { + const eng = engineRef.current; + if (!eng) return; + eng.setColor(accent); + eng.setType(effect); + }, [effect, accent]); + + return ; } diff --git a/apps/desktop/tests/e2e/appearance.spec.ts b/apps/desktop/tests/e2e/appearance.spec.ts new file mode 100644 index 0000000..a9ae59f --- /dev/null +++ b/apps/desktop/tests/e2e/appearance.spec.ts @@ -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(); +}); diff --git a/packages/shared/src/types/ipc.ts b/packages/shared/src/types/ipc.ts index d7fb8ca..33081ca 100644 --- a/packages/shared/src/types/ipc.ts +++ b/packages/shared/src/types/ipc.ts @@ -27,6 +27,8 @@ export const IPC = { winClose: 'win:close', copyMd: 'doc:copy-md', printPdf: 'doc:print-pdf', + appearanceLoad: 'appearance:load', + appearanceSave: 'appearance:save', } as const; export type IpcChannel = (typeof IPC)[keyof typeof IPC]; @@ -54,6 +56,8 @@ export interface IpcMap { [IPC.winClose]: { args: void; result: void }; [IPC.copyMd]: { args: { path: string }; result: string }; [IPC.printPdf]: { args: void; result: void }; + [IPC.appearanceLoad]: { args: void; result: { themeId: string; params: Record; effect: string } | null }; + [IPC.appearanceSave]: { args: { themeId: string; params: Record; effect: string }; result: void }; } // 类型安全的 invoke 帮助器签名(preload 暴露 + 渲染层使用)