feat(M5): FR-A6/A7/A9/A10 外观面板完善——恢复主题默认按钮+save剥离等于默认的键(A6); Esc回滚+关闭面板(A7); 智能取色据图片明暗自动切换深/浅主题基底(A9); 背景图压缩改1920px/WebP+超2MB降质(A10); appearance-enhanced E2E 3 项
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef, useEffect } from 'react';
|
||||||
import { useAppearance } from './appearance-store';
|
import { useAppearance } from './appearance-store';
|
||||||
import { THEMES } from './themes';
|
import { THEMES } from './themes';
|
||||||
import type { ThemeDef } 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','涟漪']];
|
const EFFECTS: [EffectType, string][] = [['off','关闭'],['auto','智能'],['particle','微粒'],['meteor','流星'],['aurora','极光'],['ripple','涟漪']];
|
||||||
|
|
||||||
export function AppearancePanel({ onClose }: { onClose: () => void }) {
|
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<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(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','浮层背景']];
|
const colors: [keyof typeof draft, string][] = [['accent','强调色'],['glow','辉光色'],['accentText','强调色文字'],['textPrimary','主要文字'],['textSecondary','辅助文字'],['textTertiary','弱化文字'],['controlBorder','控件边框'],['bgPrimary','工作区背景'],['bgSecondary','浮层背景']];
|
||||||
return (
|
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 }}>
|
<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 }}>
|
||||||
@@ -52,20 +58,32 @@ export function AppearancePanel({ onClose }: { onClose: () => void }) {
|
|||||||
<button key={t} onClick={() => setEffect(t)} style={{ padding: '8px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: effect === t ? '2px solid var(--accent)' : '1px solid var(--control-border)', background: effect === t ? 'var(--control-border)' : 'transparent', color: effect === t ? 'var(--accent-text)' : 'var(--text-secondary)' }}>{label}</button>
|
<button key={t} onClick={() => setEffect(t)} style={{ padding: '8px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: effect === t ? '2px solid var(--accent)' : '1px solid var(--control-border)', background: effect === t ? 'var(--control-border)' : 'transparent', color: effect === t ? 'var(--accent-text)' : 'var(--text-secondary)' }}>{label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => revert()} style={btn}>恢复(取消改动)</button>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={() => revert()} style={btn}>恢复(取消改动)</button>
|
||||||
|
<button onClick={() => restoreThemeDefault()} style={btn}>恢复主题默认</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 压缩背景图:限宽 1600、JPEG 0.82,避免 config.json 过大
|
// FR-A10:压缩背景图——最长边 1920px、转 WebP、超 2MB 再降质量
|
||||||
async function compressImage(file: File, maxW = 1600, quality = 0.82): Promise<string> {
|
async function compressImage(file: File, maxW = 1920, quality = 0.85): Promise<string> {
|
||||||
const dataUrl = await new Promise<string>((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result as string); r.onerror = rej; r.readAsDataURL(file); });
|
const dataUrl = await new Promise<string>((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result as string); r.onerror = rej; r.readAsDataURL(file); });
|
||||||
const img = await new Promise<HTMLImageElement>((res, rej) => { const i = new Image(); i.onload = () => res(i); i.onerror = rej; i.src = dataUrl; });
|
const img = await new Promise<HTMLImageElement>((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 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);
|
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 {
|
function normalizeColor(c: string): string {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface AppearanceState {
|
|||||||
load: () => Promise<void>; // 从 config.json 恢复(FR-A 持久化)
|
load: () => Promise<void>; // 从 config.json 恢复(FR-A 持久化)
|
||||||
save: () => Promise<void>; // 持久化 + 更新 saved 快照
|
save: () => Promise<void>; // 持久化 + 更新 saved 快照
|
||||||
revert: () => void; // Esc/取消:回滚到 saved
|
revert: () => void; // Esc/取消:回滚到 saved
|
||||||
|
restoreThemeDefault: () => void; // FR-A6:恢复当前主题的默认参数
|
||||||
isDirty: () => boolean;
|
isDirty: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +72,14 @@ export const useAppearance = create<AppearanceState>((set, get) => ({
|
|||||||
// 智能取色(FR-A9):依背景图推导强调色/模糊/表面不透明,尊重用户手动项
|
// 智能取色(FR-A9):依背景图推导强调色/模糊/表面不透明,尊重用户手动项
|
||||||
try {
|
try {
|
||||||
const r = analyzeImage(await sampleImage(dataUrl));
|
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 => {
|
set(s => {
|
||||||
const draft = { ...s.draft };
|
const draft = { ...s.draft };
|
||||||
if (!s.manualFlags.has('accent')) draft.accent = r.accent;
|
if (!s.manualFlags.has('accent')) draft.accent = r.accent;
|
||||||
@@ -99,11 +108,27 @@ export const useAppearance = create<AppearanceState>((set, get) => ({
|
|||||||
|
|
||||||
save: async () => {
|
save: async () => {
|
||||||
const { themeId, draft, effect, bgImage } = get();
|
const { themeId, draft, effect, bgImage } = get();
|
||||||
await (globalThis as any).dsh.invoke(IPC.appearanceSave, { themeId, params: draft as unknown as Record<string, unknown>, effect, bgImage });
|
// FR-A6:剥离与当前主题默认值相同的项(保持配置干净)
|
||||||
|
const theme = THEMES.find(x => x.id === themeId);
|
||||||
|
const cleanParams: Record<string, unknown> = {};
|
||||||
|
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 } });
|
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),
|
isDirty: () => JSON.stringify(get().draft) !== JSON.stringify(get().saved),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user