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:
@@ -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(); });
|
||||
|
||||
@@ -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 不可用时忽略 */ }
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -28,6 +28,7 @@ export const IPC = {
|
||||
winMinimize: 'win:minimize',
|
||||
winMaximize: 'win:maximize',
|
||||
winClose: 'win:close',
|
||||
gpuInfo: 'gpu:info',
|
||||
copyMd: 'doc:copy-md',
|
||||
printPdf: 'doc:print-pdf',
|
||||
appearanceLoad: 'appearance:load',
|
||||
@@ -62,6 +63,7 @@ export interface IpcMap {
|
||||
[IPC.winMinimize]: { args: void; result: void };
|
||||
[IPC.winMaximize]: { args: void; result: void };
|
||||
[IPC.winClose]: { args: void; result: void };
|
||||
[IPC.gpuInfo]: { args: void; result: { softwareRendering: boolean; vendor: string; renderer: string } };
|
||||
[IPC.copyMd]: { args: { path: string }; result: string };
|
||||
[IPC.printPdf]: { args: void; result: void };
|
||||
[IPC.appearanceLoad]: { args: void; result: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null } | null };
|
||||
|
||||
Reference in New Issue
Block a user