1
0

feat: Dashboard 主题模式切换 — 系统跟随/明亮/黑暗,localStorage 持久化,TDesign theme-mode 驱动

新增 useThemePreference hook 和纯工具函数,支持系统/明亮/黑暗三态主题选择、
matchMedia 系统主题跟随、localStorage 持久化和启动期主题预应用,通过 <html
theme-mode> 驱动 TDesign 主题变量切换。

Header 右侧控件重新组织为 .dashboard-header-controls 单行桌面布局,主题
RadioGroup 位于刷新频率 RadioGroup 前。

附带:build.ts import specifier 改为跨平台 sep 转换;config-loader 测试适配
Windows PATH 和 YAML 路径转义;test-utils 类型窄化修复。
This commit is contained in:
2026-05-15 22:18:29 +08:00
parent 8793fbd786
commit c46ab14cce
14 changed files with 419 additions and 66 deletions

View File

@@ -156,7 +156,7 @@ describe("loadConfig", () => {
expect(t.cmd.args).toEqual(["nginx"]);
expect(t.cmd.cwd).toBe(subdir);
expect(t.cmd.maxOutputBytes).toBe(104857600);
expect(t.cmd.env["PATH"]).toBeDefined();
expect(Object.keys(t.cmd.env).some((key) => key.toUpperCase() === "PATH")).toBe(true);
});
test("解析完整配置", async () => {
@@ -234,7 +234,7 @@ targets:
await writeFile(
configPath,
`server:
dataDir: "${dataDir}"
dataDir: ${JSON.stringify(dataDir)}
targets:
- name: "test"
type: http
@@ -609,7 +609,7 @@ targets:
const t = config.targets[0] as ResolvedCommandTarget;
expect(t.cmd.env["LANG"]).toBe("C");
expect(t.cmd.env["CUSTOM_VAR"]).toBe("test");
expect(t.cmd.env["PATH"]).toBeDefined();
expect(Object.keys(t.cmd.env).some((key) => key.toUpperCase() === "PATH")).toBe(true);
});
test("解析 group 字段", async () => {

View File

@@ -3,14 +3,14 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import "../../../tests/web/test-utils";
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
import { App } from "../../../src/web/app";
import { THEME_MEDIA_QUERY, THEME_PREFERENCE_STORAGE_KEY } from "../../../src/web/hooks/use-theme-preference";
// Mock hooks
void vi.mock("../../../src/web/hooks/use-queries", () => ({
useDashboard: vi.fn(() => ({
function createDashboardResult(overrides = {}) {
return {
data: {
summary: {
down: 0,
@@ -31,7 +31,43 @@ void vi.mock("../../../src/web/hooks/use-queries", () => ({
isFetching: false,
isLoading: false,
refetch: vi.fn(),
})),
...overrides,
};
}
function installMatchMedia(initialMatches: boolean) {
const originalMatchMedia = window.matchMedia;
let matches = initialMatches;
const listeners = new Set<(event: MediaQueryListEvent) => void>();
const mediaQueryList = {
addEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => listeners.add(listener),
addListener: (listener: (event: MediaQueryListEvent) => void) => listeners.add(listener),
dispatchEvent: () => true,
get matches() {
return matches;
},
media: THEME_MEDIA_QUERY,
onchange: null,
removeEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener),
removeListener: (listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener),
} as MediaQueryList;
window.matchMedia = () => mediaQueryList;
return {
restore: () => {
window.matchMedia = originalMatchMedia;
},
setMatches: (nextMatches: boolean) => {
matches = nextMatches;
listeners.forEach((listener) => listener({ matches, media: THEME_MEDIA_QUERY } as MediaQueryListEvent));
},
};
}
// Mock hooks
void vi.mock("../../../src/web/hooks/use-queries", () => ({
useDashboard: vi.fn(() => createDashboardResult()),
useMeta: vi.fn(() => ({
data: { checkerTypes: ["http", "cmd"] },
})),
@@ -61,6 +97,20 @@ void vi.mock("../../../src/web/hooks/use-target-detail", () => ({
}));
describe("App", () => {
let matchMediaController: ReturnType<typeof installMatchMedia>;
beforeEach(() => {
const { useDashboard } = require("../../../src/web/hooks/use-queries");
useDashboard.mockReturnValue(createDashboardResult());
window.localStorage.clear();
document.documentElement.removeAttribute("theme-mode");
matchMediaController = installMatchMedia(false);
});
afterEach(() => {
matchMediaController?.restore();
});
test("渲染不崩溃", () => {
const { container } = render(<App />);
expect(container.firstChild).not.toBeNull();
@@ -68,14 +118,16 @@ describe("App", () => {
test("loading 状态不崩溃", () => {
const { useDashboard } = require("../../../src/web/hooks/use-queries");
useDashboard.mockReturnValue({
data: null,
dataUpdatedAt: 0,
error: null,
isFetching: true,
isLoading: true,
refetch: vi.fn(),
});
useDashboard.mockReturnValue(
createDashboardResult({
data: null,
dataUpdatedAt: 0,
error: null,
isFetching: true,
isLoading: true,
refetch: vi.fn(),
}),
);
const { container } = render(<App />);
expect(container.firstChild).not.toBeNull();
@@ -83,14 +135,16 @@ describe("App", () => {
test("错误状态不崩溃", () => {
const { useDashboard } = require("../../../src/web/hooks/use-queries");
useDashboard.mockReturnValue({
data: null,
dataUpdatedAt: 0,
error: { message: "Network error" },
isFetching: false,
isLoading: false,
refetch: vi.fn(),
});
useDashboard.mockReturnValue(
createDashboardResult({
data: null,
dataUpdatedAt: 0,
error: { message: "Network error" },
isFetching: false,
isLoading: false,
refetch: vi.fn(),
}),
);
const { container } = render(<App />);
expect(container.firstChild).not.toBeNull();
@@ -98,30 +152,60 @@ describe("App", () => {
test("有数据状态不崩溃", () => {
const { useDashboard } = require("../../../src/web/hooks/use-queries");
useDashboard.mockReturnValue({
data: {
summary: {
down: 1,
incidents: 0,
lastCheckTime: "2025-01-15T10:00:00.000Z",
total: 2,
up: 1,
window: {
from: "2025-01-14T10:00:00.000Z",
label: "24h",
to: "2025-01-15T10:00:00.000Z",
useDashboard.mockReturnValue(
createDashboardResult({
data: {
summary: {
down: 1,
incidents: 0,
lastCheckTime: "2025-01-15T10:00:00.000Z",
total: 2,
up: 1,
window: {
from: "2025-01-14T10:00:00.000Z",
label: "24h",
to: "2025-01-15T10:00:00.000Z",
},
},
targets: [],
},
targets: [],
},
dataUpdatedAt: Date.now(),
error: null,
isFetching: false,
isLoading: false,
refetch: vi.fn(),
});
dataUpdatedAt: Date.now(),
error: null,
isFetching: false,
isLoading: false,
refetch: vi.fn(),
}),
);
const { container } = render(<App />);
expect(container.firstChild).not.toBeNull();
});
test("默认渲染主题模式选项并按系统亮色应用主题", async () => {
render(<App />);
expect(screen.getByText("系统")).not.toBeNull();
expect(screen.getByText("明亮")).not.toBeNull();
expect(screen.getByText("黑暗")).not.toBeNull();
await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("light"));
});
test("切换黑暗模式后写入本地存储并应用主题", async () => {
render(<App />);
fireEvent.click(screen.getByText("黑暗"));
expect(window.localStorage.getItem(THEME_PREFERENCE_STORAGE_KEY)).toBe("dark");
await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("dark"));
});
test("刷新后恢复已保存的主题偏好", async () => {
window.localStorage.setItem(THEME_PREFERENCE_STORAGE_KEY, "dark");
render(<App />);
await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("dark"));
});
test("系统模式响应 matchMedia 变化", async () => {
render(<App />);
await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("light"));
act(() => matchMediaController.setMatches(true));
await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("dark"));
});
});

View File

@@ -0,0 +1,83 @@
import { describe, expect, test } from "bun:test";
import {
applyThemeMode,
parseThemePreference,
readThemePreference,
resolveEffectiveTheme,
THEME_PREFERENCE_STORAGE_KEY,
writeThemePreference,
} from "../../../src/web/hooks/use-theme-preference";
function createMemoryStorage(initialValue?: string): Storage {
const data = new Map<string, string>();
if (initialValue !== undefined) data.set(THEME_PREFERENCE_STORAGE_KEY, initialValue);
return {
clear: () => data.clear(),
getItem: (key: string) => data.get(key) ?? null,
key: (index: number) => Array.from(data.keys())[index] ?? null,
get length() {
return data.size;
},
removeItem: (key: string) => void data.delete(key),
setItem: (key: string, value: string) => void data.set(key, value),
};
}
function createThrowingStorage(): Storage {
return {
clear: () => {
throw new Error("storage unavailable");
},
getItem: () => {
throw new Error("storage unavailable");
},
key: () => {
throw new Error("storage unavailable");
},
get length(): number {
throw new Error("storage unavailable");
},
removeItem: () => {
throw new Error("storage unavailable");
},
setItem: () => {
throw new Error("storage unavailable");
},
};
}
describe("use-theme-preference 工具函数", () => {
test("解析有效主题偏好并对非法值回退为系统", () => {
expect(parseThemePreference("system")).toBe("system");
expect(parseThemePreference("light")).toBe("light");
expect(parseThemePreference("dark")).toBe("dark");
expect(parseThemePreference("unknown")).toBe("system");
expect(parseThemePreference(null)).toBe("system");
});
test("根据系统模式计算有效主题", () => {
expect(resolveEffectiveTheme("system", true)).toBe("dark");
expect(resolveEffectiveTheme("system", false)).toBe("light");
expect(resolveEffectiveTheme("light", true)).toBe("light");
expect(resolveEffectiveTheme("dark", false)).toBe("dark");
});
test("读取本地存储偏好并在非法值时回退", () => {
expect(readThemePreference(createMemoryStorage("dark"))).toBe("dark");
expect(readThemePreference(createMemoryStorage("bad-value"))).toBe("system");
});
test("本地存储不可用时读取和写入均不抛错", () => {
const storage = createThrowingStorage();
expect(readThemePreference(storage)).toBe("system");
expect(() => writeThemePreference("dark", storage)).not.toThrow();
});
test("应用有效主题到指定根元素", () => {
const root = document.createElement("html");
applyThemeMode("dark", root);
expect(root.getAttribute("theme-mode")).toBe("dark");
});
});

View File

@@ -41,9 +41,8 @@ export const testHelpers = {
};
},
toHaveTextContent: (element: Element | null, text: RegExp | string) => {
const pass =
element?.textContent !== null &&
(typeof text === "string" ? element.textContent.includes(text) : text.test(element.textContent));
const content = element?.textContent ?? "";
const pass = element !== null && (typeof text === "string" ? content.includes(text) : text.test(content));
return {
message: () => (pass ? `Expected element not to have text "${text}"` : `Expected element to have text "${text}"`),
pass,