From 31aeee6d600b68a01264336632ff38b61d434b39 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 13 May 2026 20:55:42 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=89=8D=E7=AB=AF=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E9=87=8D=E6=9E=84=20=E2=80=94=20hook=E6=8B=86?= =?UTF-8?q?=E5=88=86=E3=80=81=E7=BB=84=E4=BB=B6=E6=8B=86=E5=88=86=E3=80=81?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E7=AD=9B=E9=80=89=E5=99=A8=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E5=8C=96=E3=80=81Meta=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 GET /api/meta 端点(checkerRegistry.supportedTypes)及 MetaResponse 类型 - 前端 hook 拆分为 use-queries.ts(全局查询+useMeta)和 use-target-detail.ts(Drawer状态) - TargetDetailDrawer 拆分为 OverviewTab + HistoryTab + history-table-columns + stats.ts - 类型筛选器由 meta API 动态驱动,删除 target-type-display 静态映射 - 列定义改为工厂函数 createTargetTableColumns(checkerTypes),TargetGroup 新增 columns prop - 修复 StatusDonut key、StatusBar maxSlots prop、TrendChart 移除 loading prop - 补充 utils/time、utils/stats、动态列工厂测试,删除旧 mapping 测试 - 同步 delta specs 到主 specs,归档 frontend-architecture-refactor change --- DEVELOPMENT.md | 47 ++--- README.md | 5 +- .../.openspec.yaml | 2 - .../frontend-architecture-refactor/design.md | 145 ---------------- .../proposal.md | 34 ---- .../specs/tanstack-query-data-layer/spec.md | 109 ------------ .../specs/target-detail-drawer/spec.md | 91 ---------- .../specs/target-table/spec.md | 69 -------- .../specs/target-type-display/spec.md | 13 -- .../frontend-architecture-refactor/tasks.md | 48 ------ .../specs/meta-api/spec.md | 6 +- openspec/specs/probe-api/spec.md | 26 +++ .../specs/tanstack-query-data-layer/spec.md | 36 +++- openspec/specs/target-detail-drawer/spec.md | 63 ++++++- openspec/specs/target-table/spec.md | 38 ++++- openspec/specs/target-type-display/spec.md | 42 ----- src/server/app.ts | 11 ++ src/server/routes/meta.ts | 12 ++ src/shared/api.ts | 4 + src/web/app.tsx | 3 +- src/web/components/HistoryTab.tsx | 31 ++++ src/web/components/OverviewTab.tsx | 57 +++++++ src/web/components/StatusBar.tsx | 5 +- src/web/components/StatusDonut.tsx | 4 +- src/web/components/TargetBoard.tsx | 11 +- src/web/components/TargetDetailDrawer.tsx | 134 ++------------- src/web/components/TargetGroup.tsx | 8 +- src/web/components/TrendChart.tsx | 7 +- src/web/constants/history-table-columns.tsx | 42 +++++ src/web/constants/target-table-columns.tsx | 161 +++++++++--------- src/web/constants/target-table-filters.ts | 9 - src/web/constants/target-type-display.ts | 10 -- src/web/hooks/use-queries.ts | 41 +++++ ...seTargetDetail.ts => use-target-detail.ts} | 38 +---- src/web/utils/stats.ts | 23 +++ tests/server/app.test.ts | 34 +++- .../constants/target-table-columns.test.ts | 84 +++++++++ .../constants/target-table-filters.test.ts | 14 +- .../web/constants/target-type-display.test.ts | 40 ----- tests/web/utils/stats.test.ts | 29 ++++ tests/web/utils/time.test.ts | 29 ++++ 41 files changed, 713 insertions(+), 902 deletions(-) delete mode 100644 openspec/changes/frontend-architecture-refactor/.openspec.yaml delete mode 100644 openspec/changes/frontend-architecture-refactor/design.md delete mode 100644 openspec/changes/frontend-architecture-refactor/proposal.md delete mode 100644 openspec/changes/frontend-architecture-refactor/specs/tanstack-query-data-layer/spec.md delete mode 100644 openspec/changes/frontend-architecture-refactor/specs/target-detail-drawer/spec.md delete mode 100644 openspec/changes/frontend-architecture-refactor/specs/target-table/spec.md delete mode 100644 openspec/changes/frontend-architecture-refactor/specs/target-type-display/spec.md delete mode 100644 openspec/changes/frontend-architecture-refactor/tasks.md rename openspec/{changes/frontend-architecture-refactor => }/specs/meta-api/spec.md (90%) delete mode 100644 openspec/specs/target-type-display/spec.md create mode 100644 src/server/routes/meta.ts create mode 100644 src/web/components/HistoryTab.tsx create mode 100644 src/web/components/OverviewTab.tsx create mode 100644 src/web/constants/history-table-columns.tsx delete mode 100644 src/web/constants/target-type-display.ts create mode 100644 src/web/hooks/use-queries.ts rename src/web/hooks/{useTargetDetail.ts => use-target-detail.ts} (70%) create mode 100644 src/web/utils/stats.ts create mode 100644 tests/web/constants/target-table-columns.test.ts delete mode 100644 tests/web/constants/target-type-display.test.ts create mode 100644 tests/web/utils/stats.test.ts create mode 100644 tests/web/utils/time.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index fae3174..d3e0f9b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -67,15 +67,17 @@ src/ styles.css 全局样式与自定义 CSS 变量 components/ UI 组件(见下方组件清单) constants/ 常量与纯函数 - target-type-display.ts 类型名称映射 - target-table-columns.tsx 表格列定义 + history-table-columns.tsx 历史记录表格列定义 + target-table-columns.tsx 目标表格列定义工厂 target-table-filters.ts 表格筛选器 target-table-sorters.ts 表格排序器 color-threshold.ts 可用率颜色阈值函数 hooks/ TanStack Query 数据层 - useTargetDetail.ts 集成轮询/条件查询的组合 hook + use-queries.ts 全局面板查询 hook(summary/targets/meta) + use-target-detail.ts 目标详情 Drawer 状态与条件查询 hook utils/ 前端工具函数 time.ts 时间处理(subtractHours) + stats.ts 趋势统计计算(computeTrendStats) scripts/ 开发、构建、schema 生成和 smoke test 脚本 tests/ Bun test 测试(结构镜像 src 目录) openspec/ OpenSpec 变更与规格文档 @@ -358,14 +360,11 @@ TcpChecker implements Checker | `engine.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `execute()` | | `store.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `serialize()` | -注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新前端展示常量、配置示例、文档和测试。 +注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新配置示例、文档和测试。 -#### 1.7.7 步骤六:更新前端展示 +#### 1.7.7 步骤六:确认前端类型展示 -| 文件 | 修改内容 | -| ------------------------------------------- | ------------------------------------------------------------ | -| `src/web/constants/target-type-display.ts` | 在 `TARGET_TYPE_DISPLAY` 中添加 `"tcp": "TCP"` | -| `src/web/constants/target-table-filters.ts` | 在 `typeFilter.list` 中添加 `{ label: "TCP", value: "tcp" }` | +前端通过 `/api/meta` 获取 `checkerRegistry.supportedTypes` 并动态生成类型筛选器,类型列和详情标题直接显示 `target.type` 原始文本。新增 checker 注册后无需更新前端类型映射或筛选常量。 #### 1.7.8 步骤七:编写测试 @@ -400,8 +399,6 @@ TcpChecker implements Checker □ src/server/checker/runner/tcp/expect.ts — 专用断言(如需要) □ src/server/checker/runner/tcp/index.ts — 模块入口(re-export) □ src/server/checker/runner/index.ts — 注册(一行导入 + 一个数组元素) -□ src/web/constants/target-type-display.ts — 前端类型标签 -□ src/web/constants/target-table-filters.ts — 前端类型筛选 □ tests/ — 契约 + 校验 + resolve + execute + 注册 测试 □ probes.example.yaml — 配置示例 □ bun run schema + bun run schema:check — Schema 导出同步 @@ -544,26 +541,29 @@ main.tsx │ │ └── useSummary() ─── GET /api/summary(8s 轮询) │ └── TargetBoard(目标列表) │ ├── useTargets() ─── GET /api/targets(8s 轮询) + │ ├── useMeta() ────── GET /api/meta(应用生命周期内缓存) │ └── TargetGroup[](按 group 字段分组) - │ └── PrimaryTable ← TARGET_TABLE_COLUMNS(列定义:排序/筛选/渲染) + │ └── PrimaryTable ← createTargetTableColumns(checkerTypes) │ └── TargetDetailDrawer(目标详情抽屉) │ └── useTargetDetail() ── 按需发起 trend + history 查询 - │ ├── Tab: 概览 → Statistic + TrendChart + StatusDonut + Descriptions - │ └── Tab: 记录 → PrimaryTable(分页历史记录) + │ ├── OverviewTab → Statistic + TrendChart + StatusDonut + Descriptions + │ └── HistoryTab → PrimaryTable(分页历史记录) └── ReactQueryDevtools(开发工具,仅开发环境) ``` **数据层架构**: ``` -hooks/useTargetDetail.ts(唯一的数据层入口) -├── queryKeys(结构化 query key,确保缓存粒度精确) +hooks/use-queries.ts(全局面板级查询) +├── queryKeys(summary/targets/meta 结构化 query key) ├── useSummary() → /api/summary(8s 自动轮询) ├── useTargets() → /api/targets(8s 自动轮询) -└── useTargetDetail()(组合 hook,管理 Drawer 全部状态) - ├── 内部复用 useTargets() 的缓存来查找 selectedTarget - ├── useQuery(/api/targets/:id/trend)(条件查询:enabled 仅当 Drawer 打开且时间范围有效) - └── useQuery(/api/targets/:id/history)(条件查询:含分页) +└── useMeta() → /api/meta(staleTime: Infinity) + +hooks/use-target-detail.ts(Drawer 状态与详情级条件查询) +├── 内部复用 useTargets() 的缓存来查找 selectedTarget +├── useQuery(/api/targets/:id/trend)(条件查询:enabled 仅当 Drawer 打开且时间范围有效) +└── useQuery(/api/targets/:id/history)(条件查询:含分页) ``` ### 2.3 TanStack Query 数据层 @@ -574,6 +574,7 @@ hooks/useTargetDetail.ts(唯一的数据层入口) const queryKeys = { summary: () => ["summary"] as const, targets: () => ["targets"] as const, + meta: () => ["meta"] as const, trend: (targetId: number, from: string, to: string) => ["trend", targetId, from, to] as const, history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const, }; @@ -668,7 +669,9 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps) | `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(全部/正常/异常) | | `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表 | | `TargetGroup` | `components/TargetGroup.tsx` | 单个分组标题 + PrimaryTable | -| `TargetDetailDrawer` | `components/TargetDetailDrawer.tsx` | 目标详情抽屉(概览/记录 Tab) | +| `TargetDetailDrawer` | `components/TargetDetailDrawer.tsx` | 目标详情抽屉壳、时间选择和 Tab 切换 | +| `OverviewTab` | `components/OverviewTab.tsx` | 目标详情概览(统计/趋势/状态分布/信息) | +| `HistoryTab` | `components/HistoryTab.tsx` | 目标历史记录表格和分页 | | `TrendChart` | `components/TrendChart.tsx` | Recharts 双轴折线图(耗时/可用率) | | `StatusDonut` | `components/StatusDonut.tsx` | Recharts 环状图(UP/DOWN 分布) | | `StatusDot` | `components/StatusDot.tsx` | 圆形状态指示点(绿/红) | @@ -682,7 +685,7 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps) 1. **确认数据需求**:是已有 API 数据还是需要新端点? - 如有新端点,先在 `src/server/routes/` 添加,参考 [1.3 新增路由步骤](#13-api-路由开发) - 如有新字段,更新 `src/shared/api.ts` 类型定义 -2. **实现 hooks**:在 `src/web/hooks/useTargetDetail.ts` 中新增 `useQuery`(写好 `queryKey` 和 `enabled` 条件) +2. **实现 hooks**:全局查询放在 `src/web/hooks/use-queries.ts`;目标详情条件查询放在 `src/web/hooks/use-target-detail.ts`(写好 `queryKey` 和 `enabled` 条件) 3. **编写组件**:在 `src/web/components/` 创建组件文件 - 在 `TargetDetailDrawer.tsx` 中新增 `` 引用 4. **编写常量**:如有列定义/排序器/筛选器,放在 `src/web/constants/` diff --git a/README.md b/README.md index 203bbab..9eb3ada 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ JSON Schema:仓库根目录导出 `probe-config.schema.json`,可在 YAML 文 | 端点 | 说明 | | ----------------------------------------------------------------- | ------------------------------------------------------------ | | `GET /health` | 健康检查 | +| `GET /api/meta` | 运行时元信息(checker 类型列表) | | `GET /api/summary` | 总览统计(total/up/down/lastCheckTime) | | `GET /api/targets` | 目标列表及最新状态、分组和采样数据 | | `GET /api/targets/:id/history?from=ISO&to=ISO&page=1&pageSize=20` | 指定目标的拨测记录(时间范围 + 分页,`pageSize` 最大 `200`) | @@ -172,7 +173,9 @@ JSON Schema:仓库根目录导出 `probe-config.schema.json`,可在 YAML 文 **SummaryResponse**: `total`、`up`、`down`、`lastCheckTime` -**TargetStatus**: `id`、`name`、`type`(http/command)、`target`(URL 或命令摘要)、`group`、`interval`、`latestCheck`、`stats`、`recentSamples` +**MetaResponse**: `checkerTypes`(已注册 checker 类型标识符列表) + +**TargetStatus**: `id`、`name`、`type`(checker 类型,如 http/command)、`target`(URL 或命令摘要)、`group`、`interval`、`latestCheck`、`stats`、`recentSamples` **RecentSample**: `timestamp`、`durationMs`、`up` diff --git a/openspec/changes/frontend-architecture-refactor/.openspec.yaml b/openspec/changes/frontend-architecture-refactor/.openspec.yaml deleted file mode 100644 index 93831bd..0000000 --- a/openspec/changes/frontend-architecture-refactor/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-13 diff --git a/openspec/changes/frontend-architecture-refactor/design.md b/openspec/changes/frontend-architecture-refactor/design.md deleted file mode 100644 index df71b33..0000000 --- a/openspec/changes/frontend-architecture-refactor/design.md +++ /dev/null @@ -1,145 +0,0 @@ -## Context - -当前前端代码约 970 行,功能完整但存在以下架构问题: - -1. **hook 职责过重**:`hooks/useTargetDetail.ts`(113 行)同时承载全局查询(`useSummary`、`useTargets`)、Drawer 状态管理、条件查询和通用 `fetchJson` 封装,文件名与实际职责不匹配 -2. **组件体积膨胀**:`TargetDetailDrawer.tsx`(228 行)混合了时间选择逻辑、两个 Tab 的完整渲染、列定义和统计计算 -3. **类型维护重复**:`target-type-display.ts` 和 `target-table-filters.ts` 各自硬编码 checker 类型列表,新增 checker 需改两处前端文件 -4. **测试覆盖不足**:仅 `constants/` 下 4 个纯函数有测试,`utils/time.ts` 和组件内统计逻辑未覆盖 -5. **小问题**:`StatusDonut` 用数组索引做 key、`StatusBar` 硬编码 30 格、`TrendChart` 冗余 loading prop - -后端 `CheckerRegistry` 已有 `supportedTypes` 属性,可直接暴露给前端。项目未上线,不需要向前兼容。 - -## Goals / Non-Goals - -**Goals:** - -- hook 按职责拆分,文件名匹配实际内容 -- `TargetDetailDrawer` 拆分为 3 个组件,每个 < 100 行 -- 类型筛选器由后端 meta API 驱动,新增 checker 前端零改动 -- 删除 type label 转换层,直接使用 type 原始文本 -- 补齐前端纯函数测试 -- 修复已知小问题 - -**Non-Goals:** - -- 不引入路由库或状态管理库 -- 不重构后端 API 结构 -- 不改变现有轮询策略和 QueryClient 配置 -- 不新增 CSS 文件(继续使用单一 `styles.css`) - -## Decisions - -### Decision 1: hook 拆分策略 - -**选择**:按查询层级拆分为 `use-queries.ts` 和 `use-target-detail.ts` - -- `use-queries.ts`:`queryKeys`、`fetchJson`(不导出)、`useSummary`、`useTargets`、`useMeta` -- `use-target-detail.ts`:仅保留 Drawer 状态管理(`selectedTargetId`、时间范围、分页、`openDrawer`/`closeDrawer`)和条件查询(trend/history) - -**理由**:全局查询(summary/targets/meta)是面板级别的,与 Drawer 详情无关。拆分后各文件职责单一,命名自解释。`fetchJson` 仅被 query 层使用,留在 `use-queries.ts` 内部不导出。 - -**备选方案**: -- 仅重命名为 `useQueries.ts` — 解决命名问题但不解决职责混合 -- 每个 query 一个文件 — 过度拆分,增加文件数量但无实质收益 - -### Decision 2: TargetDetailDrawer 拆分方式 - -**选择**:拆为 3 个组件 + 1 个常量文件 - -``` -TargetDetailDrawer.tsx ← Drawer 壳 + 时间选择 + Tab 切换 -OverviewTab.tsx ← 统计 + TrendChart + StatusDonut + Descriptions -HistoryTab.tsx ← PrimaryTable + 分页 -constants/history-table-columns.tsx ← HISTORY_COLUMNS -``` - -**理由**:两个 Tab 的内容完全独立,拆分后各组件 < 100 行。`HISTORY_COLUMNS` 与 `TARGET_TABLE_COLUMNS` 性质相同,应放在 `constants/` 下保持一致。 - -统计计算逻辑(`totalChecks`/`upChecks`/`downChecks`)提取为 `utils/stats.ts` 纯函数,便于测试和 `useMemo`。 - -### Decision 3: Meta API 设计 - -**选择**:`GET /api/meta` 返回 `{ checkerTypes: string[] }` - -```typescript -// src/shared/api.ts -export interface MetaResponse { - checkerTypes: string[]; -} -``` - -**理由**: -- 从 `checkerRegistry.supportedTypes` 直接获取,无需额外维护 -- 返回 `string[]` 而非 `{ key, label }[]`,因为决策是不做 label 转换 -- 端点命名为 `/api/meta` 而非 `/api/types`,为未来扩展预留空间(如版本号、功能开关等) -- `staleTime: Infinity`,应用生命周期内只请求一次 - -**备选方案**: -- 从 targets 响应中动态提取 — 只能获取"当前有数据的类型",不能获取"系统支持的全部类型" -- 在 health 端点中附带 — 语义不匹配,health 应保持最小化 - -### Decision 4: 类型展示策略 - -**选择**:删除 `target-type-display.ts`,所有展示位置直接使用 `target.type` 原始文本 - -**影响位置**: -- `target-table-columns.tsx` 类型列 cell:`row.type` 直接渲染 -- `TargetDetailDrawer.tsx` 标题栏 Tag:`target.type` 直接渲染 -- `typeFilter` 列表:从 meta API 获取,label 和 value 均为原始 type 文本 - -**理由**:type 文本本身已足够清晰(`http`、`command`、`tcp`),无需额外映射层。消除了前后端重复维护的问题。 - -### Decision 5: 列定义动态化 - -**选择**:`TARGET_TABLE_COLUMNS` 从静态常量改为工厂函数 - -```typescript -export function createTargetTableColumns(checkerTypes: string[]): PrimaryTableCol[] { - const typeFilter = { - list: [ - { label: "全部", value: "" }, - ...checkerTypes.map(t => ({ label: t, value: t })), - ], - type: "single" as const, - }; - // ... 返回列定义数组 -} -``` - -**数据流**:`useMeta()` → `checkerTypes` → `createTargetTableColumns(checkerTypes)` → `TargetGroup` columns prop - -**理由**:`typeFilter` 是唯一需要动态数据的部分,通过工厂函数注入参数,保持列定义的纯函数特性。`statusFilter` 保持静态(UP/DOWN 是固定的)。 - -`TargetGroup` 新增 `columns` prop 接收动态列定义,`TargetBoard` 负责调用工厂函数并传递。 - -### Decision 6: StatusBar maxSlots 参数化 - -**选择**:新增 `maxSlots` prop(默认 30),组件根据 prop 渲染格数 - -```typescript -interface StatusBarProps { - samples: Array<{ up: boolean }>; - maxSlots?: number; -} -``` - -**理由**:消除硬编码魔数,使组件可复用。默认值 30 保持向后兼容。 - -### Decision 7: TrendChart 移除 loading prop - -**选择**:移除 `loading` prop,组件只接收 `data: TrendPoint[]` - -**理由**:调用方(`OverviewTab`)已用 `Skeleton` 处理 loading 状态,`TrendChart` 只在有数据时渲染。组件内部的 `if (loading)` 分支和 `loading={false}` 传参都是死代码。 - -## Risks / Trade-offs - -| Risk | Mitigation | -|------|-----------| -| 列定义改为函数后,每次 `checkerTypes` 变化会重新创建列数组 | `useMemo` 包裹 `createTargetTableColumns` 调用;meta 数据 `staleTime: Infinity` 确保不会频繁变化 | -| meta API 在 targets 之前未返回时,筛选器暂时为空 | meta 请求极轻量(无 DB 查询),通常先于 targets 返回;即使晚到,筛选器会在数据到达后自动出现 | -| 拆分后组件间 props 传递增多 | 层级仅增加一层(Drawer → Tab),props 类型明确,不会造成 prop drilling 问题 | - -## Open Questions - -无。方案已在 explore 阶段与用户确认。 diff --git a/openspec/changes/frontend-architecture-refactor/proposal.md b/openspec/changes/frontend-architecture-refactor/proposal.md deleted file mode 100644 index 134e0de..0000000 --- a/openspec/changes/frontend-architecture-refactor/proposal.md +++ /dev/null @@ -1,34 +0,0 @@ -## Why - -前端代码经过多轮功能迭代后,出现了 hook 职责过重(`useTargetDetail.ts` 承载全部数据层)、组件体积膨胀(`TargetDetailDrawer` 228 行混合多种逻辑)、类型筛选器与后端硬编码重复维护等架构问题。需要通过拆分、动态化和测试补齐来提升可维护性,使新增 checker 类型时前端零改动。 - -## What Changes - -- 拆分 `hooks/useTargetDetail.ts` 为 `use-queries.ts`(全局查询)和 `use-target-detail.ts`(Drawer 状态管理) -- 拆分 `TargetDetailDrawer.tsx` 为 Drawer 壳、`OverviewTab.tsx`、`HistoryTab.tsx` 三个组件 -- 将 `HISTORY_COLUMNS` 移至 `constants/history-table-columns.tsx` -- 提取统计计算逻辑为 `utils/stats.ts` 纯函数 -- 后端新增 `GET /api/meta` 端点,返回 `checkerTypes` 列表 -- 前端新增 `useMeta()` hook 消费 meta API,动态生成类型筛选器 -- **BREAKING** 删除 `constants/target-type-display.ts`,前端直接使用 type 原始文本,不再做 label 转换 -- 列定义从静态常量改为工厂函数 `createTargetTableColumns(checkerTypes)` -- 修复小问题:`StatusDonut` key、`StatusBar` 硬编码、`TrendChart` 冗余 prop、统计计算 useMemo -- 补充前端测试:`utils/time.ts`、`utils/stats.ts`、动态列生成 - -## Capabilities - -### New Capabilities -- `meta-api`: 后端 meta 信息 API,提供 checker 类型列表等运行时元数据 - -### Modified Capabilities -- `target-type-display`: 移除前端静态映射,改为直接使用后端返回的 type 原始文本,筛选器列表由 meta API 动态驱动 -- `tanstack-query-data-layer`: hook 文件拆分为 `use-queries.ts` 和 `use-target-detail.ts`,新增 `useMeta()` 查询 -- `target-detail-drawer`: 组件拆分为 Drawer 壳 + OverviewTab + HistoryTab,统计计算提取为纯函数 -- `target-table`: 列定义从静态常量改为工厂函数,接收动态 checkerTypes 参数;类型列直接显示 type 原始文本 - -## Impact - -- 后端:新增 `src/server/routes/meta.ts`、`src/shared/api.ts` 增加 `MetaResponse` 类型、`app.ts` 注册路由 -- 前端:hooks/components/constants 目录结构调整,删除 `target-type-display.ts` -- 测试:删除 `target-type-display.test.ts`,新增 `time.test.ts`、`stats.test.ts`,更新 `target-table-filters.test.ts` -- 文档:更新 DEVELOPMENT.md 中前端目录结构和组件清单 diff --git a/openspec/changes/frontend-architecture-refactor/specs/tanstack-query-data-layer/spec.md b/openspec/changes/frontend-architecture-refactor/specs/tanstack-query-data-layer/spec.md deleted file mode 100644 index 84756a3..0000000 --- a/openspec/changes/frontend-architecture-refactor/specs/tanstack-query-data-layer/spec.md +++ /dev/null @@ -1,109 +0,0 @@ -## MODIFIED Requirements - -### Requirement: TanStack Query 数据层 -前端 SHALL 使用 TanStack Query(@tanstack/react-query)管理所有 API 请求,数据层代码 SHALL 按职责拆分为独立 hook 文件。 - -#### Scenario: QueryClient 配置 -- **WHEN** 应用启动 -- **THEN** 系统 SHALL 创建 QueryClient,默认配置 retry=1、refetchOnWindowFocus=true、staleTime=5000 - -#### Scenario: QueryClientProvider 挂载 -- **WHEN** 应用渲染 -- **THEN** 根组件 SHALL 包裹在 QueryClientProvider 中,提供 QueryClient 实例 - -### Requirement: queryKey 工厂 -系统 SHALL 提供统一的 queryKey 工厂函数,确保 queryKey 的唯一性和一致性。 - -#### Scenario: summary queryKey -- **WHEN** 查询 summary 数据 -- **THEN** queryKey SHALL 为 ["summary"] - -#### Scenario: targets queryKey -- **WHEN** 查询 targets 数据 -- **THEN** queryKey SHALL 为 ["targets"] - -#### Scenario: meta queryKey -- **WHEN** 查询 meta 数据 -- **THEN** queryKey SHALL 为 ["meta"] - -#### Scenario: trend queryKey -- **WHEN** 查询某目标的趋势数据 -- **THEN** queryKey SHALL 为 ["trend", targetId, from, to] - -#### Scenario: history queryKey -- **WHEN** 查询某目标的历史记录 -- **THEN** queryKey SHALL 为 ["history", targetId, from, to, page] - -### Requirement: Hook 文件拆分 -数据层 hook SHALL 按职责拆分为独立文件。 - -#### Scenario: 全局查询 hook 文件 -- **WHEN** 开发者需要使用全局面板级查询 -- **THEN** `useSummary`、`useTargets`、`useMeta` SHALL 从 `hooks/use-queries.ts` 导出 - -#### Scenario: Drawer 状态 hook 文件 -- **WHEN** 开发者需要使用 Drawer 状态管理 -- **THEN** `useTargetDetail` SHALL 从 `hooks/use-target-detail.ts` 导出 - -#### Scenario: fetchJson 不导出 -- **WHEN** 数据层内部需要 fetch 封装 -- **THEN** `fetchJson` SHALL 定义在 `use-queries.ts` 内部,不作为公共 API 导出 - -#### Scenario: queryKeys 不导出 -- **WHEN** 数据层内部需要 query key -- **THEN** `queryKeys` 对象 SHALL 定义在 `use-queries.ts` 内部,不作为公共 API 导出 - -### Requirement: Meta 查询 -系统 SHALL 提供 `useMeta` hook 查询系统元数据。 - -#### Scenario: meta 查询配置 -- **WHEN** 应用启动 -- **THEN** `useMeta` SHALL 请求 `/api/meta`,配置 `staleTime: Infinity`(应用生命周期内只请求一次) - -#### Scenario: meta 数据返回 -- **WHEN** meta 查询成功 -- **THEN** hook SHALL 返回 `MetaResponse` 类型数据,包含 `checkerTypes` 字段 - -### Requirement: Summary 轮询查询 -系统 SHALL 使用 useQuery 实现总览统计的自动轮询。 - -#### Scenario: summary 自动轮询 -- **WHEN** Dashboard 页面处于打开状态 -- **THEN** 系统 SHALL 每 8 秒自动请求 /api/summary,使用 refetchInterval=8000 - -#### Scenario: summary 后台刷新 -- **WHEN** 页面处于后台标签页 -- **THEN** 系统 SHALL 暂停轮询(refetchIntervalInBackground=false) - -### Requirement: Targets 轮询查询 -系统 SHALL 使用 useQuery 实现目标列表的自动轮询。 - -#### Scenario: targets 自动轮询 -- **WHEN** Dashboard 页面处于打开状态 -- **THEN** 系统 SHALL 每 8 秒自动请求 /api/targets,使用 refetchInterval=8000 - -### Requirement: 条件查询 -趋势和历史记录查询 SHALL 使用 enabled 条件控制,仅在目标被选中时触发。 - -#### Scenario: 未选中目标时不请求 -- **WHEN** 用户未点击任何目标表格行 -- **THEN** trend 和 history 的 useQuery SHALL enabled=false,不发起请求 - -#### Scenario: 选中目标时自动请求 -- **WHEN** 用户点击目标表格行 -- **THEN** trend 和 history 的 useQuery SHALL enabled=true,自动发起请求 - -#### Scenario: 时间范围变化时重新请求 -- **WHEN** 用户更改时间范围 -- **THEN** trend 和 history 的 useQuery SHALL 因 queryKey 变化自动重新请求 - -### Requirement: 开发调试面板 -开发环境下 SHALL 挂载 TanStack Query Devtools。 - -#### Scenario: 开发环境显示 Devtools -- **WHEN** 应用在开发模式下运行 -- **THEN** 页面 SHALL 显示 ReactQueryDevtools 浮动面板 - -#### Scenario: 生产环境排除 Devtools -- **WHEN** 应用在生产模式下构建 -- **THEN** ReactQueryDevtools SHALL 不被包含在产物中 diff --git a/openspec/changes/frontend-architecture-refactor/specs/target-detail-drawer/spec.md b/openspec/changes/frontend-architecture-refactor/specs/target-detail-drawer/spec.md deleted file mode 100644 index 0c21787..0000000 --- a/openspec/changes/frontend-architecture-refactor/specs/target-detail-drawer/spec.md +++ /dev/null @@ -1,91 +0,0 @@ -## MODIFIED Requirements - -### Requirement: 目标详情 Drawer -Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。Drawer 内容 SHALL 拆分为独立的 Tab 组件。 - -#### Scenario: 打开 Drawer -- **WHEN** 用户点击某个目标表格行 -- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="right"),宽度为视口 60% - -#### Scenario: Drawer 标题栏 -- **WHEN** Drawer 渲染 -- **THEN** 标题栏 SHALL 使用 TDesign Space 组件(align="center")布局,包含 StatusDot、目标名称(TDesign Typography.Text strong)和类型标签(TDesign Tag,直接显示 target.type 原始文本),以及内建关闭按钮。不使用内联 style 的 flex 布局 - -#### Scenario: 关闭 Drawer -- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层 -- **THEN** Drawer SHALL 关闭 - -#### Scenario: Drawer 无底部按钮 -- **WHEN** Drawer 渲染 -- **THEN** Drawer SHALL 不显示底部操作栏(footer={false}) - -#### Scenario: Drawer 数据同步 -- **WHEN** Drawer 打开期间后台轮询刷新了 targets 数据 -- **THEN** Drawer 中 selectedTarget 的状态 SHALL 随之同步更新 - -#### Scenario: 切换目标重置 Tab -- **WHEN** 用户从目标 A 切换到目标 B(点击不同的表格行) -- **THEN** Drawer SHALL 重置为概览 Tab,使用 key={target.id} 确保组件状态不残留 - -#### Scenario: Drawer 内容区间距 -- **WHEN** Drawer 内容渲染 -- **THEN** 时间选择器、Tabs 等区块之间的间距 SHALL 通过 TDesign Space 组件(direction="vertical", size={16})统一管理,不使用内联 style 的 marginBottom - -### Requirement: 概览面板组件化 -概览 Tab SHALL 作为独立组件 `OverviewTab` 实现,接收数据 props 进行渲染。 - -#### Scenario: OverviewTab 组件职责 -- **WHEN** 概览 Tab 渲染 -- **THEN** `OverviewTab` 组件 SHALL 负责统计卡片、趋势图、状态分布环形图和基本信息的渲染 - -#### Scenario: 统计计算使用纯函数 -- **WHEN** OverviewTab 需要计算 totalChecks、upChecks、downChecks -- **THEN** 计算逻辑 SHALL 通过 `utils/stats.ts` 中的纯函数实现,并使用 `useMemo` 缓存结果 - -#### Scenario: OverviewTab props -- **WHEN** OverviewTab 渲染 -- **THEN** 组件 SHALL 接收 `target: TargetStatus`、`trendData: TrendPoint[]`、`trendLoading: boolean` 作为 props - -### Requirement: 记录面板组件化 -记录 Tab SHALL 作为独立组件 `HistoryTab` 实现。 - -#### Scenario: HistoryTab 组件职责 -- **WHEN** 记录 Tab 渲染 -- **THEN** `HistoryTab` 组件 SHALL 负责检查结果表格和分页的渲染 - -#### Scenario: HistoryTab props -- **WHEN** HistoryTab 渲染 -- **THEN** 组件 SHALL 接收 `historyData: HistoryResponse`、`historyLoading: boolean`、`onPageChange: (page: number) => void` 作为 props - -#### Scenario: 历史记录列定义外置 -- **WHEN** HistoryTab 渲染表格 -- **THEN** 列定义 SHALL 从 `constants/history-table-columns.tsx` 导入,不在组件内部定义 - -### Requirement: TrendChart 简化 -TrendChart 组件 SHALL 仅接收数据 props,不处理 loading 状态。 - -#### Scenario: TrendChart 无 loading prop -- **WHEN** TrendChart 渲染 -- **THEN** 组件 SHALL 仅接收 `data: TrendPoint[]` prop,不接收 `loading` prop - -#### Scenario: TrendChart 空数据 -- **WHEN** TrendChart 接收空数组 -- **THEN** 组件 SHALL 显示"暂无趋势数据"占位文本 - -### Requirement: StatusDonut key 修复 -StatusDonut 组件 SHALL 使用语义化的 key。 - -#### Scenario: Pie Cell key -- **WHEN** StatusDonut 渲染 Pie Cell 列表 -- **THEN** 每个 Cell 的 key SHALL 使用 data item 的 `name` 字段,不使用数组索引 - -### Requirement: StatusBar 参数化 -StatusBar 组件 SHALL 支持可配置的格数。 - -#### Scenario: maxSlots prop -- **WHEN** StatusBar 渲染 -- **THEN** 组件 SHALL 接收可选的 `maxSlots` prop(默认 30),根据该值渲染对应数量的格子 - -#### Scenario: 格子渲染逻辑 -- **WHEN** StatusBar 渲染且 samples 数量少于 maxSlots -- **THEN** 多余的格子 SHALL 显示为 empty 状态 diff --git a/openspec/changes/frontend-architecture-refactor/specs/target-table/spec.md b/openspec/changes/frontend-architecture-refactor/specs/target-table/spec.md deleted file mode 100644 index 32cdc22..0000000 --- a/openspec/changes/frontend-architecture-refactor/specs/target-table/spec.md +++ /dev/null @@ -1,69 +0,0 @@ -## MODIFIED Requirements - -### Requirement: 表格列定义 -每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。列渲染不使用内联 style。列定义 SHALL 通过工厂函数动态生成。 - -#### Scenario: 状态列 -- **WHEN** 表格渲染 -- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染,标题显示"#",宽度 60px,fixed="left",居中对齐,支持筛选(UP/DOWN/全部)。StatusDot SHALL 通过 CSS 类(`.status-dot--up` / `.status-dot--down`)控制颜色,不使用内联 style - -#### Scenario: 名称列 -- **WHEN** 表格渲染 -- **THEN** 名称列 SHALL 显示目标名称,支持字母排序(zh-CN),ellipsis 超长名称自动省略并 Tooltip 显示全名 - -#### Scenario: 类型列 -- **WHEN** 表格渲染 -- **THEN** 类型列 SHALL 使用 TDesign Tag 组件(size=small, theme=primary, variant=light-outline)直接显示 target.type 原始文本,支持单选筛选 - -#### Scenario: 类型筛选器动态生成 -- **WHEN** 表格渲染 -- **THEN** 类型列的筛选器列表 SHALL 从 meta API 返回的 `checkerTypes` 动态生成,包含"全部"选项和每个 checker 类型选项(label 和 value 均为 type 原始文本) - -#### Scenario: 可用率列 -- **WHEN** 表格渲染 -- **THEN** 可用率列 SHALL 使用 TDesign Progress 组件(theme=line, size=small)渲染,颜色通过 CSS 自定义属性 `--avail-N`(基于项目自定义色值)控制,每 10% 一档,label 显示百分比数值,支持排序(升序优先,最差排最前)。color-threshold 函数 SHALL 返回 CSS 自定义属性引用而非硬编码色值 - -#### Scenario: 最近状态列 -- **WHEN** 表格渲染 -- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染采样色块,宽度 220px。StatusBar SHALL 通过 CSS 类(`.status-bar-block--up` / `.status-bar-block--down` / `.status-bar-block--empty`)控制色块颜色,不使用内联 style - -#### Scenario: 延迟列 -- **WHEN** 表格渲染 -- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐。颜色 SHALL 通过 CSS 类实现:≤100ms 使用 `.latency-ok`、100-500ms 使用 `.latency-warn`、>500ms 使用 `.latency-error`。无数据 SHALL 使用 `.text-disabled` 类显示 "-",数值 SHALL 使用 `.tabular-nums` 类等宽显示。不使用内联 style - -#### Scenario: 间隔列 -- **WHEN** 表格渲染 -- **THEN** 间隔列 SHALL 显示检查间隔(如 "5s"、"30s"),居中对齐,宽度 72px - -### Requirement: 列定义工厂函数 -列定义 SHALL 通过工厂函数生成,接收动态参数。 - -#### Scenario: createTargetTableColumns 函数 -- **WHEN** 需要生成表格列定义 -- **THEN** 系统 SHALL 调用 `createTargetTableColumns(checkerTypes: string[])` 函数,返回 `PrimaryTableCol[]` - -#### Scenario: checkerTypes 为空数组 -- **WHEN** meta API 尚未返回或返回空数组 -- **THEN** 类型列的筛选器 SHALL 仅包含"全部"选项 - -#### Scenario: 列定义缓存 -- **WHEN** TargetBoard 组件渲染 -- **THEN** 列定义 SHALL 通过 `useMemo` 缓存,仅在 `checkerTypes` 变化时重新生成 - -### Requirement: TargetGroup 接收 columns prop -TargetGroup 组件 SHALL 通过 prop 接收列定义,不再直接导入静态常量。 - -#### Scenario: columns prop -- **WHEN** TargetGroup 渲染 -- **THEN** 组件 SHALL 接收 `columns: PrimaryTableCol[]` prop 并传递给 PrimaryTable - -#### Scenario: TargetBoard 传递 columns -- **WHEN** TargetBoard 渲染子组件 -- **THEN** TargetBoard SHALL 调用 `createTargetTableColumns` 生成列定义并传递给每个 TargetGroup - -### Requirement: 列定义复用 -所有分组的表格 SHALL 共享同一套列定义常量。 - -#### Scenario: 列定义提取为常量 -- **WHEN** 多个分组表格渲染 -- **THEN** 列定义 SHALL 从独立的 constants/target-table-columns.tsx 导入,不在组件中重复定义 diff --git a/openspec/changes/frontend-architecture-refactor/specs/target-type-display/spec.md b/openspec/changes/frontend-architecture-refactor/specs/target-type-display/spec.md deleted file mode 100644 index f31e5ec..0000000 --- a/openspec/changes/frontend-architecture-refactor/specs/target-type-display/spec.md +++ /dev/null @@ -1,13 +0,0 @@ -## REMOVED Requirements - -### Requirement: 类型显示名称映射 -**Reason**: 前端不再维护 type → label 的静态映射,直接使用后端返回的 type 原始文本展示。类型筛选器列表改由 meta API 动态驱动。 -**Migration**: 所有使用 `getTargetTypeDisplay(type)` 的位置改为直接使用 `type` 字符串。`TARGET_TYPE_DISPLAY` 常量和 `target-type-display.ts` 文件删除。 - -### Requirement: 映射可扩展性 -**Reason**: 不再需要前端映射扩展机制,新增 checker 类型时后端注册即自动通过 meta API 暴露给前端。 -**Migration**: 无需迁移,删除即可。 - -### Requirement: 类型安全 -**Reason**: 不再有映射常量,无需 TypeScript 类型推导和 fallback 逻辑。 -**Migration**: 无需迁移,删除即可。 diff --git a/openspec/changes/frontend-architecture-refactor/tasks.md b/openspec/changes/frontend-architecture-refactor/tasks.md deleted file mode 100644 index ed61c46..0000000 --- a/openspec/changes/frontend-architecture-refactor/tasks.md +++ /dev/null @@ -1,48 +0,0 @@ -## 1. 后端 Meta API - -- [ ] 1.1 在 `src/shared/api.ts` 中新增 `MetaResponse` 类型定义 -- [ ] 1.2 创建 `src/server/routes/meta.ts`,实现 `handleMeta` 从 `checkerRegistry.supportedTypes` 返回数据 -- [ ] 1.3 在 `src/server/app.ts` 中注册 `/api/meta` 路由 -- [ ] 1.4 在 `tests/server/app.test.ts` 中添加 `/api/meta` 端点测试(GET/HEAD/405) - -## 2. 前端 Hook 拆分 - -- [ ] 2.1 创建 `src/web/hooks/use-queries.ts`,迁入 `queryKeys`、`fetchJson`、`useSummary`、`useTargets`,新增 `useMeta` -- [ ] 2.2 重写 `src/web/hooks/use-target-detail.ts`,仅保留 Drawer 状态管理和条件查询(trend/history) -- [ ] 2.3 更新 `src/web/app.tsx` 的 import 路径适配新 hook 文件 - -## 3. 前端组件拆分 - -- [ ] 3.1 创建 `src/web/utils/stats.ts`,提取统计计算纯函数(`computeTrendStats`) -- [ ] 3.2 创建 `src/web/constants/history-table-columns.tsx`,将 `HISTORY_COLUMNS` 从 Drawer 中移出 -- [ ] 3.3 创建 `src/web/components/OverviewTab.tsx`,从 TargetDetailDrawer 中提取概览面板逻辑 -- [ ] 3.4 创建 `src/web/components/HistoryTab.tsx`,从 TargetDetailDrawer 中提取记录面板逻辑 -- [ ] 3.5 精简 `src/web/components/TargetDetailDrawer.tsx`,仅保留 Drawer 壳 + 时间选择 + Tab 切换 - -## 4. 类型筛选器动态化 - -- [ ] 4.1 将 `src/web/constants/target-table-columns.tsx` 中的 `TARGET_TABLE_COLUMNS` 改为工厂函数 `createTargetTableColumns(checkerTypes)` -- [ ] 4.2 从 `src/web/constants/target-table-filters.ts` 中移除 `typeFilter`(`statusFilter` 保留) -- [ ] 4.3 更新 `src/web/components/TargetBoard.tsx`,调用 `useMeta` + `useMemo` 生成列定义并传递给 TargetGroup -- [ ] 4.4 更新 `src/web/components/TargetGroup.tsx`,新增 `columns` prop 替代静态导入 -- [ ] 4.5 删除 `src/web/constants/target-type-display.ts` -- [ ] 4.6 更新 `src/web/components/TargetDetailDrawer.tsx` 标题栏,直接使用 `target.type` 替代 `getTargetTypeDisplay` - -## 5. 小问题修复 - -- [ ] 5.1 修复 `StatusDonut.tsx`:Cell key 从 `index` 改为 `data[index].name` -- [ ] 5.2 修复 `StatusBar.tsx`:新增 `maxSlots` prop(默认 30),用 prop 驱动格数渲染 -- [ ] 5.3 修复 `TrendChart.tsx`:移除 `loading` prop,仅保留 `data` prop - -## 6. 测试补充与更新 - -- [ ] 6.1 创建 `tests/web/utils/time.test.ts`,测试 `subtractHours`(正常、跨天、跨月、0 小时) -- [ ] 6.2 创建 `tests/web/utils/stats.test.ts`,测试 `computeTrendStats` 纯函数 -- [ ] 6.3 更新 `tests/web/constants/target-table-filters.test.ts`,移除 `typeFilter` 相关测试 -- [ ] 6.4 删除 `tests/web/constants/target-type-display.test.ts` -- [ ] 6.5 创建 `tests/web/constants/target-table-columns.test.ts`,测试 `createTargetTableColumns` 工厂函数 - -## 7. 质量保障与文档 - -- [ ] 7.1 执行 `bun run check` 确保类型检查、lint、测试全部通过 -- [ ] 7.2 更新 DEVELOPMENT.md 中前端目录结构、组件清单和 hook 说明 diff --git a/openspec/changes/frontend-architecture-refactor/specs/meta-api/spec.md b/openspec/specs/meta-api/spec.md similarity index 90% rename from openspec/changes/frontend-architecture-refactor/specs/meta-api/spec.md rename to openspec/specs/meta-api/spec.md index 344ade2..9af66e7 100644 --- a/openspec/changes/frontend-architecture-refactor/specs/meta-api/spec.md +++ b/openspec/specs/meta-api/spec.md @@ -1,4 +1,8 @@ -## ADDED Requirements +## Purpose + +定义系统运行时元数据 API:checker 类型列表等元信息的对外暴露方式。 + +## Requirements ### Requirement: Meta 信息 API 系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。 diff --git a/openspec/specs/probe-api/spec.md b/openspec/specs/probe-api/spec.md index 1dcd9a4..5eb038a 100644 --- a/openspec/specs/probe-api/spec.md +++ b/openspec/specs/probe-api/spec.md @@ -130,3 +130,29 @@ #### Scenario: 无失败信息 - **WHEN** 检查结果 matched=true - **THEN** API SHALL 返回 failure 为 null + +### Requirement: Meta 信息 API +系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。 + +#### Scenario: 获取 checker 类型列表 +- **WHEN** 客户端请求 `GET /api/meta` +- **THEN** 系统 SHALL 返回 JSON `{ checkerTypes: string[] }`,包含所有已注册的 checker 类型标识符 + +#### Scenario: 类型列表来源 +- **WHEN** 系统启动并注册了 checker +- **THEN** `/api/meta` 返回的 `checkerTypes` SHALL 与 `CheckerRegistry.supportedTypes` 完全一致 + +#### Scenario: 仅允许 GET/HEAD 方法 +- **WHEN** 客户端使用 POST/PUT/DELETE 等方法请求 `/api/meta` +- **THEN** 系统 SHALL 返回 405 状态码 + +#### Scenario: HEAD 请求返回空体 +- **WHEN** 客户端使用 HEAD 方法请求 `/api/meta` +- **THEN** 系统 SHALL 返回 200 状态码和正确的 Content-Type header,body 为空 + +### Requirement: MetaResponse 共享类型 +系统 SHALL 在 `src/shared/api.ts` 中定义 `MetaResponse` 类型。 + +#### Scenario: MetaResponse 类型定义 +- **WHEN** 前后端引用 `MetaResponse` 类型 +- **THEN** 该类型 SHALL 包含 `checkerTypes: string[]` 字段 diff --git a/openspec/specs/tanstack-query-data-layer/spec.md b/openspec/specs/tanstack-query-data-layer/spec.md index ef029c4..11002d4 100644 --- a/openspec/specs/tanstack-query-data-layer/spec.md +++ b/openspec/specs/tanstack-query-data-layer/spec.md @@ -5,7 +5,7 @@ ## Requirements ### Requirement: TanStack Query 数据层 -前端 SHALL 使用 TanStack Query(@tanstack/react-query)管理所有 API 请求,替代手写 fetch hooks。 +前端 SHALL 使用 TanStack Query(@tanstack/react-query)管理所有 API 请求,数据层代码 SHALL 按职责拆分为独立 hook 文件。 #### Scenario: QueryClient 配置 - **WHEN** 应用启动 @@ -34,6 +34,40 @@ - **WHEN** 查询某目标的历史记录 - **THEN** queryKey SHALL 为 ["history", targetId, from, to, page] +#### Scenario: meta queryKey +- **WHEN** 查询 meta 数据 +- **THEN** queryKey SHALL 为 ["meta"] + +### Requirement: Meta 查询 +系统 SHALL 提供 `useMeta` hook 查询系统元数据。 + +#### Scenario: meta 查询配置 +- **WHEN** 应用启动 +- **THEN** `useMeta` SHALL 请求 `/api/meta`,配置 `staleTime: Infinity`(应用生命周期内只请求一次) + +#### Scenario: meta 数据返回 +- **WHEN** meta 查询成功 +- **THEN** hook SHALL 返回 `MetaResponse` 类型数据,包含 `checkerTypes` 字段 + +### Requirement: Hook 文件拆分 +数据层 hook SHALL 按职责拆分为独立文件。 + +#### Scenario: 全局查询 hook 文件 +- **WHEN** 开发者需要使用全局面板级查询 +- **THEN** `useSummary`、`useTargets`、`useMeta` SHALL 从 `hooks/use-queries.ts` 导出 + +#### Scenario: Drawer 状态 hook 文件 +- **WHEN** 开发者需要使用 Drawer 状态管理 +- **THEN** `useTargetDetail` SHALL 从 `hooks/use-target-detail.ts` 导出 + +#### Scenario: fetchJson 不导出 +- **WHEN** 数据层内部需要 fetch 封装 +- **THEN** `fetchJson` SHALL 定义在 `use-queries.ts` 内部,不作为公共 API 导出 + +#### Scenario: queryKeys 不导出 +- **WHEN** 数据层内部需要 query key +- **THEN** `queryKeys` 对象 SHALL 定义在 `use-queries.ts` 内部,不作为公共 API 导出 + ### Requirement: Summary 轮询查询 系统 SHALL 使用 useQuery 实现总览统计的自动轮询。 diff --git a/openspec/specs/target-detail-drawer/spec.md b/openspec/specs/target-detail-drawer/spec.md index 675fad8..f8f6b04 100644 --- a/openspec/specs/target-detail-drawer/spec.md +++ b/openspec/specs/target-detail-drawer/spec.md @@ -5,7 +5,7 @@ ## Requirements ### Requirement: 目标详情 Drawer -Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。 +Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。Drawer 内容 SHALL 拆分为独立的 Tab 组件。 #### Scenario: 打开 Drawer - **WHEN** 用户点击某个目标表格行 @@ -13,7 +13,7 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示 #### Scenario: Drawer 标题栏 - **WHEN** Drawer 渲染 -- **THEN** 标题栏 SHALL 使用 TDesign Space 组件(align="center")布局,包含 StatusDot、目标名称(TDesign Typography.Text strong)和类型标签(TDesign Tag),以及内建关闭按钮。不使用内联 style 的 flex 布局 +- **THEN** 标题栏 SHALL 使用 TDesign Space 组件(align="center")布局,包含 StatusDot、目标名称(TDesign Typography.Text strong)和类型标签(TDesign Tag,直接显示 target.type 原始文本),以及内建关闭按钮。不使用内联 style 的 flex 布局 #### Scenario: 关闭 Drawer - **WHEN** 用户点击关闭按钮、ESC 键或遮罩层 @@ -35,6 +35,65 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示 - **WHEN** Drawer 内容渲染 - **THEN** 时间选择器、Tabs 等区块之间的间距 SHALL 通过 TDesign Space 组件(direction="vertical", size={16})统一管理,不使用内联 style 的 marginBottom +### Requirement: 概览面板组件化 +概览 Tab SHALL 作为独立组件 `OverviewTab` 实现,接收数据 props 进行渲染。 + +#### Scenario: OverviewTab 组件职责 +- **WHEN** 概览 Tab 渲染 +- **THEN** `OverviewTab` 组件 SHALL 负责统计卡片、趋势图、状态分布环形图和基本信息的渲染 + +#### Scenario: 统计计算使用纯函数 +- **WHEN** OverviewTab 需要计算 totalChecks、upChecks、downChecks +- **THEN** 计算逻辑 SHALL 通过 `utils/stats.ts` 中的纯函数实现,并使用 `useMemo` 缓存结果 + +#### Scenario: OverviewTab props +- **WHEN** OverviewTab 渲染 +- **THEN** 组件 SHALL 接收 `target: TargetStatus`、`trendData: TrendPoint[]`、`trendLoading: boolean` 作为 props + +### Requirement: 记录面板组件化 +记录 Tab SHALL 作为独立组件 `HistoryTab` 实现。 + +#### Scenario: HistoryTab 组件职责 +- **WHEN** 记录 Tab 渲染 +- **THEN** `HistoryTab` 组件 SHALL 负责检查结果表格和分页的渲染 + +#### Scenario: HistoryTab props +- **WHEN** HistoryTab 渲染 +- **THEN** 组件 SHALL 接收 `historyData: HistoryResponse`、`historyLoading: boolean`、`onPageChange: (page: number) => void` 作为 props + +#### Scenario: 历史记录列定义外置 +- **WHEN** HistoryTab 渲染表格 +- **THEN** 列定义 SHALL 从 `constants/history-table-columns.tsx` 导入,不在组件内部定义 + +### Requirement: TrendChart 简化 +TrendChart 组件 SHALL 仅接收数据 props,不处理 loading 状态。 + +#### Scenario: TrendChart 无 loading prop +- **WHEN** TrendChart 渲染 +- **THEN** 组件 SHALL 仅接收 `data: TrendPoint[]` prop,不接收 `loading` prop + +#### Scenario: TrendChart 空数据 +- **WHEN** TrendChart 接收空数组 +- **THEN** 组件 SHALL 显示"暂无趋势数据"占位文本 + +### Requirement: StatusDonut key 修复 +StatusDonut 组件 SHALL 使用语义化的 key。 + +#### Scenario: Pie Cell key +- **WHEN** StatusDonut 渲染 Pie Cell 列表 +- **THEN** 每个 Cell 的 key SHALL 使用 data item 的 `name` 字段,不使用数组索引 + +### Requirement: StatusBar 参数化 +StatusBar 组件 SHALL 支持可配置的格数。 + +#### Scenario: maxSlots prop +- **WHEN** StatusBar 渲染 +- **THEN** 组件 SHALL 接收可选的 `maxSlots` prop(默认 30),根据该值渲染对应数量的格子 + +#### Scenario: 格子渲染逻辑 +- **WHEN** StatusBar 渲染且 samples 数量少于 maxSlots +- **THEN** 多余的格子 SHALL 显示为 empty 状态 + ### Requirement: 时间范围选择器 Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录两个面板的数据。时间选择器 SHALL 分两行显示:第一行为快捷按钮,第二行为日期时间范围选择器。 diff --git a/openspec/specs/target-table/spec.md b/openspec/specs/target-table/spec.md index ef4edf6..166b2c4 100644 --- a/openspec/specs/target-table/spec.md +++ b/openspec/specs/target-table/spec.md @@ -32,7 +32,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立 - **THEN** 分组之间 SHALL 使用 TDesign Space 组件(direction=vertical, size=32px)统一间距 ### Requirement: 表格列定义 -每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。列渲染不使用内联 style。 +每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。列渲染不使用内联 style。列定义 SHALL 通过工厂函数动态生成。 #### Scenario: 状态列 - **WHEN** 表格渲染 @@ -44,7 +44,11 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立 #### Scenario: 类型列 - **WHEN** 表格渲染 -- **THEN** 类型列 SHALL 使用 TDesign Tag 组件(size=small, theme=primary, variant=light-outline)显示类型名称,支持单选筛选 +- **THEN** 类型列 SHALL 使用 TDesign Tag 组件(size=small, theme=primary, variant=light-outline)直接显示 target.type 原始文本,支持单选筛选 + +#### Scenario: 类型筛选器动态生成 +- **WHEN** 表格渲染 +- **THEN** 类型列的筛选器列表 SHALL 从 meta API 返回的 `checkerTypes` 动态生成,包含"全部"选项和每个 checker 类型选项(label 和 value 均为 type 原始文本) #### Scenario: 可用率列 - **WHEN** 表格渲染 @@ -52,7 +56,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立 #### Scenario: 最近状态列 - **WHEN** 表格渲染 -- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染 30 格采样色块,宽度 220px。StatusBar SHALL 通过 CSS 类(`.status-bar-block--up` / `.status-bar-block--down` / `.status-bar-block--empty`)控制色块颜色,不使用内联 style +- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染采样色块,宽度 220px。StatusBar SHALL 通过 CSS 类(`.status-bar-block--up` / `.status-bar-block--down` / `.status-bar-block--empty`)控制色块颜色,不使用内联 style #### Scenario: 延迟列 - **WHEN** 表格渲染 @@ -62,6 +66,32 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立 - **WHEN** 表格渲染 - **THEN** 间隔列 SHALL 显示检查间隔(如 "5s"、"30s"),居中对齐,宽度 72px +### Requirement: 列定义工厂函数 +列定义 SHALL 通过工厂函数生成,接收动态参数。 + +#### Scenario: createTargetTableColumns 函数 +- **WHEN** 需要生成表格列定义 +- **THEN** 系统 SHALL 调用 `createTargetTableColumns(checkerTypes: string[])` 函数,返回 `PrimaryTableCol[]` + +#### Scenario: checkerTypes 为空数组 +- **WHEN** meta API 尚未返回或返回空数组 +- **THEN** 类型列的筛选器 SHALL 仅包含"全部"选项 + +#### Scenario: 列定义缓存 +- **WHEN** TargetBoard 组件渲染 +- **THEN** 列定义 SHALL 通过 `useMemo` 缓存,仅在 `checkerTypes` 变化时重新生成 + +### Requirement: TargetGroup 接收 columns prop +TargetGroup 组件 SHALL 通过 prop 接收列定义,不再直接导入静态常量。 + +#### Scenario: columns prop +- **WHEN** TargetGroup 渲染 +- **THEN** 组件 SHALL 接收 `columns: PrimaryTableCol[]` prop 并传递给 PrimaryTable + +#### Scenario: TargetBoard 传递 columns +- **WHEN** TargetBoard 渲染子组件 +- **THEN** TargetBoard SHALL 调用 `createTargetTableColumns` 生成列定义并传递给每个 TargetGroup + ### Requirement: 默认排序 表格 SHALL 默认按状态降序排列,异常(DOWN)目标排在最前面。 @@ -103,7 +133,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立 - **THEN** 表格 SHALL 设置 size="small"、stripe、hover、bordered ### Requirement: 列定义复用 -所有分组的表格 SHALL 共享同一套列定义常量。 +所有分组的表格 SHALL 共享同一套列定义。 #### Scenario: 列定义提取为常量 - **WHEN** 多个分组表格渲染 diff --git a/openspec/specs/target-type-display/spec.md b/openspec/specs/target-type-display/spec.md deleted file mode 100644 index a4e8f85..0000000 --- a/openspec/specs/target-type-display/spec.md +++ /dev/null @@ -1,42 +0,0 @@ -## Purpose - -定义目标类型(Target Type)的前端显示名称映射系统,支持从后端类型标识符到 TDesign Tag 组件展示的可扩展转换。 - -## Requirements - -### Requirement: 类型显示名称映射 -系统 SHALL 提供目标类型到显示名称的映射,将后端类型标识符转换为 TDesign Tag 组件的展示文本。 - -#### Scenario: HTTP 类型显示 -- **WHEN** 目标类型为 "http" -- **THEN** 前端 SHALL 使用 TDesign Tag 组件(size=small, theme=primary, variant=light-outline)显示 "HTTP" - -#### Scenario: Command 类型显示 -- **WHEN** 目标类型为 "command" -- **THEN** 前端 SHALL 使用 TDesign Tag 组件显示 "CMD" - -#### Scenario: 未知类型处理 -- **WHEN** 目标类型不在映射表中 -- **THEN** 前端 SHALL 将类型名称转换为大写显示在 TDesign Tag 组件中 - -### Requirement: 映射可扩展性 -类型映射系统 SHALL 支持后续新增类型,无需修改多处代码。 - -#### Scenario: 新增类型映射 -- **WHEN** 需要新增目标类型(如 "tcp"、"dns"、"grpc") -- **THEN** 开发者 SHALL 仅需在映射常量中添加一条记录 - -#### Scenario: 映射单一数据源 -- **WHEN** 前端组件需要显示目标类型 -- **THEN** 组件 SHALL 调用统一的映射函数,不直接硬编码映射逻辑 - -### Requirement: 类型安全 -类型映射系统 SHALL 提供类型安全的访问方式。 - -#### Scenario: TypeScript 类型推导 -- **WHEN** 使用映射常量 -- **THEN** TypeScript SHALL 能够推导出正确的类型(使用 `as const`) - -#### Scenario: 运行时安全 -- **WHEN** 传入无效类型 -- **THEN** 系统 SHALL 返回 fallback 值,不抛出异常 diff --git a/src/server/app.ts b/src/server/app.ts index 0b26790..7fda131 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -5,6 +5,7 @@ import { createApiError, jsonResponse } from "./helpers"; import { guardGetHead } from "./middleware"; import { handleHealth } from "./routes/health"; import { handleHistory } from "./routes/history"; +import { handleMeta } from "./routes/meta"; import { handleSummary } from "./routes/summary"; import { handleTargets } from "./routes/targets"; import { handleTrend } from "./routes/trend"; @@ -29,6 +30,10 @@ export function createFetchHandler(options: AppOptions) { return handleHealth(request.method, options.mode); } + if (url.pathname === "/api/meta") { + return handleMetaRoute(request, options.mode); + } + if (url.pathname.startsWith("/api/") && options.store) { return handleApiRoute(url, request, options.store, options.mode); } @@ -78,3 +83,9 @@ function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: Run return jsonResponse(createApiError("API route not found", 404), { method, mode, status: 404 }); } + +function handleMetaRoute(request: Request, mode: RuntimeMode): Response { + const guardResult = guardGetHead(request.method, mode); + if (guardResult) return guardResult; + return handleMeta(request.method, mode); +} diff --git a/src/server/routes/meta.ts b/src/server/routes/meta.ts new file mode 100644 index 0000000..79873bd --- /dev/null +++ b/src/server/routes/meta.ts @@ -0,0 +1,12 @@ +import type { MetaResponse, RuntimeMode } from "../../shared/api"; + +import { checkerRegistry } from "../checker/runner"; +import { jsonResponse } from "../helpers"; + +export function handleMeta(method: string, mode: RuntimeMode): Response { + const response: MetaResponse = { + checkerTypes: checkerRegistry.supportedTypes, + }; + + return jsonResponse(response, { method, mode }); +} diff --git a/src/shared/api.ts b/src/shared/api.ts index cd8713b..b6d9a64 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -33,6 +33,10 @@ export interface HistoryResponse { total: number; } +export interface MetaResponse { + checkerTypes: string[]; +} + export interface RecentSample { durationMs: null | number; timestamp: string; diff --git a/src/web/app.tsx b/src/web/app.tsx index 3bcfdb5..7fe42b3 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -3,7 +3,8 @@ import { Alert, Loading, Typography } from "tdesign-react"; import { SummaryCards } from "./components/SummaryCards"; import { TargetBoard } from "./components/TargetBoard"; import { TargetDetailDrawer } from "./components/TargetDetailDrawer"; -import { useSummary, useTargetDetail, useTargets } from "./hooks/useTargetDetail"; +import { useSummary, useTargets } from "./hooks/use-queries"; +import { useTargetDetail } from "./hooks/use-target-detail"; export function App() { const { data: summary, error: summaryError, isLoading: summaryLoading } = useSummary(); diff --git a/src/web/components/HistoryTab.tsx b/src/web/components/HistoryTab.tsx new file mode 100644 index 0000000..8f1e8ce --- /dev/null +++ b/src/web/components/HistoryTab.tsx @@ -0,0 +1,31 @@ +import { PrimaryTable } from "tdesign-react"; + +import type { HistoryResponse } from "../../shared/api"; + +import { HISTORY_COLUMNS } from "../constants/history-table-columns"; + +interface HistoryTabProps { + historyData: HistoryResponse; + historyLoading: boolean; + onPageChange: (page: number) => void; +} + +export function HistoryTab({ historyData, historyLoading, onPageChange }: HistoryTabProps) { + return ( + { + if (current) onPageChange(current); + }} + pagination={{ + current: historyData.page, + pageSize: historyData.pageSize, + total: historyData.total, + }} + rowKey="timestamp" + /> + ); +} diff --git a/src/web/components/OverviewTab.tsx b/src/web/components/OverviewTab.tsx new file mode 100644 index 0000000..9443968 --- /dev/null +++ b/src/web/components/OverviewTab.tsx @@ -0,0 +1,57 @@ +import { useMemo } from "react"; +import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic } from "tdesign-react"; + +import type { TargetStatus, TrendPoint } from "../../shared/api"; + +import { computeTrendStats } from "../utils/stats"; +import { StatusDonut } from "./StatusDonut"; +import { TrendChart } from "./TrendChart"; + +interface OverviewTabProps { + target: TargetStatus; + trendData: TrendPoint[]; + trendLoading: boolean; +} + +export function OverviewTab({ target, trendData, trendLoading }: OverviewTabProps) { + const { downChecks, totalChecks, upChecks } = useMemo(() => computeTrendStats(trendData), [trendData]); + + return ( + + 统计 + + + + + + + + + + + + + + + + 趋势 + {trendLoading ? : } + + 状态分布 + + + 基本信息 + + + ); +} diff --git a/src/web/components/StatusBar.tsx b/src/web/components/StatusBar.tsx index 4799744..0a94008 100644 --- a/src/web/components/StatusBar.tsx +++ b/src/web/components/StatusBar.tsx @@ -1,10 +1,11 @@ interface StatusBarProps { + maxSlots?: number; samples: Array<{ up: boolean }>; } -export function StatusBar({ samples }: StatusBarProps) { +export function StatusBar({ maxSlots = 30, samples }: StatusBarProps) { const blocks = []; - for (let i = 0; i < 30; i++) { + for (let i = 0; i < maxSlots; i++) { const sample = samples[i]; if (sample) { blocks.push( diff --git a/src/web/components/StatusDonut.tsx b/src/web/components/StatusDonut.tsx index 0e852dd..79fb4ed 100644 --- a/src/web/components/StatusDonut.tsx +++ b/src/web/components/StatusDonut.tsx @@ -28,8 +28,8 @@ export function StatusDonut({ down, up }: StatusDonutProps) { - {data.map((_, index) => ( - + {data.map((item, index) => ( + ))} diff --git a/src/web/components/TargetBoard.tsx b/src/web/components/TargetBoard.tsx index 87a428a..93ca760 100644 --- a/src/web/components/TargetBoard.tsx +++ b/src/web/components/TargetBoard.tsx @@ -1,15 +1,24 @@ +import { useMemo } from "react"; import { Space } from "tdesign-react"; import type { TargetStatus } from "../../shared/api"; +import { createTargetTableColumns } from "../constants/target-table-columns"; +import { useMeta } from "../hooks/use-queries"; import { TargetGroup } from "./TargetGroup"; +const EMPTY_CHECKER_TYPES: string[] = []; + interface TargetBoardProps { onTargetClick: (target: TargetStatus) => void; targets: TargetStatus[]; } export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) { + const { data: meta } = useMeta(); + const checkerTypes = meta?.checkerTypes ?? EMPTY_CHECKER_TYPES; + const columns = useMemo(() => createTargetTableColumns(checkerTypes), [checkerTypes]); + const groups = new Map(); for (const target of targets) { const group = target.group; @@ -29,7 +38,7 @@ export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) { return ( {sortedGroups.map(([name, groupTargets]) => ( - + ))} ); diff --git a/src/web/components/TargetDetailDrawer.tsx b/src/web/components/TargetDetailDrawer.tsx index bda9bbe..091f473 100644 --- a/src/web/components/TargetDetailDrawer.tsx +++ b/src/web/components/TargetDetailDrawer.tsx @@ -1,30 +1,14 @@ import type { TabValue } from "tdesign-react"; import { useCallback, useState } from "react"; -import { - Col, - DateRangePicker, - Descriptions, - Divider, - Drawer, - PrimaryTable, - RadioGroup, - Row, - Skeleton, - Space, - Statistic, - Tabs, - Tag, - Typography, -} from "tdesign-react"; +import { DateRangePicker, Drawer, RadioGroup, Space, Tabs, Tag, Typography } from "tdesign-react"; -import type { CheckResult, HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api"; +import type { HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api"; -import { getTargetTypeDisplay } from "../constants/target-type-display"; import { subtractHours } from "../utils/time"; -import { StatusDonut } from "./StatusDonut"; +import { HistoryTab } from "./HistoryTab"; +import { OverviewTab } from "./OverviewTab"; import { StatusDot } from "./StatusDot"; -import { TrendChart } from "./TrendChart"; interface TargetDetailDrawerProps { historyData: HistoryResponse; @@ -46,43 +30,6 @@ const TIME_SHORTCUTS = [ { hours: 168, label: "7天", value: "7d" }, ] as const; -const HISTORY_COLUMNS = [ - { - cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => ( - - ), - colKey: "matched", - title: "#", - width: 40, - }, - { - cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => { - const d = new Date(row.timestamp); - const pad = (n: number) => String(n).padStart(2, "0"); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; - }, - colKey: "timestamp", - title: "时间", - width: 180, - }, - { - align: "center" as const, - cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => - row.durationMs !== null ? Math.round(row.durationMs) : "-", - colKey: "durationMs", - title: "耗时(ms)", - width: 96, - }, - { - cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => { - const parts = [row.statusDetail, row.failure?.message].filter(Boolean); - return parts.length > 0 ? parts.join(":") : "-"; - }, - colKey: "statusDetail", - title: "详情", - }, -]; - export function TargetDetailDrawer({ historyData, historyLoading, @@ -123,9 +70,6 @@ export function TargetDetailDrawer({ if (!target) return null; const isUp = target.latestCheck?.matched; - const totalChecks = trendData.reduce((sum, p) => sum + p.totalChecks, 0); - const upChecks = trendData.reduce((sum, p) => sum + Math.round((p.availability / 100) * p.totalChecks), 0); - const downChecks = totalChecks - upChecks; return ( {target.name} - {getTargetTypeDisplay(target.type)} + {target.type} } @@ -163,66 +107,16 @@ export function TargetDetailDrawer({ value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined} valueType="YYYY-MM-DD HH:mm" /> + setActiveTab(val)} value={activeTab}> + + + + + + + + - - setActiveTab(val)} value={activeTab}> - - - 统计 - - - - - - - - - - - - - - - - 趋势 - {trendLoading ? : } - - 状态分布 - - - 基本信息 - - - - - - { - if (current) onPageChange(current); - }} - pagination={{ - current: historyData.page, - pageSize: historyData.pageSize, - total: historyData.total, - }} - rowKey="timestamp" - /> - - ); } diff --git a/src/web/components/TargetGroup.tsx b/src/web/components/TargetGroup.tsx index a7ecd39..ad4717b 100644 --- a/src/web/components/TargetGroup.tsx +++ b/src/web/components/TargetGroup.tsx @@ -1,17 +1,19 @@ +import type { PrimaryTableCol } from "tdesign-react"; + import { PrimaryTable } from "tdesign-react"; import type { TargetStatus } from "../../shared/api"; -import { TARGET_TABLE_COLUMNS } from "../constants/target-table-columns"; import { GroupHeader } from "./GroupHeader"; interface TargetGroupProps { + columns: Array>; name: string; onTargetClick: (target: TargetStatus) => void; targets: TargetStatus[]; } -export function TargetGroup({ name, onTargetClick, targets }: TargetGroupProps) { +export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGroupProps) { const up = targets.filter((t) => t.latestCheck?.matched).length; const down = targets.length - up; @@ -21,7 +23,7 @@ export function TargetGroup({ name, onTargetClick, targets }: TargetGroupProps) 加载趋势数据...; - } - +export function TrendChart({ data }: TrendChartProps) { if (data.length === 0) { return
暂无趋势数据
; } diff --git a/src/web/constants/history-table-columns.tsx b/src/web/constants/history-table-columns.tsx new file mode 100644 index 0000000..4f1c1f7 --- /dev/null +++ b/src/web/constants/history-table-columns.tsx @@ -0,0 +1,42 @@ +import type { PrimaryTableCellParams, PrimaryTableCol } from "tdesign-react"; + +import type { CheckResult } from "../../shared/api"; + +import { StatusDot } from "../components/StatusDot"; + +export const HISTORY_COLUMNS: Array> = [ + { + cell: ({ row }: PrimaryTableCellParams) => , + colKey: "matched", + title: "#", + width: 40, + }, + { + cell: ({ row }: PrimaryTableCellParams) => formatTimestamp(row.timestamp), + colKey: "timestamp", + title: "时间", + width: 180, + }, + { + align: "center", + cell: ({ row }: PrimaryTableCellParams) => + row.durationMs !== null ? Math.round(row.durationMs) : "-", + colKey: "durationMs", + title: "耗时(ms)", + width: 96, + }, + { + cell: ({ row }: PrimaryTableCellParams) => { + const parts = [row.statusDetail, row.failure?.message].filter(Boolean); + return parts.length > 0 ? parts.join(":") : "-"; + }, + colKey: "statusDetail", + title: "详情", + }, +]; + +function formatTimestamp(timestamp: string): string { + const date = new Date(timestamp); + const pad = (value: number) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} diff --git a/src/web/constants/target-table-columns.tsx b/src/web/constants/target-table-columns.tsx index 05114b9..62a5fcb 100644 --- a/src/web/constants/target-table-columns.tsx +++ b/src/web/constants/target-table-columns.tsx @@ -7,86 +7,91 @@ import type { TargetStatus } from "../../shared/api"; import { StatusBar } from "../components/StatusBar"; import { StatusDot } from "../components/StatusDot"; import { getAvailabilityProgressColor } from "./color-threshold"; -import { statusFilter, typeFilter } from "./target-table-filters"; +import { statusFilter } from "./target-table-filters"; import { availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters"; -import { getTargetTypeDisplay } from "./target-type-display"; -export const TARGET_TABLE_COLUMNS: Array> = [ - { - align: "center", - cell: ({ row }: PrimaryTableCellParams) => , - colKey: "latestCheck.matched", - filter: statusFilter, - fixed: "left", - title: "#", - width: 60, - }, - { - colKey: "name", - ellipsis: true, - sorter: nameSorter, - sortType: "all", - title: "名称", - }, - { - cell: ({ row }: PrimaryTableCellParams) => ( - - {getTargetTypeDisplay(row.type)} - - ), - colKey: "type", - filter: typeFilter, - title: "类型", - width: 80, - }, - { - cell: ({ row }: PrimaryTableCellParams) => { - const availability = row.stats?.availability; - if (availability === undefined || availability === null) return "-"; - const color = getAvailabilityProgressColor(availability); - return ( - - ); +export function createTargetTableColumns(checkerTypes: string[]): Array> { + return [ + { + align: "center", + cell: ({ row }: PrimaryTableCellParams) => , + colKey: "latestCheck.matched", + filter: statusFilter, + fixed: "left", + title: "#", + width: 60, }, - colKey: "stats.availability", - sorter: availabilitySorter, - sortType: "all", - title: "可用率", - width: 160, - }, - { - cell: ({ row }: PrimaryTableCellParams) => , - colKey: "recentSamples", - title: "最近状态", - width: 220, - }, - { - align: "right", - cell: ({ row }: PrimaryTableCellParams) => { - const ms = row.latestCheck?.durationMs; - if (ms === null || ms === undefined) return -; - const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error"; - return {Math.round(ms)}ms; + { + colKey: "name", + ellipsis: true, + sorter: nameSorter, + sortType: "all", + title: "名称", }, - colKey: "latestCheck.durationMs", - sorter: latencySorter, - sortType: "all", - title: "延迟", - width: 80, - }, - { - align: "center", - colKey: "interval", - title: "间隔", - width: 72, - }, -]; + { + cell: ({ row }: PrimaryTableCellParams) => ( + + {row.type} + + ), + colKey: "type", + filter: createTypeFilter(checkerTypes), + title: "类型", + width: 80, + }, + { + cell: ({ row }: PrimaryTableCellParams) => { + const availability = row.stats?.availability; + if (availability === undefined || availability === null) return "-"; + const color = getAvailabilityProgressColor(availability); + return ( + + ); + }, + colKey: "stats.availability", + sorter: availabilitySorter, + sortType: "all", + title: "可用率", + width: 160, + }, + { + cell: ({ row }: PrimaryTableCellParams) => , + colKey: "recentSamples", + title: "最近状态", + width: 220, + }, + { + align: "right", + cell: ({ row }: PrimaryTableCellParams) => { + const ms = row.latestCheck?.durationMs; + if (ms === null || ms === undefined) return -; + const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error"; + return {Math.round(ms)}ms; + }, + colKey: "latestCheck.durationMs", + sorter: latencySorter, + sortType: "all", + title: "延迟", + width: 80, + }, + { + align: "center", + colKey: "interval", + title: "间隔", + width: 72, + }, + ]; +} -export { statusFilter, typeFilter } from "./target-table-filters"; -export { availabilitySorter, latencySorter, nameSorter, statusSorter } from "./target-table-sorters"; +function createTypeFilter(checkerTypes: string[]): PrimaryTableCol["filter"] { + return { + list: [{ label: "全部", value: "" }, ...checkerTypes.map((type) => ({ label: type, value: type }))], + type: "single", + }; +} diff --git a/src/web/constants/target-table-filters.ts b/src/web/constants/target-table-filters.ts index c52a9af..70d0f22 100644 --- a/src/web/constants/target-table-filters.ts +++ b/src/web/constants/target-table-filters.ts @@ -8,12 +8,3 @@ export const statusFilter: PrimaryTableCol["filter"] = { ], type: "single", }; - -export const typeFilter: PrimaryTableCol["filter"] = { - list: [ - { label: "全部", value: "" }, - { label: "HTTP", value: "http" }, - { label: "CMD", value: "command" }, - ], - type: "single", -}; diff --git a/src/web/constants/target-type-display.ts b/src/web/constants/target-type-display.ts deleted file mode 100644 index 9baee87..0000000 --- a/src/web/constants/target-type-display.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const TARGET_TYPE_DISPLAY = { - command: "CMD", - http: "HTTP", -} as const; - -export type TargetType = keyof typeof TARGET_TYPE_DISPLAY; - -export function getTargetTypeDisplay(type: string): string { - return TARGET_TYPE_DISPLAY[type as TargetType] || type.toUpperCase(); -} diff --git a/src/web/hooks/use-queries.ts b/src/web/hooks/use-queries.ts new file mode 100644 index 0000000..75a039c --- /dev/null +++ b/src/web/hooks/use-queries.ts @@ -0,0 +1,41 @@ +import { useQuery } from "@tanstack/react-query"; + +import type { MetaResponse, SummaryResponse, TargetStatus } from "../../shared/api"; + +const queryKeys = { + meta: () => ["meta"] as const, + summary: () => ["summary"] as const, + targets: () => ["targets"] as const, +}; + +export async function fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + +export function useMeta() { + return useQuery({ + queryFn: () => fetchJson("/api/meta"), + queryKey: queryKeys.meta(), + staleTime: Infinity, + }); +} + +export function useSummary() { + return useQuery({ + queryFn: () => fetchJson("/api/summary"), + queryKey: queryKeys.summary(), + refetchInterval: 8000, + refetchIntervalInBackground: false, + }); +} + +export function useTargets() { + return useQuery({ + queryFn: () => fetchJson("/api/targets"), + queryKey: queryKeys.targets(), + refetchInterval: 8000, + refetchIntervalInBackground: false, + }); +} diff --git a/src/web/hooks/useTargetDetail.ts b/src/web/hooks/use-target-detail.ts similarity index 70% rename from src/web/hooks/useTargetDetail.ts rename to src/web/hooks/use-target-detail.ts index 23789d0..65f01e4 100644 --- a/src/web/hooks/useTargetDetail.ts +++ b/src/web/hooks/use-target-detail.ts @@ -1,26 +1,16 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; -import type { HistoryResponse, SummaryResponse, TargetStatus, TrendPoint } from "../../shared/api"; +import type { HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api"; import { subtractHours } from "../utils/time"; +import { fetchJson, useTargets } from "./use-queries"; -const queryKeys = { +const detailQueryKeys = { history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const, - summary: () => ["summary"] as const, - targets: () => ["targets"] as const, trend: (targetId: number, from: string, to: string) => ["trend", targetId, from, to] as const, }; -export function useSummary() { - return useQuery({ - queryFn: () => fetchJson("/api/summary"), - queryKey: queryKeys.summary(), - refetchInterval: 8000, - refetchIntervalInBackground: false, - }); -} - export function useTargetDetail() { const queryClient = useQueryClient(); const [selectedTargetId, setSelectedTargetId] = useState(null); @@ -29,9 +19,8 @@ export function useTargetDetail() { const [historyPage, setHistoryPage] = useState(1); const { data: targetsData } = useTargets(); - const selectedTarget = - selectedTargetId !== null ? (targetsData?.find((t) => t.id === selectedTargetId) ?? null) : null; + selectedTargetId !== null ? (targetsData?.find((target) => target.id === selectedTargetId) ?? null) : null; const trend = useQuery({ enabled: selectedTargetId !== null && !!timeFrom && !!timeTo, @@ -41,7 +30,7 @@ export function useTargetDetail() { ), queryKey: selectedTargetId !== null && timeFrom && timeTo - ? queryKeys.trend(selectedTargetId, timeFrom, timeTo) + ? detailQueryKeys.trend(selectedTargetId, timeFrom, timeTo) : ["trend", "disabled"], }); @@ -53,7 +42,7 @@ export function useTargetDetail() { ), queryKey: selectedTargetId !== null && timeFrom && timeTo - ? queryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage) + ? detailQueryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage) : ["history", "disabled"], }); @@ -96,18 +85,3 @@ export function useTargetDetail() { trendLoading: trend.isLoading, }; } - -export function useTargets() { - return useQuery({ - queryFn: () => fetchJson("/api/targets"), - queryKey: queryKeys.targets(), - refetchInterval: 8000, - refetchIntervalInBackground: false, - }); -} - -async function fetchJson(url: string): Promise { - const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json() as Promise; -} diff --git a/src/web/utils/stats.ts b/src/web/utils/stats.ts new file mode 100644 index 0000000..8bc9fde --- /dev/null +++ b/src/web/utils/stats.ts @@ -0,0 +1,23 @@ +import type { TrendPoint } from "../../shared/api"; + +export interface TrendStats { + downChecks: number; + totalChecks: number; + upChecks: number; +} + +export function computeTrendStats(points: TrendPoint[]): TrendStats { + let totalChecks = 0; + let upChecks = 0; + + for (const point of points) { + totalChecks += point.totalChecks; + upChecks += Math.round((point.availability / 100) * point.totalChecks); + } + + return { + downChecks: totalChecks - upChecks, + totalChecks, + upChecks, + }; +} diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index e5acafe..d119471 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -3,7 +3,13 @@ import { mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { HealthResponse, HistoryResponse, SummaryResponse, TargetStatus } from "../../src/shared/api"; +import type { + HealthResponse, + HistoryResponse, + MetaResponse, + SummaryResponse, + TargetStatus, +} from "../../src/shared/api"; import { createFetchHandler, type StaticAssets } from "../../src/server/app"; import { checkerRegistry } from "../../src/server/checker/runner"; @@ -151,6 +157,32 @@ describe("API 路由", () => { expect(tB.latestCheck).toBeNull(); }); + test("/api/meta 返回 checker 类型列表", async () => { + const response = fetchHandler(new Request("http://localhost/api/meta")); + const body = (await response.json()) as MetaResponse; + + expect(response.status).toBe(200); + expect(body.checkerTypes).toEqual(checkerRegistry.supportedTypes); + expect(body.checkerTypes).toContain("http"); + expect(body.checkerTypes).toContain("command"); + }); + + test("/api/meta HEAD 请求返回 headers 无 body", async () => { + const response = fetchHandler(new Request("http://localhost/api/meta", { method: "HEAD" })); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(body).toBe(""); + }); + + test("/api/meta 不支持的 method 返回 405", () => { + const response = fetchHandler(new Request("http://localhost/api/meta", { method: "POST" })); + + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("GET, HEAD"); + }); + test("/api/targets/:id/history 返回历史记录", async () => { const targets = store.getTargets(); const from = "2024-01-01T00:00:00.000Z"; diff --git a/tests/web/constants/target-table-columns.test.ts b/tests/web/constants/target-table-columns.test.ts new file mode 100644 index 0000000..40f99a6 --- /dev/null +++ b/tests/web/constants/target-table-columns.test.ts @@ -0,0 +1,84 @@ +import type { PrimaryTableCellParams, PrimaryTableCol } from "tdesign-react"; + +import { describe, expect, test } from "bun:test"; + +import type { TargetStatus } from "../../../src/shared/api"; + +import { createTargetTableColumns } from "../../../src/web/constants/target-table-columns"; + +interface TableFilter { + list?: Array<{ label: string; value: string }>; + type?: string; +} + +function getColumn(columns: Array>, colKey: string): PrimaryTableCol { + const column = columns.find((item) => item.colKey === colKey); + expect(column).toBeDefined(); + return column!; +} + +function makeTarget(overrides: Partial = {}): TargetStatus { + return { + group: "default", + id: 1, + interval: "5s", + latestCheck: null, + name: "test", + recentSamples: [], + stats: { availability: 100, totalChecks: 0 }, + target: "https://example.com", + type: "http", + ...overrides, + }; +} + +describe("createTargetTableColumns", () => { + test("生成 7 个目标表格列", () => { + const columns = createTargetTableColumns(["http", "command"]); + + expect(columns.map((column) => column.colKey)).toEqual([ + "latestCheck.matched", + "name", + "type", + "stats.availability", + "recentSamples", + "latestCheck.durationMs", + "interval", + ]); + }); + + test("根据 checkerTypes 生成类型筛选器", () => { + const typeColumn = getColumn(createTargetTableColumns(["http", "command", "tcp"]), "type"); + const filter = typeColumn.filter as TableFilter; + + expect(filter.type).toBe("single"); + expect(filter.list).toEqual([ + { label: "全部", value: "" }, + { label: "http", value: "http" }, + { label: "command", value: "command" }, + { label: "tcp", value: "tcp" }, + ]); + }); + + test("checkerTypes 为空时只保留全部选项", () => { + const typeColumn = getColumn(createTargetTableColumns([]), "type"); + const filter = typeColumn.filter as TableFilter; + + expect(filter.list).toEqual([{ label: "全部", value: "" }]); + }); + + test("类型列直接渲染原始 type 文本", () => { + const typeColumn = getColumn(createTargetTableColumns(["tcp"]), "type"); + const renderCell = typeColumn.cell as (params: PrimaryTableCellParams) => { + props: { children: unknown }; + }; + const element = renderCell({ + col: typeColumn, + colIndex: 2, + row: makeTarget({ type: "tcp" }), + rowIndex: 0, + }); + + expect(element.props.children).toBe("tcp"); + }); +}); diff --git a/tests/web/constants/target-table-filters.test.ts b/tests/web/constants/target-table-filters.test.ts index 3d39daf..42ecdd9 100644 --- a/tests/web/constants/target-table-filters.test.ts +++ b/tests/web/constants/target-table-filters.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { statusFilter, typeFilter } from "../../../src/web/constants/target-table-filters"; +import { statusFilter } from "../../../src/web/constants/target-table-filters"; describe("target-table-filters", () => { describe("statusFilter", () => { @@ -14,16 +14,4 @@ describe("target-table-filters", () => { expect(list[2]!.label).toBe("DOWN"); }); }); - - describe("typeFilter", () => { - test("包含全部选项", () => { - expect(typeFilter).toBeDefined(); - expect(typeFilter!.type).toBe("single"); - const list = typeFilter!.list!; - expect(list).toHaveLength(3); - expect(list[0]!.label).toBe("全部"); - expect(list[1]!.label).toBe("HTTP"); - expect(list[2]!.label).toBe("CMD"); - }); - }); }); diff --git a/tests/web/constants/target-type-display.test.ts b/tests/web/constants/target-type-display.test.ts deleted file mode 100644 index 752f309..0000000 --- a/tests/web/constants/target-type-display.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { getTargetTypeDisplay, TARGET_TYPE_DISPLAY } from "../../../src/web/constants/target-type-display"; - -describe("target-type-display", () => { - describe("TARGET_TYPE_DISPLAY 常量", () => { - test("定义了 http 类型映射", () => { - expect(TARGET_TYPE_DISPLAY.http).toBe("HTTP"); - }); - - test("定义了 command 类型映射", () => { - expect(TARGET_TYPE_DISPLAY.command).toBe("CMD"); - }); - }); - - describe("getTargetTypeDisplay 函数", () => { - test("http 类型返回 HTTP", () => { - expect(getTargetTypeDisplay("http")).toBe("HTTP"); - }); - - test("command 类型返回 CMD", () => { - expect(getTargetTypeDisplay("command")).toBe("CMD"); - }); - - test("未知类型返回大写形式", () => { - expect(getTargetTypeDisplay("tcp")).toBe("TCP"); - expect(getTargetTypeDisplay("dns")).toBe("DNS"); - expect(getTargetTypeDisplay("grpc")).toBe("GRPC"); - }); - - test("空字符串返回空字符串", () => { - expect(getTargetTypeDisplay("")).toBe(""); - }); - - test("已大写的类型保持大写", () => { - expect(getTargetTypeDisplay("HTTP")).toBe("HTTP"); - expect(getTargetTypeDisplay("CMD")).toBe("CMD"); - }); - }); -}); diff --git a/tests/web/utils/stats.test.ts b/tests/web/utils/stats.test.ts new file mode 100644 index 0000000..cf6a8a8 --- /dev/null +++ b/tests/web/utils/stats.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; + +import type { TrendPoint } from "../../../src/shared/api"; + +import { computeTrendStats } from "../../../src/web/utils/stats"; + +describe("computeTrendStats", () => { + test("空趋势返回 0 统计", () => { + expect(computeTrendStats([])).toEqual({ downChecks: 0, totalChecks: 0, upChecks: 0 }); + }); + + test("汇总总检查、正常和异常数量", () => { + const points: TrendPoint[] = [ + { availability: 80, avgDurationMs: 100, hour: "2025-01-01T00:00:00.000Z", totalChecks: 10 }, + { availability: 40, avgDurationMs: 200, hour: "2025-01-01T01:00:00.000Z", totalChecks: 5 }, + ]; + + expect(computeTrendStats(points)).toEqual({ downChecks: 5, totalChecks: 15, upChecks: 10 }); + }); + + test("按每个趋势点四舍五入正常数量", () => { + const points: TrendPoint[] = [ + { availability: 33.3, avgDurationMs: null, hour: "2025-01-01T00:00:00.000Z", totalChecks: 3 }, + { availability: 66.7, avgDurationMs: null, hour: "2025-01-01T01:00:00.000Z", totalChecks: 3 }, + ]; + + expect(computeTrendStats(points)).toEqual({ downChecks: 3, totalChecks: 6, upChecks: 3 }); + }); +}); diff --git a/tests/web/utils/time.test.ts b/tests/web/utils/time.test.ts new file mode 100644 index 0000000..894c56d --- /dev/null +++ b/tests/web/utils/time.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; + +import { subtractHours } from "../../../src/web/utils/time"; + +describe("subtractHours", () => { + test("正常扣减小时", () => { + const result = subtractHours(new Date("2025-01-15T12:00:00.000Z"), 3); + + expect(result.toISOString()).toBe("2025-01-15T09:00:00.000Z"); + }); + + test("跨天扣减", () => { + const result = subtractHours(new Date("2025-01-15T02:00:00.000Z"), 6); + + expect(result.toISOString()).toBe("2025-01-14T20:00:00.000Z"); + }); + + test("跨月扣减", () => { + const result = subtractHours(new Date("2025-03-01T01:00:00.000Z"), 2); + + expect(result.toISOString()).toBe("2025-02-28T23:00:00.000Z"); + }); + + test("扣减 0 小时返回相同时间", () => { + const result = subtractHours(new Date("2025-01-15T12:00:00.000Z"), 0); + + expect(result.toISOString()).toBe("2025-01-15T12:00:00.000Z"); + }); +});