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:
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user