feat: add safe backup management and theme preferences

Add snapshot usage and protected cleanup, preserve unsaved profile edits, avoid holding SQL connections during snapshot deletion, and fix download filenames. Include regression tests and API documentation.
This commit is contained in:
2026-10-17 00:36:21 +08:00
parent 09e202796f
commit 602917698a
20 changed files with 1240 additions and 83 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/><meta name="theme-color" media="(prefers-color-scheme: light)" content="#152b36"/><meta name="theme-color" media="(prefers-color-scheme: dark)" content="#101c22"/><link rel="icon" type="image/svg+xml" href="/favicon.svg"/><title>DSH · 工作台</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
<html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/><meta name="theme-color" content="#152b36"/><link rel="icon" type="image/svg+xml" href="/favicon.svg"/><title>DSH · 工作台</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
+2
View File
@@ -12,6 +12,7 @@ import { RuntimeWorkspace, useRuntimeInstances } from './RuntimeWorkspace';
import { Button, buttonVariants } from './components/ui/button';
import { Input } from './components/ui/input';
import { cn } from './lib/utils';
import { ThemeSettings } from './ThemeSettings';
const pages = [
{id: 'dsh', label: 'DSH', icon: SquareTerminal, description: '你的 DeepSeek Harness 工作台'},
@@ -121,6 +122,7 @@ function Console({user, onLogout, onPasswordChanged}: {user: User; onLogout: ()
{page === 'workspace' && <Workspace runtime={runtime} />}
{page === 'admin' && user.admin && <Admin />}
{page === 'settings' && <>
<ThemeSettings />
<Panel title="DSH 实例" action={<Button variant="ghost" size="sm" disabled={runtime.loading} onClick={() => void runtime.refresh()}>刷新</Button>}>
{runtime.error && <Alert>{runtime.error}<Button variant="outline" size="sm" className="ml-2" disabled={runtime.loading} onClick={() => void runtime.refresh()}>重试</Button></Alert>}
{runtime.loading && <p role="status" className="m-0 mb-3 text-[13px] text-muted-foreground">正在刷新实例…</p>}
+73 -4
View File
@@ -1,5 +1,5 @@
import { beforeEach, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0}));
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0, resourceCursor: 0, snapshots: [] as any[], refreshStorage: vi.fn(async () => {})}));
vi.mock('react', async original => ({
...await original<typeof import('react')>(),
useState(value: unknown) {const i = state.cursor++; if (!(i in state.slots)) state.slots[i] = value; return [state.slots[i], (next: unknown) => {state.slots[i] = next;}];},
@@ -8,7 +8,7 @@ vi.mock('react', async original => ({
}));
vi.mock('./ui', async original => ({
...await original<typeof import('./ui')>(),
useResource: () => ({data: [{id: 'backup', created_at: '', size: 1}], loading: false, refresh: vi.fn(async () => {})}),
useResource: () => ({data: state.resourceCursor++ % 2 === 0 ? [{id: 'backup', created_at: '', size: 1}] : state.snapshots, loading: false, refresh: state.refreshStorage}),
}));
import { BackupPanel } from './BackupPanel';
import { api } from './api';
@@ -20,12 +20,13 @@ function nodes(root: any, predicate: (node: any) => boolean): any[] {
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
}
const refresh = vi.fn(async () => {});
function render() {state.cursor = 0; return BackupPanel({instance: {id: 'one'} as any, refresh});}
const onRestored = vi.fn(async () => {});
function render() {state.cursor = 0; state.resourceCursor = 0; return BackupPanel({instance: {id: 'one'} as any, refresh, onRestored});}
function editor() {
const child = nodes(render(), n => typeof n.type === 'function' && n.props.backup)[0];
return child.type(child.props);
}
beforeEach(() => {state.slots = []; state.cursor = 0; vi.restoreAllMocks(); refresh.mockClear();});
beforeEach(() => {state.slots = []; state.cursor = 0; state.snapshots = []; state.refreshStorage.mockClear(); vi.restoreAllMocks(); refresh.mockClear(); onRestored.mockClear();});
it('retains restore password and shows failure inside the owning modal after confirmation', async () => {
const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('restore denied'));
nodes(render(), n => n.type === 'button' && Array.isArray(n.props.children) && n.props.children.includes('恢复'))[0].props.onClick();
@@ -38,4 +39,72 @@ it('retains restore password and shows failure inside the owning modal after con
expect(nodes(modal, n => n.type === 'input')[0].props.value).toBe('original-password');
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/restore', 'POST', {backup_id: 'backup', password: 'original-password'});
expect(refresh).not.toHaveBeenCalled();
expect(onRestored).not.toHaveBeenCalled();
});
function button(label: string) {
return nodes(render(), n => n.type === 'button' && (n.props.children === label || Array.isArray(n.props.children) && n.props.children.includes(label)))[0];
}
it('requires confirmation for configuration deletion and retains download', async () => {
const request = vi.spyOn(api, 'request').mockResolvedValue({});
expect(nodes(render(), n => n.type === 'a')[0].props.href).toBe('/api/instances/one/backups?download=backup');
button('删除配置包').props.onClick();
expect(request).not.toHaveBeenCalled();
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(false);
await new Promise(resolve => setTimeout(resolve, 0));
expect(request).not.toHaveBeenCalled();
button('删除配置包').props.onClick();
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
await new Promise(resolve => setTimeout(resolve, 0));
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/backups?id=backup', 'DELETE');
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
expect(refresh).toHaveBeenCalledOnce();
expect(onRestored).not.toHaveBeenCalled();
});
it('shows snapshot bytes separately, deletes confirmed snapshots and refreshes after rejection', async () => {
state.snapshots = [{id: 'pre-restore-abc', size: 1048576, created_at: '', protected: false}];
const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('instance recovery required'));
expect(JSON.stringify(render())).toContain('1.0 MiB');
button('删除快照').props.onClick();
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
await new Promise(resolve => setTimeout(resolve, 0));
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/snapshots?id=pre-restore-abc', 'DELETE');
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
expect(JSON.stringify(render())).toContain('instance recovery required');
expect(onRestored).not.toHaveBeenCalled();
});
it('disables protected snapshots and does not report failed size scans as zero', () => {
state.snapshots = [{id: 'pre-restore-abc', size: 0, created_at: '', protected: true, protection_reason: 'recovery required', size_error: 'tree limit'}];
expect(button('删除快照').props.disabled).toBe(true);
const text = JSON.stringify(render());
expect(text).toContain('recovery required');
expect(text).toContain('统计不完整');
expect(text).toContain('tree limit');
});
it('refreshes storage after successful snapshot deletion', async () => {
state.snapshots = [{id: 'pre-restore-abc', size: 1048576, created_at: '', protected: false}];
const request = vi.spyOn(api, 'request').mockResolvedValue({ok: true});
button('删除快照').props.onClick();
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
await new Promise(resolve => setTimeout(resolve, 0));
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/snapshots?id=pre-restore-abc', 'DELETE');
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
expect(refresh).toHaveBeenCalledOnce();
expect(JSON.stringify(render())).toContain('快照已删除');
expect(onRestored).not.toHaveBeenCalled();
});
it('manual storage refresh does not reset the profile', async () => {
button('刷新备份与占用').props.onClick();
await new Promise(resolve => setTimeout(resolve, 0));
expect(refresh).toHaveBeenCalledOnce();
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
expect(onRestored).not.toHaveBeenCalled();
});
it('only a successful restore invokes the profile reload callback', async () => {
vi.spyOn(api, 'request').mockResolvedValue({ok: true});
button('恢复').props.onClick();
nodes(editor(), n => n.type === 'form')[0].props.onSubmit({preventDefault: vi.fn()});
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
await new Promise(resolve => setTimeout(resolve, 0));
expect(onRestored).toHaveBeenCalledOnce();
expect(refresh).not.toHaveBeenCalled();
});
+46 -6
View File
@@ -1,6 +1,6 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import { Download, HardDriveDownload, RotateCcw, Upload } from 'lucide-react';
import { api, instancePath, type Instance, type Backup } from './api';
import { Download, HardDriveDownload, RotateCcw, Trash2, Upload } from 'lucide-react';
import { api, instancePath, type Instance, type Backup, type Snapshot } from './api';
import { ResourceState, Empty, bytes, date, useAction, useResource } from './ui';
import { useConfirm } from './components/ConfirmDialog';
import { AlertBox } from './components/ui/alert';
@@ -9,14 +9,29 @@ import { buttonVariants } from './components/ui/button';
import { inputClass } from './components/ui/input';
import { cn } from './lib/utils';
export function BackupPanel({instance, refresh}: {instance: Instance; refresh: () => Promise<void>}) {
export function BackupPanel({instance, refresh, onRestored}: {instance: Instance; refresh: () => Promise<void>; onRestored: () => Promise<void>}) {
const base = instancePath(instance.id);
const backups = useResource(() => api.request<Backup[]>(base + '/backups'));
const backups = useResource(() => api.request<Backup[]>(base + '/backups'), base);
const snapshots = useResource(() => api.request<Snapshot[]>(base + '/snapshots'), base);
const action = useAction();
const confirm = useConfirm();
const [password, setPassword] = useState('');
const [importPassword, setImportPassword] = useState(''), [file, setFile] = useState<File>();
const [restoreTarget, setRestoreTarget] = useState<Backup>();
async function refreshStorage() {
await Promise.all([backups.refresh(), snapshots.refresh(), refresh()]);
}
async function remove(item: Backup | Snapshot, snapshot: boolean) {
if (snapshot && (item as Snapshot).protected) return;
if (!await confirm.ask({
title: snapshot ? '删除完整 home 快照' : '删除配置备份', danger: true, confirmLabel: '永久删除',
body: `确认永久删除 ${item.id}?此操作不可撤销,不影响当前 home。${snapshot ? '删除后将无法使用此快照回滚。' : '请先下载需要保留的配置包;删除配置包不会清理完整快照。'}`,
})) return;
await action.run(async () => {
try { await api.request(base + (snapshot ? '/snapshots?id=' : '/backups?id=') + encodeURIComponent(item.id), 'DELETE'); }
finally { await refreshStorage(); }
}, snapshot ? '快照已删除,空间占用已刷新' : '配置备份已删除');
}
function createBackup(e: FormEvent) {
e.preventDefault();
@@ -44,7 +59,10 @@ export function BackupPanel({instance, refresh}: {instance: Instance; refresh: (
title: '恢复配置备份', danger: true, confirmLabel: '确认恢复',
body: '确认覆盖当前配置?服务器将先保留当前 home 快照,随后按需停止实例并恢复原运行状态。',
})) return false;
return action.run(async () => { await api.request(base + '/restore', 'POST', {backup_id: backup.id, password: originalPassword}); await refresh(); }, '配置恢复及插件验证完成,已恢复原运行状态。');
return action.run(async () => {
try { await api.request(base + '/restore', 'POST', {backup_id: backup.id, password: originalPassword}); await onRestored(); }
finally { await snapshots.refresh(); }
}, '配置恢复及插件验证完成,已恢复原运行状态。');
}
return <section aria-label="备份与恢复" className="grid gap-5">
@@ -71,6 +89,8 @@ export function BackupPanel({instance, refresh}: {instance: Instance; refresh: (
</div>
{!restoreTarget && action.feedback}
<h3 className="m-0 text-sm font-semibold">配置备份(.dshbak)</h3>
<p className="m-0 text-xs text-muted-foreground">仅包含指定配置与资源,不包含完整 home 或依赖;下方大小为加密配置包大小。</p>
<ResourceState resource={backups} />
{backups.data?.length === 0 && <Empty>暂无配置备份</Empty>}
<div className="grid gap-2">
@@ -79,13 +99,33 @@ export function BackupPanel({instance, refresh}: {instance: Instance; refresh: (
<p className="m-0 truncate font-mono text-xs">{b.id}</p>
<p className="m-0 mt-0.5 text-xs text-muted-foreground">{date(b.created_at)} · {bytes(b.size)}</p>
</div>
<div className="flex gap-1.5">
<div className="flex flex-wrap gap-1.5">
<a className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} href={'/api' + base + '/backups?download=' + encodeURIComponent(b.id)}><Download aria-hidden />下载</a>
<button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} disabled={action.busy} onClick={() => setRestoreTarget(b)}><RotateCcw aria-hidden />恢复</button>
<button className={cn(buttonVariants({variant: 'destructive', size: 'sm'}))} disabled={action.busy} onClick={() => void remove(b, false)}><Trash2 aria-hidden />删除配置包</button>
</div>
</div>)}
</div>
<section aria-label="完整 home 快照" className="grid gap-3 border-t border-border pt-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="m-0 text-sm font-semibold">完整 home 快照</h3>
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy || snapshots.loading || backups.loading} onClick={() => void refreshStorage()}>刷新备份与占用</button>
</div>
<AlertBox variant="info">升级或恢复前保留的完整 home 副本,包含依赖及工作文件,会计入空间配额;不包含在上面的配置包大小中。快照暂不支持网页下载,需要导出时请联系管理员。删除不可撤销,待恢复实例的快照受保护。</AlertBox>
<ResourceState resource={snapshots} />
{snapshots.data && !snapshots.error && <p className="m-0 text-sm">快照占用(文件字节数):{snapshots.data.some(s => s.size_error) ? '统计不完整,请查看各项错误' : bytes(snapshots.data.reduce((total, s) => total + s.size, 0))}</p>}
{snapshots.data?.length === 0 && <Empty>暂无完整 home 快照</Empty>}
{snapshots.data?.map(s => <div key={s.id} className="flex flex-wrap items-center gap-3 rounded-lg border border-border bg-card px-3.5 py-3">
<div className="min-w-0 flex-1">
<p className="m-0 break-all font-mono text-xs">{s.id}</p>
<p className="m-0 mt-1 text-xs text-muted-foreground">{date(s.created_at)} · {s.size_error ? '大小统计失败:' + s.size_error : bytes(s.size)}</p>
{s.protected && <p className="m-0 mt-1 text-xs text-destructive">受保护:{s.protection_reason || '实例需要恢复,请联系管理员'}</p>}
</div>
<button className={cn(buttonVariants({variant: 'destructive', size: 'sm'}))} disabled={action.busy || s.protected} onClick={() => void remove(s, true)}><Trash2 aria-hidden />删除快照</button>
</div>)}
</section>
{restoreTarget && <RestoreDialog backup={restoreTarget} busy={action.busy} feedback={action.feedback} onClose={() => setRestoreTarget(undefined)} onSubmit={async password => {
const ok = await restore(restoreTarget, password);
if (ok) setRestoreTarget(undefined);
+81
View File
@@ -0,0 +1,81 @@
import { expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({
slots: [] as any[], cursor: 0, resourceCursor: 0, resources: [] as any[],
}));
vi.mock('react', async original => ({
...await original<typeof import('react')>(),
useState(value: unknown) {
const slots = state.slots, i = state.cursor++;
if (!(i in slots)) slots[i] = value;
return [slots[i], (next: unknown) => {slots[i] = typeof next === 'function' ? next(slots[i]) : next;}];
},
}));
vi.mock('./ui', async original => ({
...await original<typeof import('./ui')>(),
useResource: () => state.resources[state.resourceCursor++],
useAction: () => ({busy: false, feedback: null}),
}));
vi.mock('./components/ConfirmDialog', () => ({useConfirm: () => ({element: null})}));
vi.mock('./ProfileEditor', () => ({ProfileEditor: () => null}));
import { Instances } from './Instances';
import { ProfileEditor } from './ProfileEditor';
import { BackupPanel } from './BackupPanel';
function nodes(root: any, predicate: (node: any) => boolean): any[] {
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
if (!root || typeof root !== 'object') return [];
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
}
it('preserves the unsaved Profile draft on backup refresh and reloads it only after restore', async () => {
const instance = {id: 'one', name: 'Test instance', status: 'stopped', created_at: '', version: 'test'};
const instances = {data: [instance], loading: false, refresh: vi.fn(async () => {})};
const usage = {data: {used_bytes: 1, quota_bytes: 100}, loading: false, refresh: vi.fn(async () => {})};
const profile = {
data: {content: 'name: original'}, loading: false,
refresh: vi.fn(async () => {profile.data = {content: 'name: restored'};}),
};
const parentSlots: any[] = [], detailSlots: any[] = [];
function renderParent() {
state.slots = parentSlots; state.cursor = 0; state.resourceCursor = 0;
state.resources = [instances, usage];
return Instances();
}
function renderDetail() {
const detail = nodes(renderParent(), n => typeof n.type === 'function' && n.props.instance?.id === instance.id)[0];
expect(detail).toBeDefined();
state.slots = detailSlots; state.cursor = 0; state.resourceCursor = 0;
state.resources = [{data: []}, profile, {data: undefined}, {data: undefined}];
return detail.type(detail.props);
}
const editor = (tree: any) => nodes(tree, n => n.type === ProfileEditor && n.props.onChange)[0];
const backup = (tree: any) => nodes(tree, n => n.type === BackupPanel)[0];
const save = (tree: any) => nodes(tree, n => n.type === 'button' && n.props.children === '保存并启动')[0];
nodes(renderParent(), n => n.type === 'button' && n.props.children === '管理')[0].props.onClick();
let tree = renderDetail();
expect(editor(tree).props.value).toBe('name: original');
expect(save(tree).props.disabled).toBe(true);
editor(tree).props.onChange('name: unsaved draft');
tree = renderDetail();
expect(editor(tree).props.value).toBe('name: unsaved draft');
expect(save(tree).props.disabled).toBe(false);
await backup(tree).props.refresh();
expect(instances.refresh).toHaveBeenCalledOnce();
expect(usage.refresh).toHaveBeenCalledOnce();
expect(profile.refresh).not.toHaveBeenCalled();
tree = renderDetail();
expect(editor(tree).props.value).toBe('name: unsaved draft');
expect(save(tree).props.disabled).toBe(false);
await backup(tree).props.onRestored();
expect(profile.refresh).toHaveBeenCalledOnce();
expect(instances.refresh).toHaveBeenCalledTimes(2);
expect(usage.refresh).toHaveBeenCalledTimes(2);
tree = renderDetail();
expect(editor(tree).props.value).toBe('name: restored');
expect(save(tree).props.disabled).toBe(true);
});
+2 -2
View File
@@ -100,7 +100,7 @@ export function Instances() {
</div>
</>}
</Panel>
{selected && <InstanceDetail key={selected.id} instance={instances.data?.find(i => i.id === selected.id) || selected} close={() => setSelected(undefined)} refresh={instances.refresh} />}
{selected && <InstanceDetail key={selected.id} instance={instances.data?.find(i => i.id === selected.id) || selected} close={() => setSelected(undefined)} refresh={async () => { await Promise.all([instances.refresh(), usage.refresh()]); }} />}
</>;
}
@@ -162,7 +162,7 @@ function InstanceDetail({instance, close, refresh}: {instance: Instance; close:
</>}
</TabsContent>
<TabsContent value="backups">
<BackupPanel instance={instance} refresh={async () => { await profile.refresh(); setContent(undefined); await refresh(); }} />
<BackupPanel instance={instance} refresh={refresh} onRestored={async () => { await profile.refresh(); setContent(undefined); await refresh(); }} />
</TabsContent>
<TabsContent value="upgrade">
<form className="grid max-w-xl gap-4" onSubmit={async e => {
+23
View File
@@ -0,0 +1,23 @@
import { Panel } from './ui';
import { Button } from './components/ui/button';
import { setThemePreference, useThemePreference, type ThemePreference } from './nightMode';
const options: {value: ThemePreference; label: string}[] = [
{value: 'light', label: '日间'},
{value: 'dark', label: '夜间'},
{value: 'auto', label: '自动(跟随系统)'},
];
export function ThemeSettings() {
const preference = useThemePreference();
return <Panel title="外观">
<div role="group" aria-label="工作台主题" className="flex flex-wrap gap-2">
{options.map(option => <Button key={option.value} type="button"
variant={preference === option.value ? 'default' : 'outline'}
aria-pressed={preference === option.value} onClick={() => setThemePreference(option.value)}>
{option.label}
</Button>)}
</div>
<p className="mb-0 mt-3 text-[13px] text-muted-foreground">仅在此浏览器保存,适用于工作台、登录页和文件编辑器。自动模式随系统实时切换;内嵌 DSH 的主题独立管理,不受此设置影响。</p>
</Panel>;
}
+1
View File
@@ -13,6 +13,7 @@ export interface Entry { name: string; path?: string; is_dir: boolean; size: num
export interface Usage { used_bytes: number; quota_bytes: number }
export interface TextFile { content: string; version: string }
export interface Backup { id: string; created_at: string; size: number }
export interface Snapshot { id: string; created_at: string; size: number; protected: boolean; protection_reason: string; size_error?: string }
export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string }
export function createApi(fetcher: typeof fetch = fetch) {
let csrf = '';
+3
View File
@@ -3,4 +3,7 @@ import { createRoot } from "react-dom/client";
import App from "./App";
import { RuntimeRenew } from "./RuntimeRenew";
import "./style.css";
import { initializeTheme } from "./nightMode";
const disposeTheme = initializeTheme();
if (import.meta.hot) import.meta.hot.dispose(disposeTheme);
createRoot(document.getElementById("root")!).render(<React.StrictMode>{window.location.hash.startsWith("#runtime-renew?") ? <RuntimeRenew /> : <App />}</React.StrictMode>);
+86 -27
View File
@@ -1,32 +1,91 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
const hooks = vi.hoisted(() => ({slots: [] as any[], cursor: 0, effects: [] as (() => void)[]}));
vi.mock('react', async importOriginal => ({
...await importOriginal<typeof import('react')>(),
useRef(value: unknown) { const i = hooks.cursor++; return hooks.slots[i] ??= {current:value}; },
useState(value: unknown) { const i = hooks.cursor++; if (!(i in hooks.slots)) hooks.slots[i] = value; return [hooks.slots[i], (v: unknown) => { hooks.slots[i] = v; }]; },
useEffect(fn: () => (() => void) | undefined, deps: unknown[]) {
const i = hooks.cursor++, previous = hooks.slots[i];
if (!previous || deps.some((v, n) => v !== previous.deps[n])) hooks.effects.push(() => { previous?.cleanup?.(); hooks.slots[i] = {deps, cleanup:fn()}; });
const hooks = vi.hoisted(() => ({cleanups: [] as (() => void)[], notify: vi.fn()}));
vi.mock('react', async original => ({
...await original<typeof import('react')>(),
useSyncExternalStore(subscribe: (fn: () => void) => () => void, snapshot: () => unknown) {
hooks.cleanups.push(subscribe(hooks.notify));
return snapshot();
},
}));
import { useNightMode } from './nightMode';
const media = {matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn()};
const render = () => { hooks.cursor = 0; const result = useNightMode(); hooks.effects.splice(0).forEach(effect => effect()); return result; };
beforeEach(() => { hooks.slots = []; hooks.cursor = 0; hooks.effects = []; media.matches = false; vi.clearAllMocks(); vi.stubGlobal('window', {matchMedia: vi.fn(() => media)}); });
afterEach(() => { hooks.slots.forEach(s => s?.cleanup?.()); vi.unstubAllGlobals(); });
it('reflects the current system color scheme', () => {
media.matches = true;
expect(render()).toBe(true);
expect(media.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
import { initializeTheme, setThemePreference, themeStorageKey, useNightMode, useThemePreference } from './nightMode';
import { ThemeSettings } from './ThemeSettings';
let cleanup: (() => void) | undefined;
let media: EventTarget & {matches: boolean};
let win: EventTarget & {localStorage: {getItem: ReturnType<typeof vi.fn>; setItem: ReturnType<typeof vi.fn>}; matchMedia: ReturnType<typeof vi.fn>};
let dataset: {theme?: string};
let meta: {setAttribute: ReturnType<typeof vi.fn>};
let saved: string | null;
beforeEach(() => {
saved = null;
media = Object.assign(new EventTarget(), {matches: false});
win = Object.assign(new EventTarget(), {localStorage: {getItem: vi.fn(() => saved), setItem: vi.fn((_key, value) => {saved = value;})}, matchMedia: vi.fn(() => media)});
dataset = {}; meta = {setAttribute: vi.fn()};
vi.stubGlobal('window', win);
vi.stubGlobal('document', {documentElement: {dataset}, querySelector: vi.fn(() => meta)});
hooks.notify.mockClear();
});
it('updates when the system scheme changes and unsubscribes on cleanup', () => {
expect(render()).toBe(false);
const listener = media.addEventListener.mock.calls[0][1] as (event: MediaQueryListEvent) => void;
listener({matches: true} as MediaQueryListEvent);
expect(render()).toBe(true);
hooks.slots.forEach(s => s?.cleanup?.());
expect(media.removeEventListener).toHaveBeenCalledWith('change', listener);
afterEach(() => {cleanup?.(); hooks.cleanups.splice(0).forEach(fn => fn()); vi.unstubAllGlobals();});
function start() {cleanup = initializeTheme();}
function os(dark: boolean) {media.matches = dark; media.dispatchEvent(new Event('change'));}
function storage(value: string | null, key: string | null = themeStorageKey, area: unknown = win.localStorage) {
win.dispatchEvent(Object.assign(new Event('storage'), {key, newValue: value, storageArea: area}));
}
it('defaults to auto and applies OS changes to DOM, metadata and Monaco', () => {
start(); expect(useThemePreference()).toBe('auto'); expect(useNightMode()).toBe(false);
os(true); expect(useNightMode()).toBe(true); expect(hooks.notify).toHaveBeenCalled();
expect(dataset.theme).toBe('dark');
expect(meta.setAttribute).toHaveBeenLastCalledWith('content', '#101c22');
os(false); expect(dataset.theme).toBe('light');
expect(meta.setAttribute).toHaveBeenLastCalledWith('content', '#152b36');
});
it.each(['light', 'dark'] as const)('persists %s and overrides either OS scheme across initialization', value => {
start(); setThemePreference(value);
for (const system of [true, false]) {os(system); expect(useNightMode()).toBe(value === 'dark');}
expect(win.localStorage.setItem).toHaveBeenCalledWith(themeStorageKey, value);
cleanup!(); start(); expect(useThemePreference()).toBe(value); expect(dataset.theme).toBe(value);
setThemePreference('auto'); os(true); expect(useNightMode()).toBe(true); expect(saved).toBe('auto');
});
it.each([null, '', 'invalid', 'DARK', 'auto'])('treats stored %s as auto', value => {
saved = value; media.matches = true; start();
expect(useThemePreference()).toBe('auto'); expect(dataset.theme).toBe('dark');
});
it('synchronizes tabs including removal, clear and invalid values without writing back', () => {
start(); storage('dark'); expect(useNightMode()).toBe(true);
storage('light'); expect(useThemePreference()).toBe('light'); os(true); expect(useNightMode()).toBe(false);
for (const value of [null, 'bad', 'auto']) {storage(value); expect(useThemePreference()).toBe('auto'); expect(useNightMode()).toBe(true);}
storage('light'); storage(null, null); expect(useThemePreference()).toBe('auto');
storage('light', 'unrelated'); storage('light', themeStorageKey, {});
expect(useThemePreference()).toBe('auto'); expect(win.localStorage.setItem).not.toHaveBeenCalled();
});
it('keeps manual choices usable with blocked storage', () => {
Object.defineProperty(win, 'localStorage', {get() {throw new Error('blocked');}});
start(); expect(useThemePreference()).toBe('auto');
setThemePreference('dark'); os(false); expect(useNightMode()).toBe(true);
setThemePreference('light'); os(true); expect(useNightMode()).toBe(false);
setThemePreference('auto'); expect(useNightMode()).toBe(true);
});
it('keeps a selection when storage writes fail', () => {
start(); win.localStorage.setItem.mockImplementation(() => {throw new Error('quota');});
setThemePreference('dark'); expect(dataset.theme).toBe('dark');
});
it('initializes once and cleans up global listeners and hook subscriptions', () => {
const removeMedia = vi.spyOn(media, 'removeEventListener');
const removeWindow = vi.spyOn(win, 'removeEventListener');
start(); expect(initializeTheme()).toBe(cleanup); expect(win.matchMedia).toHaveBeenCalledOnce();
useNightMode(); hooks.cleanups.splice(0).forEach(fn => fn());
hooks.notify.mockClear(); setThemePreference('light'); expect(hooks.notify).not.toHaveBeenCalled();
cleanup!(); cleanup = undefined;
expect(removeMedia).toHaveBeenCalledWith('change', expect.any(Function));
expect(removeWindow).toHaveBeenCalledWith('storage', expect.any(Function));
os(true); storage('dark'); expect(dataset.theme).toBe('light');
});
it('settings buttons expose one pressed choice and explain DSH independence', () => {
start(); const buttons = () => ThemeSettings().props.children[0].props.children;
expect(buttons().map((b: any) => b.props['aria-pressed'])).toEqual([false, false, true]);
for (const [index, value] of ['light', 'dark', 'auto'].entries()) {
buttons()[index].props.onClick(); expect(useThemePreference()).toBe(value);
expect(buttons().filter((b: any) => b.props['aria-pressed'])).toHaveLength(1);
expect(buttons()[index].props['aria-pressed']).toBe(true);
}
expect(JSON.stringify(ThemeSettings())).toContain('内嵌 DSH 的主题独立管理');
});
+54 -12
View File
@@ -1,16 +1,58 @@
import { useEffect, useState } from 'react';
import { useSyncExternalStore } from 'react';
const query = () => window.matchMedia('(prefers-color-scheme: dark)');
export type ThemePreference = 'light' | 'dark' | 'auto';
export const themeStorageKey = 'dsh.console.theme';
let preference: ThemePreference = 'auto';
let dark = false;
let media: MediaQueryList | undefined;
let dispose: (() => void) | undefined;
const listeners = new Set<() => void>();
const normalize = (value: string | null): ThemePreference => value === 'light' || value === 'dark' ? value : 'auto';
/** Tracks the OS dark-scheme preference, matching the automatic night mode of embedded DSH. */
function update() {
dark = preference === 'dark' || (preference === 'auto' && !!media?.matches);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', dark ? '#101c22' : '#152b36');
listeners.forEach(listener => listener());
}
export function initializeTheme() {
if (dispose) return dispose;
try { preference = normalize(window.localStorage.getItem(themeStorageKey)); } catch { preference = 'auto'; }
media = window.matchMedia('(prefers-color-scheme: dark)');
const storage = (event: StorageEvent) => {
if (event.key !== themeStorageKey && event.key !== null) return;
try { if (event.storageArea !== window.localStorage) return; } catch { return; }
preference = normalize(event.key === null ? null : event.newValue);
update();
};
media.addEventListener('change', update);
window.addEventListener('storage', storage);
update();
const currentMedia = media;
dispose = () => {
currentMedia.removeEventListener('change', update);
window.removeEventListener('storage', storage);
dispose = undefined;
};
return dispose;
}
export function setThemePreference(value: ThemePreference) {
preference = value;
try { window.localStorage.setItem(themeStorageKey, value); } catch { /* Keep the selection for this page when storage is blocked. */ }
update();
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
export function useThemePreference() {
return useSyncExternalStore(subscribe, () => preference);
}
export function useNightMode() {
const [dark, setDark] = useState(query().matches);
useEffect(() => {
const media = query();
const update = (event: MediaQueryListEvent) => setDark(event.matches);
setDark(media.matches);
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, []);
return dark;
return useSyncExternalStore(subscribe, () => dark);
}
+24 -29
View File
@@ -51,45 +51,40 @@
--touch-min: 2.75rem;
}
/* Automatic night mode: follow the OS color scheme, same as embedded DSH.
Dark values override the @theme variables above; utilities keep working
because they reference the variables at computed-value time. */
@layer base {
:root {
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--color-background: #101c22;
--color-foreground: #d9e5e9;
--color-card: #17282f;
--color-card-foreground: #d9e5e9;
--color-muted: #1f333c;
--color-muted-foreground: #92acb6;
--color-border: #2c434d;
--color-input: #3a545f;
--color-ring: #4ec9ae;
:root[data-theme="dark"] {
color-scheme: dark;
--color-background: #101c22;
--color-foreground: #d9e5e9;
--color-card: #17282f;
--color-card-foreground: #d9e5e9;
--color-muted: #1f333c;
--color-muted-foreground: #92acb6;
--color-border: #2c434d;
--color-input: #3a545f;
--color-ring: #4ec9ae;
--color-primary-hover: #15a192;
--color-primary-hover: #15a192;
--color-secondary: #1e343c;
--color-secondary-foreground: #7fd8c5;
--color-secondary: #1e343c;
--color-secondary-foreground: #7fd8c5;
--color-destructive: #d45d53;
--color-destructive-muted: #3b2421;
--color-destructive: #d45d53;
--color-destructive-muted: #3b2421;
--color-success: #4cba8f;
--color-success-muted: #173a2e;
--color-warning: #d9a94f;
--color-warning-muted: #3b3018;
--color-danger-muted: #3b2421;
--color-success: #4cba8f;
--color-success-muted: #173a2e;
--color-warning: #d9a94f;
--color-warning-muted: #3b3018;
--color-danger-muted: #3b2421;
/* Navigation chrome (--color-nav-*) is already dark in both schemes. */
/* Navigation chrome (--color-nav-*) is already dark in both schemes. */
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.35), 0 1px 6px rgb(0 0 0 / 0.3);
--shadow-dialog: 0 24px 80px rgb(0 0 0 / 0.55);
}
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.35), 0 1px 6px rgb(0 0 0 / 0.3);
--shadow-dialog: 0 24px 80px rgb(0 0 0 / 0.55);
}
}