1
0

refactor: 前端视觉重构 — Layout/HeadMenu 骨架、SummaryCards 合并、Card 分组、Drawer 概览重设计

This commit is contained in:
2026-05-14 15:51:39 +08:00
parent 1c5cfafda6
commit c61a4a6091
20 changed files with 530 additions and 427 deletions

8
.claude/settings.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"tdesign-mcp-server": {
"command": "bunx",
"args": ["tdesign-mcp-server@latest"]
}
}
}

View File

@@ -541,7 +541,7 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs)
| 语言 | TypeScript 6 | 类型安全 |
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
| 数据层 | TanStack Query (React Query) + React Query Devtools | 服务端状态管理与自动轮询 |
| 图表 | Recharts | 拨测趋势折线图与状态环状图 |
| 图表 | Recharts | 拨测趋势折线图 |
| 路由 | 无(单页面 Dashboard | 仅需 Drawer/Tab 做页面内导航 |
**不引入的依赖**React Router单页面场景不需要、状态管理库TanStack Query 即服务端状态层,组件内用 `useState` 足够、Vite已由 Bun 原生 fullstack 替代)
@@ -553,17 +553,17 @@ main.tsx
└── StrictMode
└── ErrorBoundaryReact 错误边界)
└── QueryClientProviderTanStack Query 全局挂载)
├── App根组件
│ ├── useDashboard() ─── GET /api/dashboard?window=24h&recentLimit=308s 轮询)
│ ├── SummaryCards总览统计卡片
│ └── TargetBoard目标列表
├── App根组件Layout + HeadMenu 骨架
│ ├── useDashboard() ─── GET /api/dashboard?window=24h&recentLimit=308s 轮询dataUpdatedAt 倒计时
│ ├── SummaryCards单 Card 内嵌居中 Statistic无 shadow
│ └── TargetBoard目标列表Space 24px 间距
│ ├── DashboardResponse.targets
│ ├── useMeta() ───── GET /api/meta应用生命周期内缓存
│ └── TargetGroup[]按 group 字段分组
│ └── TargetGroup[]Card 包裹 PrimaryTableheaderBordered
│ └── PrimaryTable ← createTargetTableColumns(checkerTypes)
│ └── TargetDetailDrawer目标详情抽屉
│ └── TargetDetailDrawer目标详情抽屉width=52%
│ └── useTargetDetail() ── 按需发起 metrics + history 查询
│ ├── OverviewTab → Statistic + TrendChart + StatusDonut + Descriptions
│ ├── OverviewTab → Descriptions直接展示+ 4×2 统计卡片 + TrendChart
│ └── HistoryTab → PrimaryTable分页历史记录
└── ReactQueryDevtools开发工具仅开发环境
```
@@ -679,21 +679,19 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
#### 现有组件清单
| 组件 | 文件 | 用途 |
| -------------------- | ----------------------------------- | ----------------------------------------- |
| `App` | `app.tsx` | 根组件,编排全局状态与布局 |
| `ErrorBoundary` | `components/ErrorBoundary.tsx` | React 错误边界,捕获渲染异常并展示降级 UI |
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(全部/正常/异常) |
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表 |
| `TargetGroup` | `components/TargetGroup.tsx` | 单个分组标题 + PrimaryTable |
| `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` | 圆形状态指示点(绿/红) |
| `StatusBar` | `components/StatusBar.tsx` | 最近采样状态条(多色块) |
| `GroupHeader` | `components/GroupHeader.tsx` | 分组标题(名称 + 统计) |
| 组件 | 文件 | 用途 |
| -------------------- | ----------------------------------- | ----------------------------------------------------------- |
| `App` | `app.tsx` | 根组件,Layout + HeadMenu 骨架、刷新倒计时、Skeleton 加载 |
| `ErrorBoundary` | `components/ErrorBoundary.tsx` | React 错误边界,捕获渲染异常并展示降级 UI |
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(单 Card 内嵌居中 Statistic无 shadow |
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表Space 24px 间距) |
| `TargetGroup` | `components/TargetGroup.tsx` | 单个分组 Cardtitle+actions+headerBordered+ PrimaryTable |
| `TargetDetailDrawer` | `components/TargetDetailDrawer.tsx` | 目标详情抽屉width=52%)、时间选择器单行布局和 Tab 切换 |
| `OverviewTab` | `components/OverviewTab.tsx` | 目标详情概览(Descriptions 直接展示 + 4×2 统计卡片 + 趋势) |
| `HistoryTab` | `components/HistoryTab.tsx` | 目标历史记录表格和分页 |
| `TrendChart` | `components/TrendChart.tsx` | Recharts 双轴折线图(耗时/可用率) |
| `StatusDot` | `components/StatusDot.tsx` | 圆形状态指示点(绿/红) |
| `StatusBar` | `components/StatusBar.tsx` | 最近采样状态条(多色块 + Tooltip 提示时间和状态) |
### 2.5 新增功能开发步骤

View File

@@ -31,11 +31,11 @@ styles.css SHALL 定义 10 级可用率色阶 CSS 自定义属性,使用项目
- **THEN** 色阶 SHALL 从红色0-30%经橙色30-60%过渡到绿色60-100%
### Requirement: 辅助工具类
styles.css SHALL 定义前端组件复用的工具类。
styles.css SHALL 定义前端组件复用的工具类,包含页面布局相关类
#### Scenario: 文本禁用色类
- **WHEN** 延迟列无数据需要显示占位符
- **THEN** 组件 SHALL 使用 `.text-disabled`color: `--td-text-color-disabled`,不使用内联 style
- **THEN** 组件 SHALL 使用 `.text-disabled`color: `--td-text-color-disabled`
#### Scenario: 等宽数字类
- **WHEN** 数值需要等宽显示
@@ -43,26 +43,70 @@ styles.css SHALL 定义前端组件复用的工具类。
#### Scenario: 延迟色值类
- **WHEN** 延迟数值渲染
- **THEN** 组件 SHALL 使用 `.latency-ok`color: `--td-success-color`)、`.latency-warn`color: `--td-warning-color`)或 `.latency-error`color: `--td-error-color`)类,不使用内联 style
- **THEN** 组件 SHALL 使用 `.latency-ok``.latency-warn``.latency-error`
#### Scenario: 延迟值容器类
- **WHEN** 延迟数值需要固定宽度对齐
- **THEN** 组件 SHALL 使用 `.latency-value`display: inline-block; min-width: 7ch; white-space: nowrap
#### Scenario: 全宽布局类
- **WHEN** 组件需要占满父容器宽度
- **THEN** 组件 SHALL 使用 `.full-width`width: 100%,不使用内联 style
- **THEN** 组件 SHALL 使用 `.full-width`width: 100%
#### Scenario: 可点击表格类
- **WHEN** PrimaryTable 行支持点击交互
- **THEN** 表格 SHALL 使用 `.clickable-table`cursor: pointer,不使用内联 style
- **THEN** 表格 SHALL 使用 `.clickable-table`cursor: pointer
#### Scenario: Tab 面板内边距类
- **WHEN** Drawer 内 Tabs 面板需要内边距
- **THEN** TabPanel SHALL 使用 `className="tab-panel-padded"` prop 传入类名,不通过入侵 TDesign 内部类名覆盖
- **THEN** TabPanel SHALL 使用 `className="tab-panel-padded"` prop 传入类名
#### Scenario: 内容区居中类
- **WHEN** Dashboard 内容区需要居中且限制最大宽度
- **THEN** 内容区 SHALL 使用 `.dashboard-content`max-width: 1400px; margin: 0 auto; padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl)
#### Scenario: 页面背景色
- **WHEN** Dashboard 页面渲染
- **THEN** `.dashboard` 类 SHALL 设置 background: var(--td-bg-color-page)min-height: 100vhwidth: 100%
#### Scenario: 品牌标识类
- **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)
#### Scenario: 刷新状态类
- **WHEN** HeadMenu operations 区域渲染刷新倒计时
- **THEN** 容器 SHALL 使用 `.dashboard-refresh-status`display: inline-flex; align-items: center; margin-right: var(--td-comp-margin-xxl)
#### Scenario: SummaryCard 居中类
- **WHEN** SummaryCards 内 Statistic 需要居中
- **THEN** Statistic 所在的 Col SHALL 使用 `.summary-stat-col`text-align: center
#### Scenario: SummaryCards 行间距类
- **WHEN** SummaryCards 容器需要与下方内容保持间距
- **THEN** 容器 SHALL 使用 `.summary-cards-row`margin-bottom: var(--td-comp-margin-xl)
#### Scenario: Drawer 时间控件单行类
- **WHEN** Drawer 时间选择器需要单行布局
- **THEN** 控件容器 SHALL 使用 `.drawer-time-controls`display: flex; align-items: center; gap: var(--td-comp-margin-m); width: 100%),日期选择器 SHALL 使用 `.drawer-date-range`flex: 1; min-width: 360px
#### Scenario: Drawer 时间控件响应式
- **WHEN** 视口宽度 ≤ 768px
- **THEN** `.drawer-time-controls` SHALL 启用 flex-wrap`.drawer-date-range` min-width 改为 100%
#### Scenario: 概览统计卡片类
- **WHEN** Drawer 概览统计区渲染
- **THEN** 统计卡片 SHALL 使用 `.overview-stat-card`background: var(--td-bg-color-container-hover)),内部项 SHALL 使用 `.overview-stat-item`display: flex; align-items: center; justify-content: space-between数值 SHALL 使用 `.overview-stat-value`font-size: var(--td-font-size-body-medium); text-align: right
### Requirement: 异常行背景类
styles.css SHALL 定义 DOWN 行的背景色,使用安全选择器且不使用 `!important`
styles.css SHALL 定义 DOWN 行的背景色和左侧竖线,使用安全选择器且不使用 `!important`
#### Scenario: DOWN 行背景色
- **WHEN** 表格行标记为 DOWN 状态
- **THEN** 行 SHALL 通过 `.t-table tr.row-down` 选择器获得浅红色背景(引用 `--td-error-color-light` token不使用 `!important`
- **THEN** 行 SHALL 通过 `.t-table tr.row-down` 选择器获得浅红色背景
#### Scenario: DOWN 行左侧竖线
- **WHEN** 表格行标记为 DOWN 状态
- **THEN** 行 SHALL 通过 `.t-table tr.row-down` 选择器获得 `border-left: 3px solid var(--td-error-color)`
#### Scenario: DOWN 行 hover 状态
- **WHEN** 鼠标悬停在 DOWN 行上

View File

@@ -0,0 +1,40 @@
## Purpose
定义 Dashboard 页面骨架布局:顶部导航栏(含品牌标识和刷新倒计时)、内容区域居中与最大宽度、页面背景色。
## Requirements
### Requirement: 页面骨架布局
Dashboard SHALL 使用 TDesign Layout 组件体系构建页面骨架,包含顶部导航栏和内容区域。
#### Scenario: Layout 结构
- **WHEN** Dashboard 页面渲染
- **THEN** 页面 SHALL 使用 TDesign `Layout` 组件包裹 `Layout.Header``Layout.Content`
#### Scenario: 顶部导航栏
- **WHEN** Dashboard 页面渲染
- **THEN** `Layout.Header` SHALL 内嵌 TDesign `HeadMenu` 组件,`logo` prop 渲染品牌名 "DiAL" 和副标题 "统一拨测平台"(水平排列),`operations` prop 渲染数据刷新倒计时文字
#### Scenario: 刷新倒计时
- **WHEN** Dashboard 数据已成功获取dataUpdatedAt > 0
- **THEN** HeadMenu operations 区域 SHALL 展示刷新倒计时文本(如"下一次刷新5秒"),使用 TDesign Typography.Texttheme="secondary"),基于 React Query `dataUpdatedAt` 和轮询间隔常量计算
#### Scenario: 刷新中状态
- **WHEN** Dashboard 正在重新获取数据isFetching=true 且 isLoading=false
- **THEN** 刷新倒计时文本 SHALL 展示为"刷新中..."
#### Scenario: 首次加载状态
- **WHEN** Dashboard 尚未获取过数据dataUpdatedAt = 0
- **THEN** 刷新倒计时文本 SHALL 展示为"等待首次刷新"
#### Scenario: 刷新倒计时位置
- **WHEN** HeadMenu 渲染
- **THEN** operations 区域 SHALL 使用右侧 margin 向内收缩,避免紧贴浏览器右边缘
#### Scenario: 内容区域居中
- **WHEN** Dashboard 内容区渲染
- **THEN** `Layout.Content` 内部 SHALL 使用 CSS 类限制最大宽度max-width: 1400px并水平居中
#### Scenario: 页面背景色
- **WHEN** Dashboard 页面渲染
- **THEN** 页面背景色 SHALL 使用 `var(--td-bg-color-page)`,内容卡片浮于浅灰背景之上

View File

@@ -1,6 +1,6 @@
## Purpose
定义拨测系统前端 Dashboard 页面:总览统计卡片(含数据新鲜度)、Dashboard 数据查询、页面标题、加载和错误状态处理。分组表格布局见 `target-table`,目标详情 Drawer 见 `target-detail-drawer`,数据轮询和缓存见 `tanstack-query-data-layer`
定义拨测系统前端 Dashboard 页面总览统计卡片、Dashboard 数据查询、加载和错误状态处理。页面骨架布局见 `dashboard-layout`分组表格布局见 `target-table`,目标详情 Drawer 见 `target-detail-drawer`,数据轮询和缓存见 `tanstack-query-data-layer`
## Requirements
@@ -20,41 +20,26 @@ Dashboard SHALL 通过 `GET /api/dashboard` 获取首屏总览统计和目标列
- **THEN** 前端 SHALL 继续通过 `GET /api/meta` 独立查询 checkerTypes
### Requirement: 总览统计卡片
Dashboard SHALL 在页面顶部使用 TDesign Statistic 组件展示总览统计,包含总目标数、正常数、异常数和窗口异常事件数,并展示数据新鲜度
Dashboard SHALL 在页面顶部使用单个 TDesign Card 组件内嵌一行居中的 Statistic 展示总览统计,包含总目标数、正常数、异常数和窗口异常事件数。
#### Scenario: 展示统计卡片
- **WHEN** 用户打开 Dashboard 页面
- **THEN** 页面顶部 SHALL 使用 TDesign Row/Col 布局展示 4 个 TDesign Card + Statistic 组合全部目标数color=blue、正常目标数color=green、异常目标数color=red、24h 异常事件数color=orange
- **THEN** 页面顶部 SHALL 使用单个 TDesign Card无 shadow、无 bordered内嵌 TDesign Row/Col 布局展示 4 个居中的 Statistic全部目标数color=blue、正常目标数color=green、异常目标数color=red、24h 异常事件数color=orange
#### Scenario: 指标居中显示
- **WHEN** SummaryCards 渲染
- **THEN** 每个 Statistic 所在的 Col SHALL 使用 `.summary-stat-col` 类实现标题和数字居中对齐
#### Scenario: 异常事件数据来源
- **WHEN** SummaryCards 渲染 24h 异常事件数
- **THEN** 该数值 SHALL 使用 DashboardResponse.summary.incidents 字段,标题 SHALL 基于当前 window 展示为"24h 异常事件数"
#### Scenario: 展示数据新鲜度
- **WHEN** Summary 数据包含 lastCheckTime
- **THEN** 统计卡片行底部 SHALL 展示相对时间文本(如"最后更新: 3秒前"),使用 TDesign Typography.Texttheme="secondary"
#### Scenario: 数据新鲜度警告
- **WHEN** lastCheckTime 距当前时间超过 60 秒
- **THEN** 相对时间文本 SHALL 使用警告色(--td-warning-color
#### Scenario: 无检查时间
- **WHEN** Summary 数据 lastCheckTime 为 null
- **THEN** 数据新鲜度 SHALL 展示为"尚无检查数据"或等价占位文本
### Requirement: 页面标题
Dashboard 页面 SHALL 使用 TDesign Typography 组件渲染标题和副标题。
#### Scenario: 页面标题渲染
- **WHEN** Dashboard 页面渲染
- **THEN** 页面标题 SHALL 使用 TDesign Typography.Title 组件level="h1")渲染"DiAL",副标题 SHALL 使用 Typography.Text 组件theme="secondary")渲染"统一拨测平台"
### Requirement: 页面加载与错误状态
Dashboard SHALL 使用 TDesign 组件正确处理加载状态和 API 错误。
Dashboard SHALL 使用 TDesign Skeleton 组件处理首次加载状态,使用 Alert 处理错误。
#### Scenario: 首次加载
- **WHEN** 页面首次加载且数据尚未返回
- **THEN** 表格 SHALL 显示 TDesign Loading 加载状态
- **THEN** 页面 SHALL 使用 TDesign Skeleton 组件animation="gradient")展示页面骨架,模拟 Summary 区域和 Table 区域的大致结构
#### Scenario: API 请求失败
- **WHEN** 前端 API 请求失败

View File

@@ -1,6 +1,6 @@
## Purpose
定义目标详情 Drawer时间范围筛选TDesign RadioGroup + DateRangePicker含快捷按钮联动概览和记录面板、Tabs 组织概览/记录两个面板、Metrics 数据查询 Hook、多维度统计图表2×4 布局)和分页检查结果列表。
定义目标详情 Drawer时间范围筛选TDesign RadioGroup + DateRangePicker 单行布局含快捷按钮联动概览和记录面板、Tabs 组织概览/记录两个面板、Metrics 数据查询 Hook、多维度统计图表4×2 Card 布局)和分页检查结果列表。
## Requirements
@@ -9,7 +9,7 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示
#### Scenario: 打开 Drawer
- **WHEN** 用户点击某个目标表格行
- **THEN** 系统 SHALL 从右侧滑出 Drawerplacement="right"),宽度为视口 60%
- **THEN** 系统 SHALL 从右侧滑出 Drawerplacement="right"),宽度为 52%
#### Scenario: Drawer 标题栏
- **WHEN** Drawer 渲染
@@ -36,15 +36,11 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示
- **THEN** 时间选择器、Tabs 等区块之间的间距 SHALL 通过 TDesign Space 组件direction="vertical", size={16})统一管理,不使用内联 style 的 marginBottom
### Requirement: 概览面板组件化
概览 Tab SHALL 作为独立组件 `OverviewTab` 实现,展示多维度统计、趋势图、状态分布和基本信息。
概览 Tab SHALL 作为独立组件 `OverviewTab` 实现,展示基本信息、多维度统计(左右布局卡片)和趋势图。不再包含状态分布环形图
#### Scenario: OverviewTab 组件职责
- **WHEN** 概览 Tab 渲染
- **THEN** `OverviewTab` 组件 SHALL 负责多维度统计卡片(2×4 布局)趋势图(延迟范围面积图+异常标记点)、状态分布环形图和基本信息的渲染
#### Scenario: 统计计算不再使用 computeTrendStats
- **WHEN** OverviewTab 需要 totalChecks、upChecks、downChecks
- **THEN** SHALL 直接使用 metricsData.stats 中的 totalChecks、upChecks、downChecks 字段,`computeTrendStats` 工具函数 SHALL 被删除
- **THEN** `OverviewTab` 组件 SHALL 负责基本信息(直接展示 Descriptions多维度统计卡片(4×2 左右布局)趋势图的渲染
#### Scenario: OverviewTab props
- **WHEN** OverviewTab 渲染
@@ -76,13 +72,6 @@ TrendChart 组件 SHALL 仅接收数据 props不处理 loading 状态。
- **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 支持可配置的格数。
@@ -118,7 +107,7 @@ StatusBar 组件 SHALL 支持可配置的格数。
- **THEN** 系统 MAY 清理 metrics 和 history 查询缓存,避免旧目标数据残留
### Requirement: 时间范围选择器
Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录两个面板的数据。时间选择器 SHALL 分两行显示:第一行为快捷按钮,第二行为日期时间范围选择器。
Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录两个面板的数据。
#### Scenario: 快捷时间按钮
- **WHEN** Drawer 渲染
@@ -146,7 +135,7 @@ Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录
#### Scenario: DateRangePicker 全宽显示
- **WHEN** Drawer 渲染
- **THEN** DateRangePicker SHALL 通过 CSS 类 `.full-width` 占满时间选择区第二行的宽度,不使用内联 style 的 width: 100%
- **THEN** DateRangePicker SHALL 通过 CSS 类 `.full-width` 占满时间选择区的宽度,不使用内联 style 的 width: 100%
#### Scenario: 默认时间范围
- **WHEN** Drawer 打开
@@ -168,31 +157,31 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。TabP
- **THEN** TabPanel SHALL 通过 `className` prop 传入自定义类名(`tab-panel-padded`)控制内边距,不通过入侵 TDesign 内部类名(`.t-tab-panel`)覆盖
### Requirement: 概览面板
概览 Tab SHALL 按区域展示多维度统计趋势图、状态分布和基本信息
概览 Tab SHALL 按区域展示基本信息、多维度统计趋势图。
#### Scenario: 区域排列顺序
- **WHEN** 概览面板渲染
- **THEN** 面板 SHALL 按以下顺序展示区域:统计 → 趋势 → 状态分布 → 基本信息,每个区域前 SHALL 显示 TDesign Divideralign="left")作为小标题
- **THEN** 面板 SHALL 按以下顺序展示区域:基本信息 → 统计 → 趋势,每个区域前 SHALL 显示 TDesign Divideralign="left")作为小标题
#### Scenario: 统计区多维度布局
#### Scenario: 基本信息直接展示
- **WHEN** 概览面板渲染
- **THEN** 面板 SHALL 在"统计"区域使用 2 行 × 4 列的 TDesign Row/Col + Statistic 布局第一行为可用率suffix="%"、平均延迟suffix="ms"、P95 延迟suffix="ms")、检查总数;第二行为 MTTR动态单位、最长故障动态单位、故障次数suffix="次"、连续正常suffix="次",固定标题"连续正常",当目标当前处于异常状态时值为 0
- **THEN** 面板 SHALL 在"基本信息"区域直接使用 TDesign Descriptions 组件展示配置信息(不折叠
#### Scenario: P99 暂不展示
- **WHEN** metricsData.stats 包含 p99DurationMs
- **THEN** 当前 2×4 统计区 SHALL 展示 P99 延迟
#### Scenario: 基本信息内容
- **WHEN** 概览面板渲染
- **THEN** Descriptions SHALL 展示:目标地址、检查间隔、最新检查时间、状态详情
#### Scenario: MTTR 和最长故障动态单位
- **WHEN** MTTR 或最长故障值小于 60000ms
- **THEN** SHALL 以秒为单位展示suffix="秒"
- **WHEN** 值大于等于 60000ms 且小于 3600000ms
- **THEN** SHALL 以分钟为单位展示suffix="分钟"
- **WHEN** 值大于等于 3600000ms
- **THEN** SHALL 以小时为单位展示suffix="小时"
#### Scenario: 统计区左右布局卡片
- **WHEN** 概览面板渲染且有统计数据
- **THEN** 面板 SHALL 在"统计"区域使用 4 列 × 2 行的 Row/Col 布局,每个统计项使用 Card 包裹Card 内标题左对齐、数值右对齐,数值使用普通文本字号
#### Scenario: 统计区数据来源
- **WHEN** 统计区渲染
- **THEN** 第一行和第二行数据 SHALL 来自 metricsData.stats
#### Scenario: 统计区内容
- **WHEN** 概览面板渲染
- **THEN** 统计区 SHALL 展示可用率suffix="%"、平均延迟suffix="ms"、P95 延迟suffix="ms"、检查总数、MTTR动态单位、最长故障动态单位、故障次数suffix="次"、连续正常suffix="次"
#### Scenario: 趋势图
- **WHEN** 概览面板渲染且 metricsData.trend 可用
- **THEN** 面板 SHALL 在"趋势"区域展示 TrendChart 组件
#### Scenario: 统计区加载状态
- **WHEN** metricsData 正在加载
@@ -200,39 +189,25 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。TabP
#### Scenario: 统计区无数据
- **WHEN** metricsData 为 null 且未处于加载状态
- **THEN** 统计区 SHALL 展示占位状态,不从其他数据源反推统计值
#### Scenario: 延迟类指标无数据
- **WHEN** metricsData.stats 中 avgDurationMs 或 p95DurationMs 为 null
- **THEN** 对应 Statistic SHALL 展示值为 0 且不带单位后缀TDesign Statistic value 仅接受 number无数据时通过缺省 suffix 区分)
#### Scenario: 趋势图延迟范围面积
- **WHEN** 概览面板渲染且 metricsData.trend 可用
- **THEN** 趋势图 SHALL 使用 recharts Area 组件渲染 minDurationMs 到 maxDurationMs 的延迟范围(半透明品牌色填充),叠加 avgDurationMs 实线
#### Scenario: 趋势图时间轴标签本地化
- **WHEN** 趋势图渲染 X 轴标签
- **THEN** 前端 SHALL 使用 `toLocaleTimeString` 或等价方法将 UTC `bucketStart` 转换为本地时间标签(如 "08:00"),不直接展示 UTC 时间字符串
#### Scenario: 趋势图异常标记点
- **WHEN** metricsData.trend 中某小时的 availability < 100
- **THEN** 趋势图 SHALL 在 avgDurationMs 线上该时间点渲染红色圆点fill: var(--td-error-color)),使用 recharts Line 的 dot 回调函数实现;图表 SHALL 仅保留左侧 Y 轴ms移除右侧 Y 轴(%)和 availability 折线
- **THEN** 统计区 SHALL 展示占位状态
#### Scenario: 趋势数据加载中
- **WHEN** metricsData 正在加载
- **THEN** "趋势"区域 SHALL 显示 TDesign Skeleton 加载占位
#### Scenario: 状态分布环形图
- **WHEN** 概览面板渲染且 metricsData 可用
- **THEN** 面板 SHALL 在"状态分布"区域展示 recharts 环形图StatusDonut使用 metricsData.stats.upChecks 和 metricsData.stats.downChecks 作为数据源,外圈显示 UP/DOWN 比例,中间显示可用率百分比
### Requirement: Drawer 宽度
Drawer 宽度 SHALL 设置为 52%。
#### Scenario: 状态分布加载状态
- **WHEN** metricsData 正在加载
- **THEN** 状态分布区域 SHALL 显示 TDesign Skeleton 加载占位
#### Scenario: Drawer 宽度
- **WHEN** Drawer 打开
- **THEN** Drawer size SHALL 为 "52%"
#### Scenario: 元信息展示
- **WHEN** 概览面板渲染
- **THEN** 面板 SHALL 在"基本信息"区域使用 TDesign Descriptions 组件展示目标元信息:目标地址、检查间隔、最新检查时间、状态详情
### Requirement: 时间选择器单行布局
Drawer 顶部的时间范围快捷按钮和日期范围选择器 SHALL 在同一行展示。
#### Scenario: 单行布局
- **WHEN** Drawer 渲染时间选择区域
- **THEN** RadioGroup 和 DateRangePicker SHALL 使用 flex 布局在同一行水平排列
### Requirement: 记录面板
记录 Tab SHALL 展示分页检查结果列表,使用 TDesign PrimaryTable。

View File

@@ -5,38 +5,42 @@
## Requirements
### Requirement: 分组表格展示
Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立的 TDesign PrimaryTable分组间使用 TDesign Space 垂直排列。
Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Card 包裹独立的 PrimaryTable分组间使用 TDesign Space 垂直排列。
#### Scenario: 按分组渲染独立表格
- **WHEN** 用户打开 Dashboard 页面
- **THEN** 页面 SHALL 按 group 字段将目标分组,每个分组包含带统计的分组标题和一个独立 PrimaryTable
- **THEN** 页面 SHALL 按 group 字段将目标分组,每个分组使用 TDesign Card 组件包裹Card 内包含一个 PrimaryTable
#### Scenario: 分组 Card 标题
- **WHEN** 页面渲染某个分组
- **THEN** Card 的 `title` prop SHALL 渲染分组名称("default" 显示为 "默认分组"Card 的 `actions` prop SHALL 渲染统计 Tag正常数theme=success, variant=light和异常数theme=danger, variant=light
#### Scenario: 分组 Card 样式
- **WHEN** 页面渲染分组 Card
- **THEN** Card SHALL 设置 `headerBordered` 在标题和表格之间显示分割线
#### Scenario: 分组顺序
- **WHEN** 页面渲染多个分组
- **THEN** "default" 分组 SHALL 排在最上面,其余分组按 YAML 配置中首次出现的顺序排列
#### Scenario: 分组标题统计标签
- **WHEN** 页面渲染某个分组的标题
- **THEN** 标题 SHALL 使用 TDesign Tag 组件显示分组名称和三个统计标签总数theme=primary, variant=light、正常数theme=success, variant=light、异常数theme=danger, variant=light
#### Scenario: "default" 分组显示名称
- **WHEN** 分组名称为 "default"
- **THEN** 分组标题 SHALL 显示 "默认分组"
- **THEN** Card title SHALL 显示 "默认分组"
#### Scenario: Dashboard 容器占满宽度
#### Scenario: Dashboard 容器最大宽度
- **WHEN** 用户打开 Dashboard 页面
- **THEN** Dashboard 容器 SHALL 占满浏览器宽度,不设置 max-width 限制
- **THEN** Dashboard 内容区 SHALL 设置 max-width: 1400px 并水平居中
#### Scenario: 分组间统一间距
- **WHEN** 页面渲染多个分组
- **THEN** 分组之间 SHALL 使用 TDesign Space 组件direction=vertical, size=32px)统一间距
- **THEN** 分组之间 SHALL 使用 TDesign Space 组件direction=vertical, size=24)统一间距
### Requirement: 表格列定义
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列不含分组列(同组内冗余)。列渲染不使用内联 style。列定义 SHALL 通过工厂函数动态生成。
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、连续状态、延迟 7 列不含间隔列)。列渲染不使用内联 style。列定义 SHALL 通过工厂函数动态生成。
#### Scenario: 状态列
- **WHEN** 表格渲染
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染,标题显示"#",宽度 60pxfixed="left"居中对齐支持筛选UP/DOWN/全部)。StatusDot SHALL 通过 CSS 类(`.status-dot--up` / `.status-dot--down`)控制颜色,不使用内联 style
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染,标题显示"#",宽度 60pxfixed="left"居中对齐支持筛选UP/DOWN/全部)
#### Scenario: 名称列
- **WHEN** 表格渲染
@@ -52,43 +56,23 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
#### Scenario: 可用率列
- **WHEN** 表格渲染
- **THEN** 可用率列标题 SHALL 展示为"可用率(24h)"(基于 Dashboard 默认 window=24h,使用 TDesign Progress 组件theme=line, size=small渲染颜色通过 CSS 自定义属性 `--avail-N`(基于项目自定义色值)控制,每 10% 一档label 显示百分比数值,支持排序(升序优先,最差排最前)。前端 SHALL 使用 DashboardResponse.targets[].stats.availability 字段作为数据来源。color-threshold 函数 SHALL 返回 CSS 自定义属性引用而非硬编码色值
- **THEN** 可用率列标题 SHALL 展示为"可用率(24h)",使用 TDesign Progress 组件theme=line, size=small渲染颜色通过 CSS 自定义属性 `--avail-N` 控制,支持排序
#### Scenario: 最近状态列
- **WHEN** 表格渲染
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染采样色块,宽度 220px。StatusBar SHALL 通过 CSS 类(`.status-bar-block--up` / `.status-bar-block--down` / `.status-bar-block--empty`)控制色块颜色,不使用内联 style
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染采样色块,宽度 220px
#### Scenario: 连续状态列渲染
- **WHEN** 表格渲染
- **THEN** 表格 SHALL 在「最近状态」列之后、「延迟」列之前展示「连续状态」列,标题为"连续",宽度 100px
#### Scenario: 连续正常展示
- **WHEN** 目标 currentStreak 为 `{ up: true, count: N }`
- **THEN** 列 SHALL 使用 TDesign Tag 组件theme=success, variant=light, size=small展示 "▲ N次"
#### Scenario: 连续异常展示
- **WHEN** 目标 currentStreak 为 `{ up: false, count: N }`
- **THEN** 列 SHALL 使用 TDesign Tag 组件theme=danger, variant=light, size=small展示 "▼ N次"
#### Scenario: 连续状态数据来源
- **WHEN** 表格需要渲染连续状态
- **THEN** 前端 SHALL 使用 DashboardResponse.targets[].currentStreak 字段,不在表格列中自行遍历 recentSamples 计算核心指标
#### Scenario: 超过样本上限
- **WHEN** currentStreak.capped 为 true
- **THEN** 列 SHALL 展示 "▲ N+" 或 "▼ N+"
#### Scenario: 无样本数据
- **WHEN** 目标 currentStreak 为 null
- **THEN** 列 SHALL 展示 "-"
- **THEN** 表格 SHALL 在「最近状态」列之后、「延迟」列之前展示「连续状态」列,标题为"连续(次)",宽度 88pxTag 内显示方向箭头和数字capped 时追加"+"
#### Scenario: 延迟列
- **WHEN** 表格渲染
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐颜色 SHALL 通过 CSS 类实现≤100ms 使用 `.latency-ok`、100-500ms 使用 `.latency-warn`、>500ms 使用 `.latency-error`。无数据 SHALL 使用 `.text-disabled` 类显示 "-",数值 SHALL 使用 `.tabular-nums` 类等宽显示。不使用内联 style
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+ms"
#### Scenario: 间隔列
#### Scenario: 间隔列移除
- **WHEN** 表格渲染
- **THEN** 间隔列 SHALL 显示检查间隔(如 "5s"、"30s"),居中对齐,宽度 72px
- **THEN** 表格 SHALL 不包含"间隔"列(间隔信息移入 Drawer 基本信息区域)
### Requirement: 列定义工厂函数
列定义 SHALL 通过工厂函数生成,接收动态参数。
@@ -124,15 +108,19 @@ TargetGroup 组件 SHALL 通过 prop 接收列定义,不再直接导入静态
- **THEN** 每个分组表格 SHALL 默认按状态降序排列DOWN 目标排在同组最前面
### Requirement: DOWN 行视觉强化
表格中状态为 DOWN 的行 SHALL 具有视觉区分,使用安全 CSS 选择器实现
表格中状态为 DOWN 的行 SHALL 具有视觉区分,包含背景色和左侧竖线
#### Scenario: DOWN 行背景色
- **WHEN** 目标最近一次检查 matched=false
- **THEN** 该行 SHALL 通过 `.t-table tr.row-down` CSS 选择器获得浅红色背景(`--td-error-color-light`),不使用 `!important`
- **THEN** 该行 SHALL 通过 CSS 选择器获得浅红色背景
#### Scenario: DOWN 行左侧竖线
- **WHEN** 目标最近一次检查 matched=false
- **THEN** 该行 SHALL 通过 CSS 选择器获得左侧 3px 红色竖线border-left: 3px solid var(--td-error-color)
#### Scenario: DOWN 行 hover 状态
- **WHEN** 鼠标悬停在 DOWN 行上
- **THEN** 行背景 SHALL 通过 `.t-table--hoverable tbody tr.row-down:hover` 选择器显示 hover 状态色,与正常行 hover 效果协调
- **THEN** 行背景 SHALL 显示 hover 状态色,与正常行 hover 效果协调
### Requirement: 行点击交互
表格行 SHALL 支持点击打开目标详情 Drawer。
@@ -150,11 +138,26 @@ TargetGroup 组件 SHALL 通过 prop 接收列定义,不再直接导入静态
- **THEN** cursor SHALL 显示为 pointer
### Requirement: 表格外观
表格 SHALL 使用 TDesign PrimaryTable 统一外观。
表格 SHALL 使用 TDesign PrimaryTable 统一外观,不设置 bordered由外层 Card 提供边界)
#### Scenario: 表格样式
- **WHEN** 表格渲染
- **THEN** 表格 SHALL 设置 size="small"、stripe、hoverbordered
- **THEN** 表格 SHALL 设置 size="small"、stripe、hover,不设置 bordered
### Requirement: StatusBar Tooltip 交互
StatusBar 色块 SHALL 在 hover 时通过 TDesign Tooltip 展示时间和状态信息。组件 props 类型 SHALL 使用完整的 `RecentSample` 类型(包含 timestamp 字段)而非简化的 `{ up: boolean }`
#### Scenario: StatusBar props 类型变更
- **WHEN** StatusBar 组件接收 samples 数据
- **THEN** 组件 SHALL 接收 `Array<RecentSample>` 类型(包含 timestamp、durationMs、up 字段),而非简化的 `Array<{ up: boolean }>` 类型
#### Scenario: 有数据色块 Tooltip
- **WHEN** 鼠标悬停在有数据的色块上
- **THEN** 色块 SHALL 通过 TDesign Tooltipplacement="top")展示该采样点的时间(使用 formatRelativeTime 格式化)和状态(正常/异常)
#### Scenario: 空色块无 Tooltip
- **WHEN** 鼠标悬停在空色块empty
- **THEN** 色块 SHALL 不显示 Tooltip
### Requirement: 列定义复用
所有分组的表格 SHALL 共享同一套列定义。

View File

@@ -1,13 +1,30 @@
import { Alert, Loading, Typography } from "tdesign-react";
import type { SkeletonProps } from "tdesign-react";
import { useEffect, useState } from "react";
import { Alert, Layout, Menu, Skeleton, Typography } from "tdesign-react";
import { SummaryCards } from "./components/SummaryCards";
import { TargetBoard } from "./components/TargetBoard";
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
import { useDashboard } from "./hooks/use-queries";
import { DASHBOARD_REFRESH_INTERVAL_MS, useDashboard } from "./hooks/use-queries";
import { useTargetDetail } from "./hooks/use-target-detail";
const { Content, Header } = Layout;
const DASHBOARD_SKELETON_ROW_COL: SkeletonProps["rowCol"] = [
[{ height: "112px", type: "rect", width: "100%" }],
[{ height: "56px", type: "rect", width: "100%" }],
[{ height: "320px", type: "rect", width: "100%" }],
];
export function App() {
const { data: dashboard, error: dashboardError, isLoading: dashboardLoading } = useDashboard();
const [now, setNow] = useState(() => new Date());
const {
data: dashboard,
dataUpdatedAt: dashboardUpdatedAt,
error: dashboardError,
isFetching: dashboardFetching,
isLoading: dashboardLoading,
} = useDashboard();
const {
closeDrawer,
handlePageChange,
@@ -21,25 +38,55 @@ export function App() {
timeFrom,
timeTo,
} = useTargetDetail();
const nextRefreshSeconds =
dashboardUpdatedAt > 0
? Math.max(0, Math.ceil((dashboardUpdatedAt + DASHBOARD_REFRESH_INTERVAL_MS - now.getTime()) / 1000))
: null;
const refreshText =
dashboardUpdatedAt > 0
? dashboardFetching && !dashboardLoading
? "刷新中..."
: `下一次刷新:${nextRefreshSeconds}`
: "等待首次刷新";
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(timer);
}, []);
return (
<main className="dashboard">
<header className="dashboard-header">
<Typography.Title level="h1">DiAL</Typography.Title>
<Typography.Text theme="secondary"></Typography.Text>
</header>
{dashboardError && <Alert closeBtn message={`请求失败: ${dashboardError.message}`} theme="error" />}
{dashboardLoading ? (
<Loading />
) : (
<>
<SummaryCards summary={dashboard?.summary ?? null} />
<TargetBoard onTargetClick={openDrawer} targets={dashboard?.targets ?? []} />
</>
)}
<Layout className="dashboard">
<Header>
<Menu.HeadMenu
logo={
<span className="dashboard-brand">
<span className="dashboard-logo">DiAL</span>
<span className="dashboard-subtitle"></span>
</span>
}
operations={
<span className="dashboard-refresh-status">
<Typography.Text className="dashboard-refresh-text" theme="secondary">
{refreshText}
</Typography.Text>
</span>
}
/>
</Header>
<Content>
<div className="dashboard-content">
{dashboardError && <Alert closeBtn message={`请求失败: ${dashboardError.message}`} theme="error" />}
{dashboardLoading ? (
<Skeleton animation="gradient" rowCol={DASHBOARD_SKELETON_ROW_COL} />
) : (
<>
<SummaryCards summary={dashboard?.summary ?? null} />
<TargetBoard onTargetClick={openDrawer} targets={dashboard?.targets ?? []} />
</>
)}
</div>
</Content>
<TargetDetailDrawer
historyData={historyData}
historyLoading={historyLoading}
@@ -53,6 +100,6 @@ export function App() {
timeFrom={timeFrom}
timeTo={timeTo}
/>
</main>
</Layout>
);
}

View File

@@ -1,27 +0,0 @@
import { Tag, Typography } from "tdesign-react";
interface GroupHeaderProps {
down: number;
name: string;
total: number;
up: number;
}
export function GroupHeader({ down, name, total, up }: GroupHeaderProps) {
const displayName = name === "default" ? "默认分组" : name;
return (
<div className="group-header">
<Typography.Title level="h4">{displayName}</Typography.Title>
<Tag theme="primary" title="总数" variant="light">
{total}
</Tag>
<Tag theme="success" title="正常" variant="light">
{up}
</Tag>
<Tag theme="danger" title="异常" variant="light">
{down}
</Tag>
</div>
);
}

View File

@@ -1,11 +1,19 @@
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic } from "tdesign-react";
import type { ReactNode } from "react";
import { Card, Col, Descriptions, Divider, Row, Skeleton, Space, Statistic, Typography } from "tdesign-react";
import type { TargetMetricsResponse, TargetStatus } from "../../shared/api";
import { formatDurationUnit } from "../utils/time";
import { StatusDonut } from "./StatusDonut";
import { TrendChart } from "./TrendChart";
interface OverviewStatItemProps {
color?: string;
suffix?: ReactNode;
title: string;
value: number;
}
interface OverviewTabProps {
metricsData: null | TargetMetricsResponse;
metricsLoading: boolean;
@@ -20,70 +28,6 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
return (
<Space className="full-width" direction="vertical" size={16}>
<Divider align="left"></Divider>
{metricsLoading ? (
<Skeleton animation="gradient" />
) : stats ? (
<Space className="full-width" direction="vertical" size={16}>
<Row gutter={16}>
<Col span={3}>
<Statistic color="green" suffix="%" title="可用率" value={stats.availability} />
</Col>
<Col span={3}>
<Statistic
suffix={stats.avgDurationMs === null ? "" : "ms"}
title="平均延迟"
value={stats.avgDurationMs ?? 0}
/>
</Col>
<Col span={3}>
<Statistic
suffix={stats.p95DurationMs === null ? "" : "ms"}
title="P95 延迟"
value={stats.p95DurationMs ?? 0}
/>
</Col>
<Col span={3}>
<Statistic color="blue" title="检查总数" value={stats.totalChecks} />
</Col>
</Row>
<Row gutter={16}>
<Col span={3}>
<Statistic suffix={mttr.suffix} title="MTTR" value={mttr.value} />
</Col>
<Col span={3}>
<Statistic suffix={longestOutage.suffix} title="最长故障" value={longestOutage.value} />
</Col>
<Col span={3}>
<Statistic color="red" suffix="次" title="故障次数" value={stats.incidentCount} />
</Col>
<Col span={3}>
<Statistic color="green" suffix="次" title="连续正常" value={currentUpStreak} />
</Col>
</Row>
</Space>
) : (
<div className="trend-empty"></div>
)}
<Divider align="left"></Divider>
{metricsLoading ? (
<Skeleton animation="gradient" />
) : metricsData ? (
<TrendChart data={metricsData.trend} />
) : (
<div className="trend-empty"></div>
)}
<Divider align="left"></Divider>
{metricsLoading ? (
<Skeleton animation="gradient" />
) : stats ? (
<StatusDonut down={stats.downChecks} up={stats.upChecks} />
) : (
<div className="trend-empty"></div>
)}
<Divider align="left"></Divider>
<Descriptions
items={[
@@ -96,6 +40,68 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
]}
/>
<Divider align="left"></Divider>
{metricsLoading ? (
<Skeleton animation="gradient" />
) : stats ? (
<Row gutter={[16, 16]}>
<Col span={3}>
<OverviewStatItem color="green" suffix="%" title="可用率" value={stats.availability} />
</Col>
<Col span={3}>
<OverviewStatItem
suffix={stats.avgDurationMs === null ? "" : "ms"}
title="平均延迟"
value={stats.avgDurationMs ?? 0}
/>
</Col>
<Col span={3}>
<OverviewStatItem
suffix={stats.p95DurationMs === null ? "" : "ms"}
title="P95 延迟"
value={stats.p95DurationMs ?? 0}
/>
</Col>
<Col span={3}>
<OverviewStatItem color="blue" title="检查总数" value={stats.totalChecks} />
</Col>
<Col span={3}>
<OverviewStatItem suffix={mttr.suffix} title="MTTR" value={mttr.value} />
</Col>
<Col span={3}>
<OverviewStatItem suffix={longestOutage.suffix} title="最长故障" value={longestOutage.value} />
</Col>
<Col span={3}>
<OverviewStatItem color="red" suffix="次" title="故障次数" value={stats.incidentCount} />
</Col>
<Col span={3}>
<OverviewStatItem color="green" suffix="次" title="连续正常" value={currentUpStreak} />
</Col>
</Row>
) : (
<div className="trend-empty"></div>
)}
<Divider align="left"></Divider>
{metricsLoading ? (
<Skeleton animation="gradient" />
) : metricsData ? (
<TrendChart data={metricsData.trend} />
) : (
<div className="trend-empty"></div>
)}
</Space>
);
}
function OverviewStatItem({ color, suffix, title, value }: OverviewStatItemProps) {
return (
<Card bordered={false} className="overview-stat-card" size="small">
<div className="overview-stat-item">
<Typography.Text theme="secondary">{title}</Typography.Text>
<Statistic className="overview-stat-value" color={color} suffix={suffix} value={value} />
</div>
</Card>
);
}

View File

@@ -1,6 +1,12 @@
import { Tooltip } from "tdesign-react";
import type { RecentSample } from "../../shared/api";
import { formatRelativeTime } from "../utils/time";
interface StatusBarProps {
maxSlots?: number;
samples: Array<{ up: boolean }>;
samples: RecentSample[];
}
export function StatusBar({ maxSlots = 30, samples }: StatusBarProps) {
@@ -9,10 +15,13 @@ export function StatusBar({ maxSlots = 30, samples }: StatusBarProps) {
const sample = samples[i];
if (sample) {
blocks.push(
<span
className={`status-bar-block ${sample.up ? "status-bar-block--up" : "status-bar-block--down"}`}
<Tooltip
content={`${formatRelativeTime(sample.timestamp)}${sample.up ? "正常" : "异常"}`}
key={i}
/>,
placement="top"
>
<span className={`status-bar-block ${sample.up ? "status-bar-block--up" : "status-bar-block--down"}`} />
</Tooltip>,
);
} else {
blocks.push(<span className="status-bar-block status-bar-block--empty" key={i} />);

View File

@@ -1,40 +0,0 @@
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
interface StatusDonutProps {
down: number;
up: number;
}
const UP_COLOR = "var(--td-success-color)";
const DOWN_COLOR = "var(--td-error-color)";
const EMPTY_COLOR = "var(--td-bg-color-component-disabled)";
export function StatusDonut({ down, up }: StatusDonutProps) {
const total = up + down;
const availability = total > 0 ? ((up / total) * 100).toFixed(1) : "-";
const data =
total > 0
? [
{ name: "UP", value: up },
{ name: "DOWN", value: down },
]
: [{ name: "EMPTY", value: 1 }];
const colors = total > 0 ? [UP_COLOR, DOWN_COLOR] : [EMPTY_COLOR];
return (
<div className="status-donut">
<ResponsiveContainer height={180} width="100%">
<PieChart>
<Pie cx="50%" cy="50%" data={data} dataKey="value" innerRadius={50} outerRadius={70} stroke="none">
{data.map((item, index) => (
<Cell fill={colors[index % colors.length]} key={item.name} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
<div className="donut-center-label">{total > 0 ? `${availability}%` : "-"}</div>
</div>
);
}

View File

@@ -1,22 +1,12 @@
import { useEffect, useState } from "react";
import { Card, Col, Row, Statistic, Typography } from "tdesign-react";
import { Card, Col, Row, Statistic } from "tdesign-react";
import type { DashboardResponse } from "../../shared/api";
import { formatRelativeTime, isOlderThan } from "../utils/time";
interface SummaryCardsProps {
summary: DashboardResponse["summary"] | null;
}
export function SummaryCards({ summary }: SummaryCardsProps) {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(timer);
}, []);
if (!summary) return null;
const cards = [
@@ -25,25 +15,18 @@ export function SummaryCards({ summary }: SummaryCardsProps) {
{ color: "red" as const, label: "异常", value: summary.down },
{ color: "orange" as const, label: `${summary.window.label} 异常事件数`, value: summary.incidents },
];
const freshnessWarning = isOlderThan(summary.lastCheckTime, 60000, now);
return (
<section className="summary-cards-row">
<Row gutter={16}>
{cards.map((card) => (
<Col key={card.label} span={3}>
<Card bordered>
<Card bordered={false}>
<Row gutter={16}>
{cards.map((card) => (
<Col className="summary-stat-col" key={card.label} span={3}>
<Statistic color={card.color} title={card.label} value={card.value} />
</Card>
</Col>
))}
</Row>
<Typography.Text
className={freshnessWarning ? "summary-freshness summary-freshness--warning" : "summary-freshness"}
theme="secondary"
>
{summary.lastCheckTime ? `最后更新: ${formatRelativeTime(summary.lastCheckTime, now)}` : "尚无检查数据"}
</Typography.Text>
</Col>
))}
</Row>
</Card>
</section>
);
}

View File

@@ -36,7 +36,7 @@ export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
});
return (
<Space className="full-width" direction="vertical" size={32}>
<Space className="full-width" direction="vertical" size={24}>
{sortedGroups.map(([name, groupTargets]) => (
<TargetGroup columns={columns} key={name} name={name} onTargetClick={onTargetClick} targets={groupTargets} />
))}

View File

@@ -85,28 +85,30 @@ export function TargetDetailDrawer({
}
onClose={onClose}
placement="right"
size="60%"
size="52%"
visible={!!target}
>
<Space className="full-width" direction="vertical" size={16}>
<RadioGroup
onChange={handleShortcut}
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
theme="button"
value={activeShortcut}
variant="default-filled"
/>
<DateRangePicker
className="full-width"
defaultTime={["00:00:00", "23:59:00"]}
enableTimePicker
format="YYYY-MM-DD HH:mm"
mode="date"
onChange={handleDateRangeChange}
timePickerProps={{ format: "HH:mm", steps: [1, 1, 60] }}
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
valueType="YYYY-MM-DD HH:mm"
/>
<div className="drawer-time-controls">
<RadioGroup
onChange={handleShortcut}
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
theme="button"
value={activeShortcut}
variant="default-filled"
/>
<DateRangePicker
className="drawer-date-range"
defaultTime={["00:00:00", "23:59:00"]}
enableTimePicker
format="YYYY-MM-DD HH:mm"
mode="date"
onChange={handleDateRangeChange}
timePickerProps={{ format: "HH:mm", steps: [1, 1, 60] }}
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
valueType="YYYY-MM-DD HH:mm"
/>
</div>
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
<OverviewTab metricsData={metricsData} metricsLoading={metricsLoading} target={target} />

View File

@@ -1,11 +1,9 @@
import type { PrimaryTableCol } from "tdesign-react";
import { PrimaryTable } from "tdesign-react";
import { Card, PrimaryTable, Space, Tag } from "tdesign-react";
import type { TargetStatus } from "../../shared/api";
import { GroupHeader } from "./GroupHeader";
interface TargetGroupProps {
columns: Array<PrimaryTableCol<TargetStatus>>;
name: string;
@@ -16,12 +14,24 @@ interface TargetGroupProps {
export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGroupProps) {
const up = targets.filter((t) => t.latestCheck?.matched).length;
const down = targets.length - up;
const displayName = name === "default" ? "默认分组" : name;
return (
<div>
<GroupHeader down={down} name={name} total={targets.length} up={up} />
<Card
actions={
<Space size={8}>
<Tag theme="success" title="正常" variant="light">
{up}
</Tag>
<Tag theme="danger" title="异常" variant="light">
{down}
</Tag>
</Space>
}
headerBordered
title={displayName}
>
<PrimaryTable
bordered
className="clickable-table"
columns={columns}
data={targets}
@@ -36,6 +46,6 @@ export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGro
size="small"
stripe
/>
</div>
</Card>
);
}

View File

@@ -74,13 +74,13 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
return (
<Tag size="small" theme={streak.up ? "success" : "danger"} variant="light">
{streak.up ? "▲" : "▼"} {streak.count}
{streak.capped ? "+" : ""}
{streak.capped ? "+" : ""}
</Tag>
);
},
colKey: "currentStreak",
title: "连续",
width: 100,
title: "连续(次)",
width: 88,
},
{
align: "right",
@@ -88,19 +88,14 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
const ms = row.latestCheck?.durationMs;
if (ms === null || ms === undefined) return <span className="text-disabled">-</span>;
const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error";
return <span className={`${colorClass} tabular-nums`}>{Math.round(ms)}ms</span>;
const latencyText = ms > 9999 ? "9999+ms" : `${Math.round(ms)}ms`;
return <span className={`${colorClass} latency-value tabular-nums`}>{latencyText}</span>;
},
colKey: "latestCheck.durationMs",
sorter: latencySorter,
sortType: "all",
title: "延迟",
width: 80,
},
{
align: "center",
colKey: "interval",
title: "间隔",
width: 72,
width: 75,
},
];
}

View File

@@ -9,6 +9,8 @@ const queryKeys = {
["metrics", targetId, from, to, bucket] as const,
};
export const DASHBOARD_REFRESH_INTERVAL_MS = 8000;
export async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -19,7 +21,7 @@ export function useDashboard() {
return useQuery({
queryFn: () => fetchJson<DashboardResponse>("/api/dashboard?window=24h&recentLimit=30"),
queryKey: queryKeys.dashboard(),
refetchInterval: 8000,
refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS,
refetchIntervalInBackground: false,
});
}

View File

@@ -12,17 +12,44 @@
}
.dashboard {
min-height: 100vh;
background: var(--td-bg-color-page);
width: 100%;
}
.dashboard-content {
box-sizing: border-box;
max-width: 1400px;
margin: 0 auto;
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
width: 100%;
}
.dashboard-header {
margin-bottom: var(--td-comp-margin-l);
.dashboard-brand {
display: inline-flex;
align-items: baseline;
justify-content: center;
gap: var(--td-comp-margin-s);
line-height: 1.2;
}
.dashboard-header .t-typography {
.dashboard-logo {
margin: 0;
line-height: 1.3;
color: var(--td-text-color-primary);
font-size: calc(var(--td-font-size-title-large) + 6px);
font-weight: 700;
}
.dashboard-subtitle {
color: var(--td-text-color-secondary);
font-size: var(--td-font-size-body-medium);
font-weight: 400;
}
.dashboard-refresh-status {
display: inline-flex;
align-items: center;
margin-right: var(--td-comp-margin-xxl);
}
.status-dot {
@@ -69,24 +96,6 @@
background: var(--td-bg-color-component-disabled);
}
.status-donut {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: fit-content;
margin: 0 auto;
}
.donut-center-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -60%);
font-size: 1.25rem;
font-weight: 700;
}
.trend-chart {
width: 100%;
}
@@ -104,6 +113,7 @@
}
.t-table tr.row-down {
border-left: 3px solid var(--td-error-color);
background: color-mix(in srgb, var(--td-error-color) 6%, transparent);
}
@@ -131,6 +141,58 @@
color: var(--td-error-color);
}
.latency-value {
display: inline-block;
min-width: 7ch;
white-space: nowrap;
}
.drawer-time-controls {
display: flex;
align-items: center;
gap: var(--td-comp-margin-m);
width: 100%;
}
.drawer-date-range {
flex: 1;
min-width: 360px;
}
.overview-stat-card {
background: var(--td-bg-color-container-hover);
}
.overview-stat-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--td-comp-margin-m);
}
.overview-stat-value {
font-size: var(--td-font-size-body-medium);
text-align: right;
}
.overview-stat-value .t-statistic-content {
font-size: var(--td-font-size-body-medium);
}
.summary-stat-col {
text-align: center;
}
@media (max-width: 768px) {
.drawer-time-controls {
flex-wrap: wrap;
}
.drawer-date-range {
min-width: 100%;
}
}
.full-width {
width: 100%;
}
@@ -139,33 +201,10 @@
cursor: pointer;
}
.group-header {
margin-bottom: var(--td-comp-margin-m);
display: flex;
align-items: center;
gap: 8px;
}
.group-header .t-typography {
margin: 0;
font-size: var(--td-font-size-title-medium);
font-weight: 600;
line-height: 1.5;
}
.summary-cards-row {
margin-bottom: var(--td-comp-margin-xl);
}
.summary-freshness {
display: block;
margin-top: var(--td-comp-margin-s);
}
.summary-freshness--warning {
color: var(--td-warning-color);
}
.error-boundary-fallback {
padding-top: 20vh;
width: 100%;

View File

@@ -34,7 +34,7 @@ function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
}
describe("createTargetTableColumns", () => {
test("生成 8 个目标表格列", () => {
test("生成 7 个目标表格列", () => {
const columns = createTargetTableColumns(["http", "cmd"]);
expect(columns.map((column) => column.colKey)).toEqual([
@@ -45,7 +45,6 @@ describe("createTargetTableColumns", () => {
"recentSamples",
"currentStreak",
"latestCheck.durationMs",
"interval",
]);
});
@@ -96,6 +95,31 @@ describe("createTargetTableColumns", () => {
rowIndex: 0,
});
expect(streakColumn.title).toBe("连续(次)");
expect(element.props.children.join("")).toBe("▼ 30+");
});
test("延迟列超过 9999ms 时显示上限文案", () => {
const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs");
const renderCell = latencyColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => {
props: { children: string; className: string };
};
const element = renderCell({
col: latencyColumn,
colIndex: 6,
row: makeTarget({
latestCheck: {
durationMs: 12000,
failure: null,
matched: true,
statusDetail: "200",
timestamp: "2026-01-01T00:00:00.000Z",
},
}),
rowIndex: 0,
});
expect(element.props.children).toBe("9999+ms");
expect(element.props.className).toContain("latency-value");
});
});