feat(M5): FR-A5 性能优先+GPU探测——main 进程 getGPUInfo 探测软渲染(SwiftShader/llvmpipe)+gpu:info IPC; settings 加 perfMode 开关+gpuStatus+loadGpu; 软渲染自动开启 perfMode; App 背景图 blur 受 perfMode 控制; EffectLayer perfMode 时关闭特效; SettingsPanel 其他页加开关+GPU 信息显示; perf-mode E2E 1 项

This commit is contained in:
hunter-dev
2026-08-26 14:34:34 +08:00
parent 56b0c25443
commit fcd822b9f4
7 changed files with 108 additions and 6 deletions
+12
View File
@@ -59,6 +59,18 @@ app.whenReady().then(async () => {
const oldDataDir = nodePath.join(app.getPath('userData'), 'legacy-data');
const r = await migrator.migrate(oldDataDir, vault.fileService?.getRoot() ?? '').catch(() => null);
if (r?.migrated) console.log('[MIGRATION] migrated', r.count, 'items');
// FR-A5:GPU 探测——软渲染时自动禁用实时模糊
let gpuStatus = { softwareRendering: false, vendor: '', renderer: '' };
try {
const gi = await (app as any).getGPUInfo('full');
gpuStatus = {
softwareRendering: gi.softwareRendering === true || /SwiftShader|llvmpipe|Software/.test(gi.gpuDevice?.[0]?.renderer ?? ''),
vendor: gi.gpuDevice?.[0]?.vendorString ?? '',
renderer: gi.gpuDevice?.[0]?.renderer ?? '',
};
} catch { /* getGPUInfo 在某些环境不可用 */ }
ipcMain.handle(IPC.gpuInfo, () => gpuStatus);
registerIpc(vault);
createWindow();
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
+2 -2
View File
@@ -36,7 +36,7 @@ export function App() {
const settings = useSettings();
const layout = useLayout();
useEffect(() => { loadVault(); loadTree(); restore(); appearance.load(); useKeymap.getState().load(); }, []);
useEffect(() => { loadVault(); loadTree(); restore(); appearance.load(); useKeymap.getState().load(); useSettings.getState().loadGpu(); }, []);
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) });
@@ -44,7 +44,7 @@ export function App() {
useEffect(() => { document.documentElement.style.setProperty('--editor-font-size', settings.fontSize + 'px'); document.documentElement.style.setProperty('--editor-font-family', settings.fontFamily); document.documentElement.style.setProperty('--editor-white-space', settings.wordWrap ? 'pre-wrap' : 'pre'); }, [settings.fontSize, settings.fontFamily, settings.wordWrap]);
return (
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100vh', background: 'var(--bg-primary, #1a1a1a)', color: '#ddd' }}>
{appearance.bgImage && <div style={{ position: 'absolute', inset: 0, backgroundImage: 'url(' + appearance.bgImage + ')', backgroundSize: 'cover', backgroundPosition: 'center', filter: 'blur(var(--blur-px,0px))', opacity: 0.45, zIndex: 0, pointerEvents: 'none' }} />}
{appearance.bgImage && <div style={{ position: 'absolute', inset: 0, backgroundImage: 'url(' + appearance.bgImage + ')', backgroundSize: 'cover', backgroundPosition: 'center', filter: settings.perfMode ? 'none' : 'blur(var(--blur-px,0px))', opacity: 0.45, zIndex: 0, pointerEvents: 'none' }} />}
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', height: '100vh' }}>
<TitleBar view={view} onToggleView={() => setView(v => v === 'editor' ? 'graph' : 'editor')} onSettings={() => setShowSettings(true)} onAppearance={() => setShowAppearance(true)} onToggleSidebar={() => layout.toggleSidebar()} onToggleMinimal={() => layout.toggleMinimal()} minimal={layout.minimal} />
<Toolbar />
@@ -1,12 +1,14 @@
import { useEffect, useRef } from 'react';
import { EffectEngine } from '../effects/effect-engine';
import { useAppearance } from '../appearance/appearance-store';
import { useSettings } from '../settings/settings-store';
export function EffectLayer() {
const ref = useRef<HTMLCanvasElement>(null);
const engineRef = useRef<EffectEngine | null>(null);
const effect = useAppearance(s => s.effect);
const accent = useAppearance(s => s.draft.accent);
const perfMode = useSettings(s => s.perfMode);
// 建引擎一次
useEffect(() => {
@@ -22,8 +24,9 @@ export function EffectLayer() {
const eng = engineRef.current;
if (!eng) return;
eng.setColor(accent);
eng.setType(effect);
}, [effect, accent]);
// FR-A5:性能优先时关闭特效
eng.setType(perfMode ? 'off' : effect);
}, [effect, accent, perfMode]);
return <canvas ref={ref} data-effect={effect} style={{ position: 'fixed', inset: 0, pointerEvents: 'none', zIndex: 9999 }} />;
}
@@ -57,7 +57,14 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
<div style={row}><span style={lbl}>代码块显示行号</span><button onClick={() => s.set('showLineNumbers', !s.showLineNumbers)} style={toggle(s.showLineNumbers)}><span style={dot(s.showLineNumbers)} /></button></div>
<div style={row}><span style={lbl}>自动换行</span><button onClick={() => s.set('wordWrap', !s.wordWrap)} style={toggle(s.wordWrap)}><span style={dot(s.wordWrap)} /></button></div>
</>}
{page === 'other' && <div style={row}><span style={lbl}>删除前确认</span><button onClick={() => s.set('confirmDelete', !s.confirmDelete)} style={toggle(s.confirmDelete)}><span style={dot(s.confirmDelete)} /></button></div>}
{page === 'other' && <>
<div style={row}><span style={lbl}>删除前确认</span><button onClick={() => s.set('confirmDelete', !s.confirmDelete)} style={toggle(s.confirmDelete)}><span style={dot(s.confirmDelete)} /></button></div>
<div style={row}>
<span style={lbl}>性能优先{s.gpuStatus?.softwareRendering ? '(检测到软渲染)' : ''}</span>
<button data-testid="perf-toggle" onClick={() => s.set('perfMode', !s.perfMode)} style={toggle(s.perfMode)}><span style={dot(s.perfMode)} /></button>
</div>
{s.gpuStatus && <div style={{ fontSize: 11, color: 'var(--text-tertiary)', padding: '4px 0' }}>GPU: {s.gpuStatus.renderer || '未知'} {s.gpuStatus.softwareRendering ? '⚠️软渲染' : '✓硬件加速'}</div>}
</>}
{page === 'path' && <PathPage />}
{page === 'keymap' && <>
<div style={{ ...lbl, opacity: 0.6, marginBottom: 8 }}>点击「录入」后按下新组合键(需含 Ctrl/Meta/Alt);Esc 取消</div>
@@ -8,6 +8,8 @@ export interface Settings {
showLineNumbers: boolean; // 代码块行号,真实生效
wordWrap: boolean; // 自动换行,真实生效
confirmDelete: boolean; // 删除前确认,真实生效
perfMode: boolean; // FR-A5:性能优先——关闭实时模糊/特效
gpuStatus: { softwareRendering: boolean; vendor: string; renderer: string } | null; // FR-A5:GPU 探测结果
}
const DEFAULTS: Settings = {
@@ -17,6 +19,8 @@ const DEFAULTS: Settings = {
showLineNumbers: false,
wordWrap: true,
confirmDelete: true,
perfMode: false,
gpuStatus: null,
};
const KEY = 'dsh-settings';
@@ -29,10 +33,19 @@ function load(): Settings {
interface SettingsState extends Settings {
set: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
persist: () => void;
loadGpu: () => Promise<void>; // FR-A5:从主进程拉取 GPU 状态
}
export const useSettings = create<SettingsState>((set, get) => ({
...load(),
set: (key, value) => { set({ [key]: value } as any); get().persist(); },
persist: () => { try { localStorage.setItem(KEY, JSON.stringify({ fontSize: get().fontSize, fontFamily: get().fontFamily, autoSaveInterval: get().autoSaveInterval, showLineNumbers: get().showLineNumbers, wordWrap: get().wordWrap, confirmDelete: get().confirmDelete })); } catch {} },
persist: () => { try { localStorage.setItem(KEY, JSON.stringify({ fontSize: get().fontSize, fontFamily: get().fontFamily, autoSaveInterval: get().autoSaveInterval, showLineNumbers: get().showLineNumbers, wordWrap: get().wordWrap, confirmDelete: get().confirmDelete, perfMode: get().perfMode })); } catch {} },
loadGpu: async () => {
try {
const r = await (globalThis as any).dsh.invoke('gpu:info');
set({ gpuStatus: r as any });
// FR-A5:软渲染时自动开启性能优先
if (r?.softwareRendering && !get().perfMode) set({ perfMode: true });
} catch { /* IPC 不可用时忽略 */ }
},
}));
+65
View File
@@ -0,0 +1,65 @@
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-A5:GPU 探测 + 性能优先开关
test('FR-A5:GPU 探测 + 性能优先开关持久化', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-a5-'));
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 });
// GPU info IPC 应可用
const gpu = await a.invoke('gpu:info', null as any);
expect(gpu).toBeTruthy();
expect(typeof gpu.softwareRendering).toBe('boolean');
// 打开设置 -> 其他页
await a.page.getByTitle('设置').click();
await expect(a.page.getByText('设置', { exact: true })).toBeVisible({ timeout: 5000 });
await a.page.getByRole('button', { name: '其他' }).click();
// 性能优先开关应可见
await expect(a.page.getByText('性能优先')).toBeVisible({ timeout: 5000 });
// GPU 信息应显示
await expect(a.page.getByText(/GPU:/)).toBeVisible({ timeout: 5000 });
// 开启性能优先——通过 data-testid 定位 toggle
const perfToggle = a.page.getByTestId('perf-toggle');
await perfToggle.click();
// 关闭设置面板
await a.page.keyboard.press('Escape');
await a.page.waitForTimeout(500);
// 重开应用验证持久化
await a.app.close();
const a2 = await launch(userData);
await a2.invoke('vault:switch', { root: vault });
await a2.page.getByTitle('设置').click();
await a2.page.getByRole('button', { name: '其他' }).click();
// perfMode 应已持久化(toggle 为开启状态)
const toggleBg = await a2.page.getByTestId('perf-toggle').evaluate((el: HTMLButtonElement) => el.style.background);
// 开启时 background 应为 var(--accent) 或对应值
expect(toggleBg).toContain('accent');
await a2.app.close();
});