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:
@@ -71,9 +71,10 @@ src/
|
|||||||
target-table-filters.ts 表格筛选器
|
target-table-filters.ts 表格筛选器
|
||||||
target-table-sorters.ts 表格排序器
|
target-table-sorters.ts 表格排序器
|
||||||
color-threshold.ts 可用率颜色阈值函数
|
color-threshold.ts 可用率颜色阈值函数
|
||||||
hooks/ TanStack Query 数据层
|
hooks/ React hooks(数据查询、Drawer 状态、浏览器 UI 偏好)
|
||||||
use-queries.ts 全局面板查询 hook(dashboard/meta/metrics)
|
use-queries.ts 全局面板查询 hook(dashboard/meta/metrics)
|
||||||
use-target-detail.ts 目标详情 Drawer 状态与条件查询 hook
|
use-target-detail.ts 目标详情 Drawer 状态与条件查询 hook
|
||||||
|
use-theme-preference.ts 主题模式偏好、本地存储和 TDesign theme-mode 应用 hook
|
||||||
utils/ 前端工具函数
|
utils/ 前端工具函数
|
||||||
time.ts 时间处理(subtractHours、相对时间、动态时长单位)
|
time.ts 时间处理(subtractHours、相对时间、动态时长单位)
|
||||||
scripts/ 构建、schema 生成和清理脚本
|
scripts/ 构建、schema 生成和清理脚本
|
||||||
@@ -554,6 +555,7 @@ main.tsx
|
|||||||
└── ErrorBoundary(React 错误边界)
|
└── ErrorBoundary(React 错误边界)
|
||||||
└── QueryClientProvider(TanStack Query 全局挂载)
|
└── QueryClientProvider(TanStack Query 全局挂载)
|
||||||
├── App(根组件,Layout + HeadMenu 骨架)
|
├── App(根组件,Layout + HeadMenu 骨架)
|
||||||
|
│ ├── useThemePreference() ─── Header 主题模式 RadioGroup(系统/明亮/黑暗,本地存储记忆 + theme-mode 应用)
|
||||||
│ ├── useDashboard(refreshInterval) ─── GET /api/dashboard?window=24h&recentLimit=30(动态刷新间隔,RadioGroup 频率选择 + 倒计时/手动刷新按钮)
|
│ ├── useDashboard(refreshInterval) ─── GET /api/dashboard?window=24h&recentLimit=30(动态刷新间隔,RadioGroup 频率选择 + 倒计时/手动刷新按钮)
|
||||||
│ ├── SummaryCards(单 Card 内嵌居中 Statistic,无 shadow)
|
│ ├── SummaryCards(单 Card 内嵌居中 Statistic,无 shadow)
|
||||||
│ └── TargetBoard(目标列表,Space 24px 间距)
|
│ └── TargetBoard(目标列表,Space 24px 间距)
|
||||||
@@ -569,7 +571,7 @@ main.tsx
|
|||||||
└── ReactQueryDevtools(开发工具,仅开发环境)
|
└── ReactQueryDevtools(开发工具,仅开发环境)
|
||||||
```
|
```
|
||||||
|
|
||||||
**数据层架构**:
|
**Hook 架构**:
|
||||||
|
|
||||||
```
|
```
|
||||||
hooks/use-queries.ts(全局面板级查询)
|
hooks/use-queries.ts(全局面板级查询)
|
||||||
@@ -583,6 +585,12 @@ hooks/use-target-detail.ts(Drawer 状态与详情级条件查询)
|
|||||||
├── activeTab 受控 Tabs 状态(每次 openDrawer 重置为 overview)
|
├── activeTab 受控 Tabs 状态(每次 openDrawer 重置为 overview)
|
||||||
├── useTargetMetrics(/api/targets/:id/metrics)(条件查询:enabled 仅当 Drawer 打开且时间范围有效)
|
├── useTargetMetrics(/api/targets/:id/metrics)(条件查询:enabled 仅当 Drawer 打开且时间范围有效)
|
||||||
└── useQuery(/api/targets/:id/history)(条件查询:enabled 仅当 Drawer 打开 + 时间范围有效 + activeTab=history)
|
└── useQuery(/api/targets/:id/history)(条件查询:enabled 仅当 Drawer 打开 + 时间范围有效 + activeTab=history)
|
||||||
|
|
||||||
|
hooks/use-theme-preference.ts(浏览器 UI 偏好)
|
||||||
|
├── ThemePreference: system / light / dark(RadioGroup 受控值)
|
||||||
|
├── EffectiveTheme: light / dark(写入 document.documentElement theme-mode)
|
||||||
|
├── localStorage key: dial.theme.preference(同一浏览器记忆)
|
||||||
|
└── matchMedia("(prefers-color-scheme: dark)")(系统模式下跟随系统明暗变化)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.3 TanStack Query 数据层
|
### 2.3 TanStack Query 数据层
|
||||||
@@ -683,7 +691,7 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
|
|||||||
|
|
||||||
| 组件 | 文件 | 用途 |
|
| 组件 | 文件 | 用途 |
|
||||||
| -------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
| -------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||||
| `App` | `app.tsx` | 根组件,Layout + HeadMenu 骨架、刷新倒计时、Skeleton 加载 |
|
| `App` | `app.tsx` | 根组件,Layout + HeadMenu 骨架、主题模式选择、刷新倒计时、Skeleton 加载 |
|
||||||
| `ErrorBoundary` | `components/ErrorBoundary.tsx` | React 错误边界,捕获渲染异常并展示降级 UI |
|
| `ErrorBoundary` | `components/ErrorBoundary.tsx` | React 错误边界,捕获渲染异常并展示降级 UI |
|
||||||
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(单 Card 内嵌居中 Statistic,无 shadow) |
|
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(单 Card 内嵌居中 Statistic,无 shadow) |
|
||||||
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表(Space 24px 间距) |
|
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表(Space 24px 间距) |
|
||||||
@@ -728,7 +736,7 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
|
|||||||
**styles.css 组织**:
|
**styles.css 组织**:
|
||||||
|
|
||||||
- 自定义 CSS 变量(如可用率渐变色 `--avail-0` ~ `--avail-9`)定义在 `:root` 中
|
- 自定义 CSS 变量(如可用率渐变色 `--avail-0` ~ `--avail-9`)定义在 `:root` 中
|
||||||
- 布局类(`.dashboard`、`.dashboard-header`)定义全局页面结构
|
- 布局类(`.dashboard`、`.dashboard-header-controls`)定义全局页面结构和 Header 右侧单行操作区
|
||||||
- 组件修饰类(`.status-dot--up`、`.latency-ok`)为自定义视觉组件提供样式变体
|
- 组件修饰类(`.status-dot--up`、`.latency-ok`)为自定义视觉组件提供样式变体
|
||||||
- TDesign 表格行高亮(`.row-down`)通过 `rowClassName` prop 应用
|
- TDesign 表格行高亮(`.row-down`)通过 `rowClassName` prop 应用
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# DiAL
|
# DiAL
|
||||||
|
|
||||||
基于 Bun + TypeScript 的多类型拨测监控工具。通过 YAML 配置文件定义 HTTP 和命令行拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite,前端 Dashboard 展示各目标实时状态、可用率、耗时趋势等,并支持手动、10 秒、30 秒、1 分钟、5 分钟刷新频率切换。
|
基于 Bun + TypeScript 的多类型拨测监控工具。通过 YAML 配置文件定义 HTTP 和命令行拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite,前端 Dashboard 展示各目标实时状态、可用率、耗时趋势等,并支持手动、10 秒、30 秒、1 分钟、5 分钟刷新频率切换,以及系统、明亮、黑暗三种主题模式。主题模式选择会保存在当前浏览器本地存储中,同一浏览器再次访问时自动恢复。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
|||||||
@@ -73,9 +73,13 @@ styles.css SHALL 定义前端组件复用的工具类,包含页面布局相关
|
|||||||
- **WHEN** HeadMenu logo 区域渲染品牌名和副标题
|
- **WHEN** HeadMenu logo 区域渲染品牌名和副标题
|
||||||
- **THEN** 品牌 SHALL 使用 `.dashboard-brand` 类(display: inline-flex; align-items: baseline; gap: var(--td-comp-margin-s)),品牌名 SHALL 使用 `.dashboard-logo` 类(font-size: calc(var(--td-font-size-title-large) + 6px); font-weight: 700),副标题 SHALL 使用 `.dashboard-subtitle` 类(font-size: var(--td-font-size-body-medium); color: var(--td-text-color-secondary))
|
- **THEN** 品牌 SHALL 使用 `.dashboard-brand` 类(display: inline-flex; align-items: baseline; gap: var(--td-comp-margin-s)),品牌名 SHALL 使用 `.dashboard-logo` 类(font-size: calc(var(--td-font-size-title-large) + 6px); font-weight: 700),副标题 SHALL 使用 `.dashboard-subtitle` 类(font-size: var(--td-font-size-body-medium); color: var(--td-text-color-secondary))
|
||||||
|
|
||||||
#### Scenario: 刷新控制区域类
|
#### Scenario: Header 右侧操作区类
|
||||||
- **WHEN** HeadMenu operations 区域渲染刷新频率选择器和倒计时/按钮
|
- **WHEN** HeadMenu operations 区域渲染主题模式选择器、刷新频率选择器和倒计时/按钮
|
||||||
- **THEN** 容器 SHALL 使用 `.dashboard-refresh-control` 类(display: inline-flex; align-items: center; gap: var(--td-comp-margin-s); margin-right: var(--td-comp-margin-xxl))
|
- **THEN** 容器 SHALL 使用 `.dashboard-header-controls` 类(display: inline-flex; align-items: center; gap: var(--td-comp-margin-s); margin-right: var(--td-comp-margin-xxl))
|
||||||
|
|
||||||
|
#### Scenario: Header 右侧操作区单行布局
|
||||||
|
- **WHEN** Header 右侧操作区渲染
|
||||||
|
- **THEN** `.dashboard-header-controls` SHALL 保持桌面单行水平布局,不为该区域新增窄屏换行或收纳规则
|
||||||
|
|
||||||
#### Scenario: 倒计时文本类
|
#### Scenario: 倒计时文本类
|
||||||
- **WHEN** 倒计时文本或刷新按钮渲染
|
- **WHEN** 倒计时文本或刷新按钮渲染
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
定义 Dashboard 页面骨架布局:顶部导航栏(含品牌标识和刷新频率选择器/倒计时控件)、内容区域居中与最大宽度、页面背景色。
|
定义 Dashboard 页面骨架布局:顶部导航栏(含品牌标识、主题模式选择器、刷新频率选择器和倒计时控件)、内容区域居中与最大宽度、页面背景色。
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -13,13 +13,17 @@ Dashboard SHALL 使用 TDesign Layout 组件体系构建页面骨架,包含顶
|
|||||||
|
|
||||||
#### Scenario: 顶部导航栏
|
#### Scenario: 顶部导航栏
|
||||||
- **WHEN** Dashboard 页面渲染
|
- **WHEN** Dashboard 页面渲染
|
||||||
- **THEN** `Layout.Header` SHALL 内嵌 TDesign `HeadMenu` 组件,`logo` prop 渲染品牌名 "DiAL" 和副标题 "统一拨测平台"(水平排列),`operations` prop 渲染刷新频率选择器和倒计时/刷新按钮组合控件
|
- **THEN** `Layout.Header` SHALL 内嵌 TDesign `HeadMenu` 组件,`logo` prop 渲染品牌名 "DiAL" 和副标题 "统一拨测平台"(水平排列),`operations` prop 渲染主题模式选择器、刷新频率选择器和倒计时/刷新按钮组合控件
|
||||||
|
|
||||||
#### Scenario: 刷新控制区域
|
#### Scenario: Header 右侧操作区
|
||||||
- **WHEN** Dashboard 页面渲染
|
- **WHEN** Dashboard 页面渲染
|
||||||
- **THEN** HeadMenu operations 区域 SHALL 包含 RadioGroup 刷新频率选择器和倒计时文本(或手动刷新按钮),两者水平排列并垂直居中
|
- **THEN** HeadMenu operations 区域 SHALL 包含主题模式 RadioGroup、刷新频率 RadioGroup 和倒计时文本(或手动刷新按钮),三者水平排列并垂直居中
|
||||||
|
|
||||||
#### Scenario: 刷新控制区域位置
|
#### Scenario: 主题选择器位置
|
||||||
|
- **WHEN** HeadMenu operations 区域渲染
|
||||||
|
- **THEN** 主题模式 RadioGroup SHALL 位于刷新频率 RadioGroup 前面
|
||||||
|
|
||||||
|
#### Scenario: Header 右侧操作区位置
|
||||||
- **WHEN** HeadMenu 渲染
|
- **WHEN** HeadMenu 渲染
|
||||||
- **THEN** operations 区域 SHALL 使用右侧 margin 向内收缩,避免紧贴浏览器右边缘
|
- **THEN** operations 区域 SHALL 使用右侧 margin 向内收缩,避免紧贴浏览器右边缘
|
||||||
|
|
||||||
@@ -29,4 +33,4 @@ Dashboard SHALL 使用 TDesign Layout 组件体系构建页面骨架,包含顶
|
|||||||
|
|
||||||
#### Scenario: 页面背景色
|
#### Scenario: 页面背景色
|
||||||
- **WHEN** Dashboard 页面渲染
|
- **WHEN** Dashboard 页面渲染
|
||||||
- **THEN** 页面背景色 SHALL 使用 `var(--td-bg-color-page)`,内容卡片浮于浅灰背景之上
|
- **THEN** 页面背景色 SHALL 使用 `var(--td-bg-color-page)`,内容卡片浮于当前 TDesign 主题背景之上
|
||||||
|
|||||||
73
openspec/specs/theme-mode-preference/spec.md
Normal file
73
openspec/specs/theme-mode-preference/spec.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
定义 Dashboard 主题模式选择、系统主题跟随、浏览器本地持久化和 TDesign 主题变量应用行为。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 主题模式选择器
|
||||||
|
Dashboard SHALL 在 Header 右侧提供主题模式 RadioGroup,允许用户选择"系统""明亮""黑暗"三种模式。
|
||||||
|
|
||||||
|
#### Scenario: 主题模式选项渲染
|
||||||
|
- **WHEN** Dashboard 页面渲染
|
||||||
|
- **THEN** HeadMenu operations 区域 SHALL 在刷新频率选择器前显示 RadioGroup(theme="button", variant="default-filled"),选项为:系统、明亮、黑暗
|
||||||
|
|
||||||
|
#### Scenario: 默认选择系统
|
||||||
|
- **WHEN** 当前浏览器没有已保存的有效主题偏好
|
||||||
|
- **THEN** 主题模式 RadioGroup SHALL 默认选中"系统"
|
||||||
|
|
||||||
|
#### Scenario: 用户切换主题模式
|
||||||
|
- **WHEN** 用户点击"系统""明亮"或"黑暗"任一主题模式选项
|
||||||
|
- **THEN** RadioGroup SHALL 选中该选项,并触发对应主题模式生效
|
||||||
|
|
||||||
|
### Requirement: 主题模式生效
|
||||||
|
系统 SHALL 根据用户主题偏好计算有效主题,并通过 `<html>` 元素的 `theme-mode` 属性应用 TDesign 主题变量。
|
||||||
|
|
||||||
|
#### Scenario: 系统模式跟随暗色系统
|
||||||
|
- **WHEN** 用户主题偏好为"系统"且 `prefers-color-scheme: dark` 匹配
|
||||||
|
- **THEN** 系统 SHALL 设置 `document.documentElement` 的 `theme-mode` 属性为 `dark`
|
||||||
|
|
||||||
|
#### Scenario: 系统模式跟随亮色系统
|
||||||
|
- **WHEN** 用户主题偏好为"系统"且 `prefers-color-scheme: dark` 不匹配
|
||||||
|
- **THEN** 系统 SHALL 设置 `document.documentElement` 的 `theme-mode` 属性为 `light`
|
||||||
|
|
||||||
|
#### Scenario: 系统主题变化自动更新
|
||||||
|
- **WHEN** 用户主题偏好为"系统"且浏览器系统主题在明亮和黑暗之间变化
|
||||||
|
- **THEN** 系统 SHALL 自动更新 `theme-mode` 属性为新的有效主题
|
||||||
|
|
||||||
|
#### Scenario: 明亮模式固定主题
|
||||||
|
- **WHEN** 用户主题偏好为"明亮"
|
||||||
|
- **THEN** 系统 SHALL 设置 `theme-mode` 属性为 `light`,且系统主题变化 SHALL NOT 改变该属性
|
||||||
|
|
||||||
|
#### Scenario: 黑暗模式固定主题
|
||||||
|
- **WHEN** 用户主题偏好为"黑暗"
|
||||||
|
- **THEN** 系统 SHALL 设置 `theme-mode` 属性为 `dark`,且系统主题变化 SHALL NOT 改变该属性
|
||||||
|
|
||||||
|
### Requirement: 主题偏好本地持久化
|
||||||
|
系统 SHALL 将用户选择的主题偏好保存到当前浏览器本地存储,并在后续页面加载时恢复。
|
||||||
|
|
||||||
|
#### Scenario: 保存用户选择
|
||||||
|
- **WHEN** 用户切换主题模式
|
||||||
|
- **THEN** 系统 SHALL 将对应偏好值写入 `localStorage` 的 `dial.theme.preference` 键
|
||||||
|
|
||||||
|
#### Scenario: 恢复已保存偏好
|
||||||
|
- **WHEN** 页面加载且 `localStorage` 的 `dial.theme.preference` 键保存了有效偏好值
|
||||||
|
- **THEN** 系统 SHALL 使用该偏好初始化主题模式 RadioGroup 和有效主题
|
||||||
|
|
||||||
|
#### Scenario: 非法本地偏好回退
|
||||||
|
- **WHEN** 页面加载且 `dial.theme.preference` 保存了非 `system`、`light`、`dark` 的值
|
||||||
|
- **THEN** 系统 SHALL 忽略该值并按"系统"模式初始化
|
||||||
|
|
||||||
|
#### Scenario: 本地存储不可用
|
||||||
|
- **WHEN** 浏览器读取或写入 `localStorage` 抛出异常
|
||||||
|
- **THEN** Dashboard SHALL 继续正常渲染,并按内存中的主题偏好应用主题
|
||||||
|
|
||||||
|
### Requirement: 启动期主题恢复
|
||||||
|
系统 SHALL 在 React App 首次渲染前尽早应用一次有效主题,降低暗色环境下的亮色闪烁。
|
||||||
|
|
||||||
|
#### Scenario: 渲染前应用已保存偏好
|
||||||
|
- **WHEN** 前端入口初始化且浏览器已保存有效主题偏好
|
||||||
|
- **THEN** 系统 SHALL 在创建 React root 前根据该偏好设置 `<html>` 的 `theme-mode` 属性
|
||||||
|
|
||||||
|
#### Scenario: 渲染前应用系统偏好
|
||||||
|
- **WHEN** 前端入口初始化且浏览器没有有效主题偏好
|
||||||
|
- **THEN** 系统 SHALL 在创建 React root 前根据 `prefers-color-scheme: dark` 设置 `<html>` 的 `theme-mode` 属性
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { readdir, rm, writeFile } from "node:fs/promises";
|
import { readdir, rm, writeFile } from "node:fs/promises";
|
||||||
import { join, relative } from "node:path";
|
import { join, relative, sep } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
|
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||||
@@ -68,7 +68,7 @@ async function codeGeneration() {
|
|||||||
for (let i = 0; i < allFiles.length; i++) {
|
for (let i = 0; i < allFiles.length; i++) {
|
||||||
const urlPath = allFiles[i]!;
|
const urlPath = allFiles[i]!;
|
||||||
const varName = `f${i}`;
|
const varName = `f${i}`;
|
||||||
const filePath = relative(buildDir, join(distWebDir, urlPath.slice(1)));
|
const filePath = toImportSpecifier(buildDir, join(distWebDir, urlPath.slice(1)));
|
||||||
importLines.push(`import ${varName} from "./${filePath}" with { type: "file" };`);
|
importLines.push(`import ${varName} from "./${filePath}" with { type: "file" };`);
|
||||||
|
|
||||||
if (urlPath === "/index.html") {
|
if (urlPath === "/index.html") {
|
||||||
@@ -134,6 +134,10 @@ async function scanDir(dir: string, prefix: string): Promise<string[]> {
|
|||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toImportSpecifier(fromDir: string, targetPath: string) {
|
||||||
|
return relative(fromDir, targetPath).split(sep).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
async function viteBuild() {
|
async function viteBuild() {
|
||||||
console.log("Step 1/3: Vite build...");
|
console.log("Step 1/3: Vite build...");
|
||||||
const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], {
|
const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TargetBoard } from "./components/TargetBoard";
|
|||||||
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
|
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
|
||||||
import { useDashboard } from "./hooks/use-queries";
|
import { useDashboard } from "./hooks/use-queries";
|
||||||
import { useTargetDetail } from "./hooks/use-target-detail";
|
import { useTargetDetail } from "./hooks/use-target-detail";
|
||||||
|
import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference";
|
||||||
|
|
||||||
const { Content, Header } = Layout;
|
const { Content, Header } = Layout;
|
||||||
const DEFAULT_REFRESH_INTERVAL_MS = 30000;
|
const DEFAULT_REFRESH_INTERVAL_MS = 30000;
|
||||||
@@ -24,9 +25,15 @@ const REFRESH_OPTIONS = [
|
|||||||
{ label: "1分钟", value: 60000 },
|
{ label: "1分钟", value: 60000 },
|
||||||
{ label: "5分钟", value: 300000 },
|
{ label: "5分钟", value: 300000 },
|
||||||
] as const;
|
] as const;
|
||||||
|
const THEME_OPTIONS = [
|
||||||
|
{ label: "系统", value: "system" },
|
||||||
|
{ label: "明亮", value: "light" },
|
||||||
|
{ label: "黑暗", value: "dark" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [refreshInterval, setRefreshInterval] = useState(DEFAULT_REFRESH_INTERVAL_MS);
|
const [refreshInterval, setRefreshInterval] = useState(DEFAULT_REFRESH_INTERVAL_MS);
|
||||||
|
const { preference: themePreference, setPreference: setThemePreference } = useThemePreference();
|
||||||
const dashboardRefetchInterval = refreshInterval === 0 ? false : refreshInterval;
|
const dashboardRefetchInterval = refreshInterval === 0 ? false : refreshInterval;
|
||||||
const {
|
const {
|
||||||
data: dashboard,
|
data: dashboard,
|
||||||
@@ -58,6 +65,10 @@ export function App() {
|
|||||||
setRefreshInterval(value);
|
setRefreshInterval(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleThemeChange = (value: ThemePreference) => {
|
||||||
|
setThemePreference(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className="dashboard">
|
<Layout className="dashboard">
|
||||||
<Header>
|
<Header>
|
||||||
@@ -69,7 +80,14 @@ export function App() {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
operations={
|
operations={
|
||||||
<div className="dashboard-refresh-control">
|
<div className="dashboard-header-controls">
|
||||||
|
<RadioGroup
|
||||||
|
onChange={handleThemeChange}
|
||||||
|
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
|
||||||
|
theme="button"
|
||||||
|
value={themePreference}
|
||||||
|
variant="default-filled"
|
||||||
|
/>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
onChange={handleIntervalChange}
|
onChange={handleIntervalChange}
|
||||||
options={REFRESH_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
|
options={REFRESH_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
|
||||||
|
|||||||
73
src/web/hooks/use-theme-preference.ts
Normal file
73
src/web/hooks/use-theme-preference.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type EffectiveTheme = "dark" | "light";
|
||||||
|
export type ThemePreference = "dark" | "light" | "system";
|
||||||
|
|
||||||
|
export const THEME_PREFERENCE_STORAGE_KEY = "dial.theme.preference";
|
||||||
|
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
|
||||||
|
|
||||||
|
export function applyInitialThemePreference() {
|
||||||
|
applyThemeMode(resolveEffectiveTheme(readThemePreference(), getSystemPrefersDark()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyThemeMode(theme: EffectiveTheme, root: HTMLElement = document.documentElement) {
|
||||||
|
root.setAttribute("theme-mode", theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSystemPrefersDark(matchMedia: Window["matchMedia"] = window.matchMedia): boolean {
|
||||||
|
try {
|
||||||
|
return matchMedia(THEME_MEDIA_QUERY).matches;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseThemePreference(value: unknown): ThemePreference {
|
||||||
|
return value === "dark" || value === "light" || value === "system" ? value : "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readThemePreference(storage: Storage = window.localStorage): ThemePreference {
|
||||||
|
try {
|
||||||
|
return parseThemePreference(storage.getItem(THEME_PREFERENCE_STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEffectiveTheme(preference: ThemePreference, systemPrefersDark: boolean): EffectiveTheme {
|
||||||
|
if (preference === "dark" || preference === "light") return preference;
|
||||||
|
return systemPrefersDark ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThemePreference() {
|
||||||
|
const [preference, setPreferenceState] = useState<ThemePreference>(() => readThemePreference());
|
||||||
|
const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark());
|
||||||
|
const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyThemeMode(effectiveTheme);
|
||||||
|
}, [effectiveTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQueryList = window.matchMedia(THEME_MEDIA_QUERY);
|
||||||
|
|
||||||
|
const handleChange = (event: MediaQueryListEvent) => setSystemPrefersDark(event.matches);
|
||||||
|
mediaQueryList.addEventListener("change", handleChange);
|
||||||
|
return () => mediaQueryList.removeEventListener("change", handleChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPreference = (nextPreference: ThemePreference) => {
|
||||||
|
setPreferenceState(nextPreference);
|
||||||
|
writeThemePreference(nextPreference);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { effectiveTheme, preference, setPreference };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeThemePreference(preference: ThemePreference, storage: Storage = window.localStorage) {
|
||||||
|
try {
|
||||||
|
storage.setItem(THEME_PREFERENCE_STORAGE_KEY, preference);
|
||||||
|
} catch {
|
||||||
|
// 存储不可用时仅使用当前内存状态,避免阻断 Dashboard 渲染。
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
|
|
||||||
import { App } from "./app";
|
import { App } from "./app";
|
||||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
|
import { applyInitialThemePreference } from "./hooks/use-theme-preference";
|
||||||
import "tdesign-react/dist/reset.css";
|
import "tdesign-react/dist/reset.css";
|
||||||
import "tdesign-react/dist/tdesign.min.css";
|
import "tdesign-react/dist/tdesign.min.css";
|
||||||
|
|
||||||
@@ -26,6 +27,8 @@ if (!rootElement) {
|
|||||||
throw new Error("找不到前端挂载节点 #root");
|
throw new Error("找不到前端挂载节点 #root");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyInitialThemePreference();
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-refresh-control {
|
.dashboard-header-controls {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--td-comp-margin-s);
|
gap: var(--td-comp-margin-s);
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ describe("loadConfig", () => {
|
|||||||
expect(t.cmd.args).toEqual(["nginx"]);
|
expect(t.cmd.args).toEqual(["nginx"]);
|
||||||
expect(t.cmd.cwd).toBe(subdir);
|
expect(t.cmd.cwd).toBe(subdir);
|
||||||
expect(t.cmd.maxOutputBytes).toBe(104857600);
|
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 () => {
|
test("解析完整配置", async () => {
|
||||||
@@ -234,7 +234,7 @@ targets:
|
|||||||
await writeFile(
|
await writeFile(
|
||||||
configPath,
|
configPath,
|
||||||
`server:
|
`server:
|
||||||
dataDir: "${dataDir}"
|
dataDir: ${JSON.stringify(dataDir)}
|
||||||
targets:
|
targets:
|
||||||
- name: "test"
|
- name: "test"
|
||||||
type: http
|
type: http
|
||||||
@@ -609,7 +609,7 @@ targets:
|
|||||||
const t = config.targets[0] as ResolvedCommandTarget;
|
const t = config.targets[0] as ResolvedCommandTarget;
|
||||||
expect(t.cmd.env["LANG"]).toBe("C");
|
expect(t.cmd.env["LANG"]).toBe("C");
|
||||||
expect(t.cmd.env["CUSTOM_VAR"]).toBe("test");
|
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 () => {
|
test("解析 group 字段", async () => {
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
import "../../../tests/web/test-utils";
|
import "../../../tests/web/test-utils";
|
||||||
import { render } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, test, vi } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
|
||||||
|
|
||||||
import { App } from "../../../src/web/app";
|
import { App } from "../../../src/web/app";
|
||||||
|
import { THEME_MEDIA_QUERY, THEME_PREFERENCE_STORAGE_KEY } from "../../../src/web/hooks/use-theme-preference";
|
||||||
|
|
||||||
// Mock hooks
|
function createDashboardResult(overrides = {}) {
|
||||||
void vi.mock("../../../src/web/hooks/use-queries", () => ({
|
return {
|
||||||
useDashboard: vi.fn(() => ({
|
|
||||||
data: {
|
data: {
|
||||||
summary: {
|
summary: {
|
||||||
down: 0,
|
down: 0,
|
||||||
@@ -31,7 +31,43 @@ void vi.mock("../../../src/web/hooks/use-queries", () => ({
|
|||||||
isFetching: false,
|
isFetching: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
refetch: vi.fn(),
|
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(() => ({
|
useMeta: vi.fn(() => ({
|
||||||
data: { checkerTypes: ["http", "cmd"] },
|
data: { checkerTypes: ["http", "cmd"] },
|
||||||
})),
|
})),
|
||||||
@@ -61,6 +97,20 @@ void vi.mock("../../../src/web/hooks/use-target-detail", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("App", () => {
|
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("渲染不崩溃", () => {
|
test("渲染不崩溃", () => {
|
||||||
const { container } = render(<App />);
|
const { container } = render(<App />);
|
||||||
expect(container.firstChild).not.toBeNull();
|
expect(container.firstChild).not.toBeNull();
|
||||||
@@ -68,14 +118,16 @@ describe("App", () => {
|
|||||||
|
|
||||||
test("loading 状态不崩溃", () => {
|
test("loading 状态不崩溃", () => {
|
||||||
const { useDashboard } = require("../../../src/web/hooks/use-queries");
|
const { useDashboard } = require("../../../src/web/hooks/use-queries");
|
||||||
useDashboard.mockReturnValue({
|
useDashboard.mockReturnValue(
|
||||||
data: null,
|
createDashboardResult({
|
||||||
dataUpdatedAt: 0,
|
data: null,
|
||||||
error: null,
|
dataUpdatedAt: 0,
|
||||||
isFetching: true,
|
error: null,
|
||||||
isLoading: true,
|
isFetching: true,
|
||||||
refetch: vi.fn(),
|
isLoading: true,
|
||||||
});
|
refetch: vi.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const { container } = render(<App />);
|
const { container } = render(<App />);
|
||||||
expect(container.firstChild).not.toBeNull();
|
expect(container.firstChild).not.toBeNull();
|
||||||
@@ -83,14 +135,16 @@ describe("App", () => {
|
|||||||
|
|
||||||
test("错误状态不崩溃", () => {
|
test("错误状态不崩溃", () => {
|
||||||
const { useDashboard } = require("../../../src/web/hooks/use-queries");
|
const { useDashboard } = require("../../../src/web/hooks/use-queries");
|
||||||
useDashboard.mockReturnValue({
|
useDashboard.mockReturnValue(
|
||||||
data: null,
|
createDashboardResult({
|
||||||
dataUpdatedAt: 0,
|
data: null,
|
||||||
error: { message: "Network error" },
|
dataUpdatedAt: 0,
|
||||||
isFetching: false,
|
error: { message: "Network error" },
|
||||||
isLoading: false,
|
isFetching: false,
|
||||||
refetch: vi.fn(),
|
isLoading: false,
|
||||||
});
|
refetch: vi.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const { container } = render(<App />);
|
const { container } = render(<App />);
|
||||||
expect(container.firstChild).not.toBeNull();
|
expect(container.firstChild).not.toBeNull();
|
||||||
@@ -98,30 +152,60 @@ describe("App", () => {
|
|||||||
|
|
||||||
test("有数据状态不崩溃", () => {
|
test("有数据状态不崩溃", () => {
|
||||||
const { useDashboard } = require("../../../src/web/hooks/use-queries");
|
const { useDashboard } = require("../../../src/web/hooks/use-queries");
|
||||||
useDashboard.mockReturnValue({
|
useDashboard.mockReturnValue(
|
||||||
data: {
|
createDashboardResult({
|
||||||
summary: {
|
data: {
|
||||||
down: 1,
|
summary: {
|
||||||
incidents: 0,
|
down: 1,
|
||||||
lastCheckTime: "2025-01-15T10:00:00.000Z",
|
incidents: 0,
|
||||||
total: 2,
|
lastCheckTime: "2025-01-15T10:00:00.000Z",
|
||||||
up: 1,
|
total: 2,
|
||||||
window: {
|
up: 1,
|
||||||
from: "2025-01-14T10:00:00.000Z",
|
window: {
|
||||||
label: "24h",
|
from: "2025-01-14T10:00:00.000Z",
|
||||||
to: "2025-01-15T10:00:00.000Z",
|
label: "24h",
|
||||||
|
to: "2025-01-15T10:00:00.000Z",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
targets: [],
|
||||||
},
|
},
|
||||||
targets: [],
|
dataUpdatedAt: Date.now(),
|
||||||
},
|
error: null,
|
||||||
dataUpdatedAt: Date.now(),
|
isFetching: false,
|
||||||
error: null,
|
isLoading: false,
|
||||||
isFetching: false,
|
refetch: vi.fn(),
|
||||||
isLoading: false,
|
}),
|
||||||
refetch: vi.fn(),
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const { container } = render(<App />);
|
const { container } = render(<App />);
|
||||||
expect(container.firstChild).not.toBeNull();
|
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"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
83
tests/web/hooks/use-theme-preference.test.ts
Normal file
83
tests/web/hooks/use-theme-preference.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,9 +41,8 @@ export const testHelpers = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
toHaveTextContent: (element: Element | null, text: RegExp | string) => {
|
toHaveTextContent: (element: Element | null, text: RegExp | string) => {
|
||||||
const pass =
|
const content = element?.textContent ?? "";
|
||||||
element?.textContent !== null &&
|
const pass = element !== null && (typeof text === "string" ? content.includes(text) : text.test(content));
|
||||||
(typeof text === "string" ? element.textContent.includes(text) : text.test(element.textContent));
|
|
||||||
return {
|
return {
|
||||||
message: () => (pass ? `Expected element not to have text "${text}"` : `Expected element to have text "${text}"`),
|
message: () => (pass ? `Expected element not to have text "${text}"` : `Expected element to have text "${text}"`),
|
||||||
pass,
|
pass,
|
||||||
|
|||||||
Reference in New Issue
Block a user