From c61a4a609124a629c43d91554b35f7f975f61cb5 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Thu, 14 May 2026 15:51:39 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=89=8D=E7=AB=AF=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E9=87=8D=E6=9E=84=20=E2=80=94=20Layout/HeadMenu=20?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6=E3=80=81SummaryCards=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E3=80=81Card=20=E5=88=86=E7=BB=84=E3=80=81Drawer=20=E6=A6=82?= =?UTF-8?q?=E8=A7=88=E9=87=8D=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 8 + DEVELOPMENT.md | 44 +++--- openspec/specs/css-utility-classes/spec.md | 60 +++++++- openspec/specs/dashboard-layout/spec.md | 40 +++++ openspec/specs/probe-dashboard/spec.md | 33 ++--- openspec/specs/target-detail-drawer/spec.md | 95 +++++------- openspec/specs/target-table/spec.md | 89 +++++------ src/web/app.tsx | 87 ++++++++--- src/web/components/GroupHeader.tsx | 27 ---- src/web/components/OverviewTab.tsx | 138 +++++++++--------- src/web/components/StatusBar.tsx | 17 ++- src/web/components/StatusDonut.tsx | 40 ----- src/web/components/SummaryCards.tsx | 35 ++--- src/web/components/TargetBoard.tsx | 2 +- src/web/components/TargetDetailDrawer.tsx | 40 ++--- src/web/components/TargetGroup.tsx | 24 ++- src/web/constants/target-table-columns.tsx | 17 +-- src/web/hooks/use-queries.ts | 4 +- src/web/styles.css | 129 ++++++++++------ .../constants/target-table-columns.test.ts | 28 +++- 20 files changed, 530 insertions(+), 427 deletions(-) create mode 100644 .claude/settings.json create mode 100644 openspec/specs/dashboard-layout/spec.md delete mode 100644 src/web/components/GroupHeader.tsx delete mode 100644 src/web/components/StatusDonut.tsx diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e48e11e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "tdesign-mcp-server": { + "command": "bunx", + "args": ["tdesign-mcp-server@latest"] + } + } +} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 320e902..5ba3fb5 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 └── ErrorBoundary(React 错误边界) └── QueryClientProvider(TanStack Query 全局挂载) - ├── App(根组件) - │ ├── useDashboard() ─── GET /api/dashboard?window=24h&recentLimit=30(8s 轮询) - │ ├── SummaryCards(总览统计卡片) - │ └── TargetBoard(目标列表) + ├── App(根组件,Layout + HeadMenu 骨架) + │ ├── useDashboard() ─── GET /api/dashboard?window=24h&recentLimit=30(8s 轮询,dataUpdatedAt 倒计时) + │ ├── SummaryCards(单 Card 内嵌居中 Statistic,无 shadow) + │ └── TargetBoard(目标列表,Space 24px 间距) │ ├── DashboardResponse.targets │ ├── useMeta() ───── GET /api/meta(应用生命周期内缓存) - │ └── TargetGroup[](按 group 字段分组) + │ └── TargetGroup[](Card 包裹 PrimaryTable,headerBordered) │ └── 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` | 单个分组 Card(title+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 新增功能开发步骤 diff --git a/openspec/specs/css-utility-classes/spec.md b/openspec/specs/css-utility-classes/spec.md index 851f86d..b567520 100644 --- a/openspec/specs/css-utility-classes/spec.md +++ b/openspec/specs/css-utility-classes/spec.md @@ -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: 100vh,width: 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 行上 diff --git a/openspec/specs/dashboard-layout/spec.md b/openspec/specs/dashboard-layout/spec.md new file mode 100644 index 0000000..380818f --- /dev/null +++ b/openspec/specs/dashboard-layout/spec.md @@ -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.Text(theme="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)`,内容卡片浮于浅灰背景之上 diff --git a/openspec/specs/probe-dashboard/spec.md b/openspec/specs/probe-dashboard/spec.md index 9b019e5..fb2f754 100644 --- a/openspec/specs/probe-dashboard/spec.md +++ b/openspec/specs/probe-dashboard/spec.md @@ -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.Text(theme="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 请求失败 diff --git a/openspec/specs/target-detail-drawer/spec.md b/openspec/specs/target-detail-drawer/spec.md index ef5992c..f0d94e7 100644 --- a/openspec/specs/target-detail-drawer/spec.md +++ b/openspec/specs/target-detail-drawer/spec.md @@ -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 从右侧滑出 Drawer(placement="right"),宽度为视口 60% +- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="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 Divider(align="left")作为小标题 +- **THEN** 面板 SHALL 按以下顺序展示区域:基本信息 → 统计 → 趋势,每个区域前 SHALL 显示 TDesign Divider(align="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。 diff --git a/openspec/specs/target-table/spec.md b/openspec/specs/target-table/spec.md index f6ec46d..2cfcec3 100644 --- a/openspec/specs/target-table/spec.md +++ b/openspec/specs/target-table/spec.md @@ -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 组件渲染,标题显示"#",宽度 60px,fixed="left",居中对齐,支持筛选(UP/DOWN/全部)。StatusDot SHALL 通过 CSS 类(`.status-dot--up` / `.status-dot--down`)控制颜色,不使用内联 style +- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染,标题显示"#",宽度 60px,fixed="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 在「最近状态」列之后、「延迟」列之前展示「连续状态」列,标题为"连续(次)",宽度 88px,Tag 内显示方向箭头和数字(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、hover、bordered +- **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` 类型(包含 timestamp、durationMs、up 字段),而非简化的 `Array<{ up: boolean }>` 类型 + +#### Scenario: 有数据色块 Tooltip +- **WHEN** 鼠标悬停在有数据的色块上 +- **THEN** 色块 SHALL 通过 TDesign Tooltip(placement="top")展示该采样点的时间(使用 formatRelativeTime 格式化)和状态(正常/异常) + +#### Scenario: 空色块无 Tooltip +- **WHEN** 鼠标悬停在空色块(empty)上 +- **THEN** 色块 SHALL 不显示 Tooltip ### Requirement: 列定义复用 所有分组的表格 SHALL 共享同一套列定义。 diff --git a/src/web/app.tsx b/src/web/app.tsx index 97f24dd..56bf69a 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -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 ( -
-
- DiAL - 统一拨测平台 -
- - {dashboardError && } - - {dashboardLoading ? ( - - ) : ( - <> - - - - )} + +
+ + DiAL + 统一拨测平台 + + } + operations={ + + + {refreshText} + + + } + /> +
+ +
+ {dashboardError && } + {dashboardLoading ? ( + + ) : ( + <> + + + + )} +
+
-
+ ); } diff --git a/src/web/components/GroupHeader.tsx b/src/web/components/GroupHeader.tsx deleted file mode 100644 index 0ce9cb3..0000000 --- a/src/web/components/GroupHeader.tsx +++ /dev/null @@ -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 ( -
- {displayName} - - {total} - - - {up} - - - {down} - -
- ); -} diff --git a/src/web/components/OverviewTab.tsx b/src/web/components/OverviewTab.tsx index 31744c9..9683806 100644 --- a/src/web/components/OverviewTab.tsx +++ b/src/web/components/OverviewTab.tsx @@ -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 ( - 统计 - {metricsLoading ? ( - - ) : stats ? ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( -
暂无指标数据
- )} - - 趋势 - {metricsLoading ? ( - - ) : metricsData ? ( - - ) : ( -
暂无趋势数据
- )} - - 状态分布 - {metricsLoading ? ( - - ) : stats ? ( - - ) : ( -
暂无状态数据
- )} - 基本信息 + + 统计 + {metricsLoading ? ( + + ) : stats ? ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : ( +
暂无指标数据
+ )} + + 趋势 + {metricsLoading ? ( + + ) : metricsData ? ( + + ) : ( +
暂无趋势数据
+ )}
); } + +function OverviewStatItem({ color, suffix, title, value }: OverviewStatItemProps) { + return ( + +
+ {title} + +
+
+ ); +} diff --git a/src/web/components/StatusBar.tsx b/src/web/components/StatusBar.tsx index 0a94008..95a479d 100644 --- a/src/web/components/StatusBar.tsx +++ b/src/web/components/StatusBar.tsx @@ -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( - , + placement="top" + > + + , ); } else { blocks.push(); diff --git a/src/web/components/StatusDonut.tsx b/src/web/components/StatusDonut.tsx deleted file mode 100644 index 5cac9d4..0000000 --- a/src/web/components/StatusDonut.tsx +++ /dev/null @@ -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 ( -
- - - - {data.map((item, index) => ( - - ))} - - - -
{total > 0 ? `${availability}%` : "-"}
-
- ); -} diff --git a/src/web/components/SummaryCards.tsx b/src/web/components/SummaryCards.tsx index 43fa0f3..9b06a7b 100644 --- a/src/web/components/SummaryCards.tsx +++ b/src/web/components/SummaryCards.tsx @@ -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 (
- - {cards.map((card) => ( - - + + + {cards.map((card) => ( + - - - ))} - - - {summary.lastCheckTime ? `最后更新: ${formatRelativeTime(summary.lastCheckTime, now)}` : "尚无检查数据"} - + + ))} + +
); } diff --git a/src/web/components/TargetBoard.tsx b/src/web/components/TargetBoard.tsx index 93ca760..8f9f54e 100644 --- a/src/web/components/TargetBoard.tsx +++ b/src/web/components/TargetBoard.tsx @@ -36,7 +36,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 09ad9ad..f216335 100644 --- a/src/web/components/TargetDetailDrawer.tsx +++ b/src/web/components/TargetDetailDrawer.tsx @@ -85,28 +85,30 @@ export function TargetDetailDrawer({ } onClose={onClose} placement="right" - size="60%" + size="52%" visible={!!target} > - ({ label: s.label, value: s.value }))} - theme="button" - value={activeShortcut} - variant="default-filled" - /> - +
+ ({ label: s.label, value: s.value }))} + theme="button" + value={activeShortcut} + variant="default-filled" + /> + +
setActiveTab(val)} value={activeTab}> diff --git a/src/web/components/TargetGroup.tsx b/src/web/components/TargetGroup.tsx index ad4717b..ecd12f4 100644 --- a/src/web/components/TargetGroup.tsx +++ b/src/web/components/TargetGroup.tsx @@ -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>; 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 ( -
- + + + {up} + + + {down} + + + } + headerBordered + title={displayName} + > -
+ ); } diff --git a/src/web/constants/target-table-columns.tsx b/src/web/constants/target-table-columns.tsx index 24bccc2..4364c90 100644 --- a/src/web/constants/target-table-columns.tsx +++ b/src/web/constants/target-table-columns.tsx @@ -74,13 +74,13 @@ export function createTargetTableColumns(checkerTypes: string[]): Array {streak.up ? "▲" : "▼"} {streak.count} - {streak.capped ? "+" : "次"} + {streak.capped ? "+" : ""} ); }, colKey: "currentStreak", - title: "连续", - width: 100, + title: "连续(次)", + width: 88, }, { align: "right", @@ -88,19 +88,14 @@ export function createTargetTableColumns(checkerTypes: string[]): Array-
; const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error"; - return {Math.round(ms)}ms; + const latencyText = ms > 9999 ? "9999+ms" : `${Math.round(ms)}ms`; + return {latencyText}; }, colKey: "latestCheck.durationMs", sorter: latencySorter, sortType: "all", title: "延迟", - width: 80, - }, - { - align: "center", - colKey: "interval", - title: "间隔", - width: 72, + width: 75, }, ]; } diff --git a/src/web/hooks/use-queries.ts b/src/web/hooks/use-queries.ts index 0bfb99d..e5430cb 100644 --- a/src/web/hooks/use-queries.ts +++ b/src/web/hooks/use-queries.ts @@ -9,6 +9,8 @@ const queryKeys = { ["metrics", targetId, from, to, bucket] as const, }; +export const DASHBOARD_REFRESH_INTERVAL_MS = 8000; + export async function fetchJson(url: string): Promise { 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("/api/dashboard?window=24h&recentLimit=30"), queryKey: queryKeys.dashboard(), - refetchInterval: 8000, + refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS, refetchIntervalInBackground: false, }); } diff --git a/src/web/styles.css b/src/web/styles.css index 7ff1703..e5e726f 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -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%; diff --git a/tests/web/constants/target-table-columns.test.ts b/tests/web/constants/target-table-columns.test.ts index 2eda58d..d2a2244 100644 --- a/tests/web/constants/target-table-columns.test.ts +++ b/tests/web/constants/target-table-columns.test.ts @@ -34,7 +34,7 @@ function makeTarget(overrides: Partial = {}): 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) => { + 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"); + }); });