From 56b0c25443c97fac6efc30b8870e6383fa1c6704 Mon Sep 17 00:00:00 2001 From: hunter-dev Date: Wed, 26 Aug 2026 14:17:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(M5):=20FR-A6/A7/A9/A10=20=E5=A4=96?= =?UTF-8?q?=E8=A7=82=E9=9D=A2=E6=9D=BF=E5=AE=8C=E5=96=84=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E4=B8=BB=E9=A2=98=E9=BB=98=E8=AE=A4=E6=8C=89?= =?UTF-8?q?=E9=92=AE+save=E5=89=A5=E7=A6=BB=E7=AD=89=E4=BA=8E=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E7=9A=84=E9=94=AE(A6);=20Esc=E5=9B=9E=E6=BB=9A+?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E9=9D=A2=E6=9D=BF(A7);=20=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E5=8F=96=E8=89=B2=E6=8D=AE=E5=9B=BE=E7=89=87=E6=98=8E=E6=9A=97?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=87=E6=8D=A2=E6=B7=B1/=E6=B5=85?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E5=9F=BA=E5=BA=95(A9);=20=E8=83=8C=E6=99=AF?= =?UTF-8?q?=E5=9B=BE=E5=8E=8B=E7=BC=A9=E6=94=B91920px/WebP+=E8=B6=852MB?= =?UTF-8?q?=E9=99=8D=E8=B4=A8(A10);=20appearance-enhanced=20E2E=203=20?= =?UTF-8?q?=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/appearance/AppearancePanel.tsx | 32 ++++-- .../features/appearance/appearance-store.ts | 29 ++++- .../tests/e2e/appearance-enhanced.spec.ts | 106 ++++++++++++++++++ 3 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/tests/e2e/appearance-enhanced.spec.ts diff --git a/apps/desktop/src/renderer/features/appearance/AppearancePanel.tsx b/apps/desktop/src/renderer/features/appearance/AppearancePanel.tsx index 5294c7b..193f6c2 100644 --- a/apps/desktop/src/renderer/features/appearance/AppearancePanel.tsx +++ b/apps/desktop/src/renderer/features/appearance/AppearancePanel.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useRef, useEffect } from 'react'; import { useAppearance } from './appearance-store'; import { THEMES } from './themes'; import type { ThemeDef } from './themes'; @@ -7,8 +7,14 @@ 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, effect, bgImage, selectTheme, override, setEffect, setBgImage, save, revert, isDirty } = useAppearance(); + const { themeId, draft, effect, bgImage, selectTheme, override, setEffect, setBgImage, save, revert, restoreThemeDefault, isDirty } = useAppearance(); const fileRef = useRef(null); + // FR-A7:Esc 回滚 + 关闭 + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { revert(); onClose(); } }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [revert, onClose]); 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 }}> @@ -52,20 +58,32 @@ export function AppearancePanel({ onClose }: { onClose: () => void }) { ))}
- +
+ + +
); } -// 压缩背景图:限宽 1600、JPEG 0.82,避免 config.json 过大 -async function compressImage(file: File, maxW = 1600, quality = 0.82): Promise { +// FR-A10:压缩背景图——最长边 1920px、转 WebP、超 2MB 再降质量 +async function compressImage(file: File, maxW = 1920, quality = 0.85): Promise { const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result as string); r.onerror = rej; r.readAsDataURL(file); }); const img = await new Promise((res, rej) => { const i = new Image(); i.onload = () => res(i); i.onerror = rej; i.src = dataUrl; }); - const scale = Math.min(1, maxW / img.width); + const longest = Math.max(img.width, img.height); + const scale = Math.min(1, maxW / longest); const c = document.createElement('canvas'); c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale); const ctx = c.getContext('2d')!; ctx.drawImage(img, 0, 0, c.width, c.height); - return c.toDataURL('image/jpeg', quality); + let result = c.toDataURL('image/webp', quality); + // 超 2MB 再降质量(约 1.5MB base64 阈值) + if (result.length > 2_000_000) { + result = c.toDataURL('image/webp', 0.7); + if (result.length > 2_000_000) { + result = c.toDataURL('image/webp', 0.55); + } + } + return result; } function normalizeColor(c: string): string { diff --git a/apps/desktop/src/renderer/features/appearance/appearance-store.ts b/apps/desktop/src/renderer/features/appearance/appearance-store.ts index 7bbe26a..70f3de7 100644 --- a/apps/desktop/src/renderer/features/appearance/appearance-store.ts +++ b/apps/desktop/src/renderer/features/appearance/appearance-store.ts @@ -19,6 +19,7 @@ interface AppearanceState { load: () => Promise; // 从 config.json 恢复(FR-A 持久化) save: () => Promise; // 持久化 + 更新 saved 快照 revert: () => void; // Esc/取消:回滚到 saved + restoreThemeDefault: () => void; // FR-A6:恢复当前主题的默认参数 isDirty: () => boolean; } @@ -71,6 +72,14 @@ export const useAppearance = create((set, get) => ({ // 智能取色(FR-A9):依背景图推导强调色/模糊/表面不透明,尊重用户手动项 try { const r = analyzeImage(await sampleImage(dataUrl)); + // FR-A9:据图片明暗自动切换深/浅主题基底 + const currentTheme = THEMES.find(x => x.id === get().themeId); + if (currentTheme && currentTheme.group !== r.mode) { + const targetTheme = THEMES.find(x => x.group === r.mode && x.id !== 'transparent') ?? THEMES.find(x => x.group === r.mode); + if (targetTheme) { + set({ themeId: targetTheme.id }); + } + } set(s => { const draft = { ...s.draft }; if (!s.manualFlags.has('accent')) draft.accent = r.accent; @@ -99,11 +108,27 @@ export const useAppearance = create((set, get) => ({ save: async () => { const { themeId, draft, effect, bgImage } = get(); - await (globalThis as any).dsh.invoke(IPC.appearanceSave, { themeId, params: draft as unknown as Record, effect, bgImage }); + // FR-A6:剥离与当前主题默认值相同的项(保持配置干净) + const theme = THEMES.find(x => x.id === themeId); + const cleanParams: Record = {}; + if (theme) { + for (const [k, v] of Object.entries(draft)) { + if (JSON.stringify(v) !== JSON.stringify((theme.params as any)[k])) { + cleanParams[k] = v; + } + } + } + await (globalThis as any).dsh.invoke(IPC.appearanceSave, { themeId, params: cleanParams, effect, bgImage }); set({ saved: { ...draft } }); }, - revert: () => { set({ draft: { ...get().saved } }); get().preview(); }, + revert: () => { set({ draft: { ...get().saved }, manualFlags: new Set() }); get().preview(); }, + + restoreThemeDefault: () => { + const t = THEMES.find(x => x.id === get().themeId) ?? defaultTheme; + set({ draft: { ...t.params }, manualFlags: new Set() }); + get().preview(); + }, isDirty: () => JSON.stringify(get().draft) !== JSON.stringify(get().saved), })); diff --git a/apps/desktop/tests/e2e/appearance-enhanced.spec.ts b/apps/desktop/tests/e2e/appearance-enhanced.spec.ts new file mode 100644 index 0000000..bbdbc03 --- /dev/null +++ b/apps/desktop/tests/e2e/appearance-enhanced.spec.ts @@ -0,0 +1,106 @@ +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 openAppearance(a: { page: any }) { + await a.page.getByTitle('外观定制').click(); + await expect(a.page.getByText('外观定制')).toBeVisible({ timeout: 5000 }); +} + +// FR-A7:Esc 关闭外观面板并回滚 +test('FR-A7:Esc 关闭外观面板', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-a7-')); + 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 a = await launch(userData); + await a.invoke('vault:switch', { root: vault }); + await openAppearance(a); + await a.page.keyboard.press('Escape'); + await expect(a.page.getByText('外观定制')).not.toBeVisible({ timeout: 5000 }); + await a.app.close(); +}); + +// FR-A6:恢复主题默认按钮 + save 剥离等于默认的项 +test('FR-A6:恢复主题默认 + 保存剥离默认值', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-a6-')); + 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 a = await launch(userData); + await a.invoke('vault:switch', { root: vault }); + await openAppearance(a); + + // 改一个颜色参数 -> dirty + const accentInput = a.page.locator('input[type="color"]').first(); + await accentInput.fill('#ff0000'); + await expect(a.page.getByText('更改尚未保存')).toBeVisible({ timeout: 5000 }); + + // 恢复主题默认 -> 应恢复 + await a.page.getByRole('button', { name: '恢复主题默认' }).click(); + await expect(a.page.getByText('所有设置已保存')).toBeVisible({ timeout: 3000 }); + + // 保存后 config.json 的 params 应为空(全等于默认) + await a.page.getByRole('button', { name: '保存外观' }).click(); + await expect(a.page.getByText('外观定制')).not.toBeVisible({ timeout: 5000 }); + + const cfgPath = nodePath.join(userData, 'config.json'); + const cfg = JSON.parse(await fs.readFile(cfgPath, 'utf-8')); + // params 应为空对象或只含非默认项 + const paramKeys = Object.keys(cfg.appearance?.params ?? {}); + expect(paramKeys.length).toBe(0); + + await a.app.close(); +}); + +// FR-A10:背景图压缩为 WebP(检查 data URL 前缀) +test('FR-A10:背景图压缩为 WebP', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-a10-')); + 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 a = await launch(userData); + await a.invoke('vault:switch', { root: vault }); + + // 创建一个小测试 PNG + const pngData = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='; + const pngBuf = Buffer.from(pngData, 'base64'); + const imgPath = nodePath.join(tmp, 'test.png'); + await fs.writeFile(imgPath, pngBuf); + + await openAppearance(a); + + // 上传图片 + const fileInput = a.page.locator('input[type="file"]'); + await fileInput.setInputFiles(imgPath); + await a.page.waitForTimeout(1000); + + // 检查 bgImage 在 store 中是 WebP data URL + const bgImage = await a.page.evaluate(() => { + // 从背景图 img 元素获取 src + const img = document.querySelector('img[alt="bg"]') as HTMLImageElement; + return img?.src ?? ''; + }); + // 应为 data:image/webp 开头 + expect(bgImage.startsWith('data:image/webp')).toBeTruthy(); + + await a.app.close(); +});