feat(web): follow system dark mode and simplify workspace hint
This commit is contained in:
@@ -203,6 +203,6 @@ bash scripts/smoke-image.sh "$RUNTIME_IMAGE_ID" # 已由管理员构建的本
|
|||||||
|
|
||||||
构建历史接口现默认分页(20条,最大100条),列表不再携带日志正文;日志通过 `/api/builds/{id}` 按需读取。升级面板后请刷新已打开的旧页面;外部脚本需按 API.md 更新,不能再依赖 `/api/builds` 返回全部历史或日志。
|
构建历史接口现默认分页(20条,最大100条),列表不再携带日志正文;日志通过 `/api/builds/{id}` 按需读取。升级面板后请刷新已打开的旧页面;外部脚本需按 API.md 更新,不能再依赖 `/api/builds` 返回全部历史或日志。
|
||||||
|
|
||||||
合理替代方案为:stdlib HTTP Docker client而非官方SDK;每实例独立bridge而非共享bridge;服务端保管DSH cookie而非下发浏览器。前端已改用 Tailwind CSS + Radix 原语的 shadcn 风格组件(保留本地Monaco),桌面与移动端共用一套响应式界面。这些是选型差异,不降低功能、安全或验收标准,详见验收表。
|
合理替代方案为:stdlib HTTP Docker client而非官方SDK;每实例独立bridge而非共享bridge;服务端保管DSH cookie而非下发浏览器。前端已改用 Tailwind CSS + Radix 原语的 shadcn 风格组件(保留本地Monaco),桌面与移动端共用一套响应式界面。界面跟随系统深浅色设置自动切换夜间模式(含 Monaco 编辑器),与内嵌 DSH 行为一致,无独立开关。这些是选型差异,不降低功能、安全或验收标准,详见验收表。
|
||||||
|
|
||||||
同源路径代理仅适用于可信用户/插件,尚无独立origin及宿主/局域网出站防护。默认软配额不能阻止agent直接写满磁盘;硬quota是依赖宿主的可选增强,不是默认路线必做项。升级/恢复快照需额外容量,home快照上限1 GiB,模板7的 `/workspace` 就是实例home,会随home快照回滚;旧共享目录不随回滚恢复;中断及回滚失败仍可能需要管理员恢复。
|
同源路径代理仅适用于可信用户/插件,尚无独立origin及宿主/局域网出站防护。默认软配额不能阻止agent直接写满磁盘;硬quota是依赖宿主的可选增强,不是默认路线必做项。升级/恢复快照需额外容量,home快照上限1 GiB,模板7的 `/workspace` 就是实例home,会随home快照回滚;旧共享目录不随回滚恢复;中断及回滚失败仍可能需要管理员恢复。
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
<!doctype html>
|
<!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" 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>
|
<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>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react';
|
|||||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||||
import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution';
|
import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution';
|
||||||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||||
|
import { useNightMode } from './nightMode';
|
||||||
|
|
||||||
// Vite emits this worker locally; no AMD loader or CDN is involved.
|
// Vite emits this worker locally; no AMD loader or CDN is involved.
|
||||||
self.MonacoEnvironment = { getWorker: () => new EditorWorker() };
|
self.MonacoEnvironment = { getWorker: () => new EditorWorker() };
|
||||||
@@ -21,6 +22,7 @@ export default function MonacoProfileEditor({ value, onChange, readOnly = false,
|
|||||||
change.current = onChange;
|
change.current = onChange;
|
||||||
const initialValue = useRef(value);
|
const initialValue = useRef(value);
|
||||||
const updating = useRef(false);
|
const updating = useRef(false);
|
||||||
|
const dark = useNightMode();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!host.current) return;
|
if (!host.current) return;
|
||||||
const model = monaco.editor.createModel(initialValue.current, 'yaml');
|
const model = monaco.editor.createModel(initialValue.current, 'yaml');
|
||||||
@@ -28,6 +30,7 @@ export default function MonacoProfileEditor({ value, onChange, readOnly = false,
|
|||||||
model, automaticLayout: true, minimap: { enabled: false },
|
model, automaticLayout: true, minimap: { enabled: false },
|
||||||
scrollBeyondLastLine: false, tabSize: 2, insertSpaces: true,
|
scrollBeyondLastLine: false, tabSize: 2, insertSpaces: true,
|
||||||
wordWrap: 'on', ariaLabel: label, readOnly,
|
wordWrap: 'on', ariaLabel: label, readOnly,
|
||||||
|
theme: dark ? 'vs-dark' : 'vs',
|
||||||
});
|
});
|
||||||
editor.current = instance;
|
editor.current = instance;
|
||||||
const listener = instance.onDidChangeModelContent(() => {
|
const listener = instance.onDidChangeModelContent(() => {
|
||||||
@@ -44,5 +47,6 @@ export default function MonacoProfileEditor({ value, onChange, readOnly = false,
|
|||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
useEffect(() => { editor.current?.updateOptions({ readOnly, ariaLabel: label }); }, [readOnly, label]);
|
useEffect(() => { editor.current?.updateOptions({ readOnly, ariaLabel: label }); }, [readOnly, label]);
|
||||||
|
useEffect(() => { monaco.editor.setTheme(dark ? 'vs-dark' : 'vs'); }, [dark]);
|
||||||
return <div role="group" aria-label={label} ref={host} style={{ height: 420, minWidth: 0, border: '1px solid var(--color-border, #d5dbe5)', borderRadius: 8, overflow: 'hidden' }} />;
|
return <div role="group" aria-label={label} ref={host} style={{ height: 420, minWidth: 0, border: '1px solid var(--color-border, #d5dbe5)', borderRadius: 8, overflow: 'hidden' }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function RuntimeWorkspace({ runtime, visible, manage, settings }: {
|
|||||||
</div>}
|
</div>}
|
||||||
{selected?.status === 'running' && <div className="flex min-h-0 flex-1 flex-col">
|
{selected?.status === 'running' && <div className="flex min-h-0 flex-1 flex-col">
|
||||||
{frameError && <div className="shrink-0 px-3 pt-2"><Alert>DSH 页面加载失败或登录已失效,请重新加载或在新窗口打开。</Alert></div>}
|
{frameError && <div className="shrink-0 px-3 pt-2"><Alert>DSH 页面加载失败或登录已失效,请重新加载或在新窗口打开。</Alert></div>}
|
||||||
<iframe className="block min-h-0 w-full flex-1 border-0 bg-white" key={selected.id + ':' + revision} src={url} title={'DSH · ' + selected.name} referrerPolicy="same-origin" onError={() => setFrameError(true)} onLoad={event => {
|
<iframe className="block min-h-0 w-full flex-1 border-0 bg-card" key={selected.id + ':' + revision} src={url} title={'DSH · ' + selected.name} referrerPolicy="same-origin" onError={() => setFrameError(true)} onLoad={event => {
|
||||||
try {
|
try {
|
||||||
const doc = event.currentTarget.contentDocument;
|
const doc = event.currentTarget.contentDocument;
|
||||||
const path = event.currentTarget.contentWindow?.location.pathname;
|
const path = event.currentTarget.contentWindow?.location.pathname;
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function WorkspaceFiles({instanceId, instanceName, controls, path, setPath}: Fil
|
|||||||
<details className="mt-2 text-[13px] text-muted-foreground">
|
<details className="mt-2 text-[13px] text-muted-foreground">
|
||||||
<summary className="cursor-pointer select-none font-medium text-primary">操作须知与敏感文件提醒</summary>
|
<summary className="cursor-pointer select-none font-medium text-primary">操作须知与敏感文件提醒</summary>
|
||||||
<div className="mt-2 grid max-w-2xl gap-1.5">
|
<div className="mt-2 grid max-w-2xl gap-1.5">
|
||||||
<p className="m-0">实例数据目录为 /workspace(模板 7);历史镜像的路径可能不同。这里管理文件,不会切换 DSH 会话的工作目录。</p>
|
<p className="m-0">实例数据目录为 /workspace;历史镜像的路径可能不同。这里管理文件,不会切换 DSH 会话的工作目录。</p>
|
||||||
<p className="m-0">包含配置和密钥,请谨慎操作。{homeAccessNotice}</p>
|
<p className="m-0">包含配置和密钥,请谨慎操作。{homeAccessNotice}</p>
|
||||||
<p className="m-0">下载确认及文件名识别仅为操作提醒,不是权限边界;右键打开下载链接等方式可能绕过提示。</p>
|
<p className="m-0">下载确认及文件名识别仅为操作提醒,不是权限边界;右键打开下载链接等方式可能绕过提示。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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()}; });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const query = () => window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
|
/** Tracks the OS dark-scheme preference, matching the automatic night mode of embedded DSH. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -51,6 +51,48 @@
|
|||||||
--touch-min: 2.75rem;
|
--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;
|
||||||
|
|
||||||
|
--color-primary-hover: #15a192;
|
||||||
|
|
||||||
|
--color-secondary: #1e343c;
|
||||||
|
--color-secondary-foreground: #7fd8c5;
|
||||||
|
|
||||||
|
--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;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
Reference in New Issue
Block a user