feat: 优化目标详情 Drawer 性能 — TDesign 生命周期控制、Tab 感知延迟加载、滚动穿透修复
This commit is contained in:
@@ -561,10 +561,11 @@ main.tsx
|
|||||||
│ ├── useMeta() ───── GET /api/meta(应用生命周期内缓存)
|
│ ├── useMeta() ───── GET /api/meta(应用生命周期内缓存)
|
||||||
│ └── TargetGroup[](Card 包裹 PrimaryTable,headerBordered)
|
│ └── TargetGroup[](Card 包裹 PrimaryTable,headerBordered)
|
||||||
│ └── PrimaryTable ← createTargetTableColumns(checkerTypes)
|
│ └── PrimaryTable ← createTargetTableColumns(checkerTypes)
|
||||||
│ └── TargetDetailDrawer(目标详情抽屉,width=52%)
|
│ └── TargetDetailDrawer(目标详情抽屉,width=52%,TDesign 生命周期控制)
|
||||||
│ └── useTargetDetail() ── 按需发起 metrics + history 查询
|
│ └── useTargetDetail() ── 按需发起 metrics 查询,history 延迟到记录 Tab 激活后请求
|
||||||
│ ├── OverviewTab → Descriptions(直接展示)+ 4×2 统计卡片 + TrendChart
|
│ ├── activeTab 受控 Tabs 状态,每次打开重置为 overview
|
||||||
│ └── HistoryTab → PrimaryTable(分页历史记录)
|
│ ├── OverviewTab → Descriptions(直接展示)+ 4×2 统计卡片 + TrendChart
|
||||||
|
│ └── HistoryTab → PrimaryTable(分页历史记录,TabPanel 懒渲染 + destroyOnHide=false)
|
||||||
└── ReactQueryDevtools(开发工具,仅开发环境)
|
└── ReactQueryDevtools(开发工具,仅开发环境)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -579,8 +580,9 @@ hooks/use-queries.ts(全局面板级查询)
|
|||||||
|
|
||||||
hooks/use-target-detail.ts(Drawer 状态与详情级条件查询)
|
hooks/use-target-detail.ts(Drawer 状态与详情级条件查询)
|
||||||
├── 内部复用 useDashboard(false) 的缓存来查找 selectedTarget
|
├── 内部复用 useDashboard(false) 的缓存来查找 selectedTarget
|
||||||
|
├── activeTab 受控 Tabs 状态(每次 openDrawer 重置为 overview)
|
||||||
├── useTargetMetrics(/api/targets/:id/metrics)(条件查询:enabled 仅当 Drawer 打开且时间范围有效)
|
├── useTargetMetrics(/api/targets/:id/metrics)(条件查询:enabled 仅当 Drawer 打开且时间范围有效)
|
||||||
└── useQuery(/api/targets/:id/history)(条件查询:含分页)
|
└── useQuery(/api/targets/:id/history)(条件查询:enabled 仅当 Drawer 打开 + 时间范围有效 + activeTab=history)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.3 TanStack Query 数据层
|
### 2.3 TanStack Query 数据层
|
||||||
@@ -679,19 +681,19 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
|
|||||||
|
|
||||||
#### 现有组件清单
|
#### 现有组件清单
|
||||||
|
|
||||||
| 组件 | 文件 | 用途 |
|
| 组件 | 文件 | 用途 |
|
||||||
| -------------------- | ----------------------------------- | ----------------------------------------------------------- |
|
| -------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||||
| `App` | `app.tsx` | 根组件,Layout + HeadMenu 骨架、刷新倒计时、Skeleton 加载 |
|
| `App` | `app.tsx` | 根组件,Layout + HeadMenu 骨架、刷新倒计时、Skeleton 加载 |
|
||||||
| `ErrorBoundary` | `components/ErrorBoundary.tsx` | React 错误边界,捕获渲染异常并展示降级 UI |
|
| `ErrorBoundary` | `components/ErrorBoundary.tsx` | React 错误边界,捕获渲染异常并展示降级 UI |
|
||||||
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(单 Card 内嵌居中 Statistic,无 shadow) |
|
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(单 Card 内嵌居中 Statistic,无 shadow) |
|
||||||
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表(Space 24px 间距) |
|
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表(Space 24px 间距) |
|
||||||
| `TargetGroup` | `components/TargetGroup.tsx` | 单个分组 Card(title+actions+headerBordered)+ PrimaryTable |
|
| `TargetGroup` | `components/TargetGroup.tsx` | 单个分组 Card(title+actions+headerBordered)+ PrimaryTable |
|
||||||
| `TargetDetailDrawer` | `components/TargetDetailDrawer.tsx` | 目标详情抽屉(width=52%)、时间选择器单行布局和 Tab 切换 |
|
| `TargetDetailDrawer` | `components/TargetDetailDrawer.tsx` | 目标详情抽屉(width=52%、TDesign 生命周期控制、preventScrollThrough、受控 Tabs、记录 TabPanel 懒渲染) |
|
||||||
| `OverviewTab` | `components/OverviewTab.tsx` | 目标详情概览(Descriptions 直接展示 + 4×2 统计卡片 + 趋势) |
|
| `OverviewTab` | `components/OverviewTab.tsx` | 目标详情概览(Descriptions 直接展示 + 4×2 统计卡片 + 趋势) |
|
||||||
| `HistoryTab` | `components/HistoryTab.tsx` | 目标历史记录表格和分页 |
|
| `HistoryTab` | `components/HistoryTab.tsx` | 目标历史记录表格和分页 |
|
||||||
| `TrendChart` | `components/TrendChart.tsx` | Recharts 双轴折线图(耗时/可用率) |
|
| `TrendChart` | `components/TrendChart.tsx` | Recharts 双轴折线图(耗时/可用率) |
|
||||||
| `StatusDot` | `components/StatusDot.tsx` | 圆形状态指示点(绿/红) |
|
| `StatusDot` | `components/StatusDot.tsx` | 圆形状态指示点(绿/红) |
|
||||||
| `StatusBar` | `components/StatusBar.tsx` | 最近采样状态条(多色块 + Tooltip 提示时间和状态) |
|
| `StatusBar` | `components/StatusBar.tsx` | 最近采样状态条(多色块 + Tooltip 提示时间和状态) |
|
||||||
|
|
||||||
### 2.5 新增功能开发步骤
|
### 2.5 新增功能开发步骤
|
||||||
|
|
||||||
|
|||||||
@@ -91,19 +91,27 @@
|
|||||||
- **THEN** 系统 SHALL 按用户选择的刷新间隔自动请求数据,轮询间隔与 summary 查询保持一致
|
- **THEN** 系统 SHALL 按用户选择的刷新间隔自动请求数据,轮询间隔与 summary 查询保持一致
|
||||||
|
|
||||||
### Requirement: 条件查询
|
### Requirement: 条件查询
|
||||||
趋势和历史记录查询 SHALL 使用 enabled 条件控制,仅在目标被选中时触发。
|
详情指标和历史记录查询 SHALL 使用 enabled 条件控制。指标查询 SHALL 在目标和时间范围有效时触发;历史记录查询 SHALL 仅在目标、时间范围有效且"记录"Tab 激活后触发。
|
||||||
|
|
||||||
#### Scenario: 未选中目标时不请求
|
#### Scenario: 未选中目标时不请求
|
||||||
- **WHEN** 用户未点击任何目标表格行
|
- **WHEN** 用户未点击任何目标表格行
|
||||||
- **THEN** trend 和 history 的 useQuery SHALL enabled=false,不发起请求
|
- **THEN** metrics 和 history 的 useQuery SHALL enabled=false,不发起请求
|
||||||
|
|
||||||
#### Scenario: 选中目标时自动请求
|
#### Scenario: 打开 Drawer 默认只请求指标
|
||||||
- **WHEN** 用户点击目标表格行
|
- **WHEN** 用户点击目标表格行并打开 Drawer
|
||||||
- **THEN** trend 和 history 的 useQuery SHALL enabled=true,自动发起请求
|
- **THEN** metrics 的 useQuery SHALL enabled=true 并自动发起请求,history 的 useQuery SHALL enabled=false 且不发起请求
|
||||||
|
|
||||||
#### Scenario: 时间范围变化时重新请求
|
#### Scenario: 激活记录 Tab 时请求历史记录
|
||||||
- **WHEN** 用户更改时间范围
|
- **WHEN** 用户切换到"记录"Tab 且目标与时间范围有效
|
||||||
- **THEN** trend 和 history 的 useQuery SHALL 因 queryKey 变化自动重新请求
|
- **THEN** history 的 useQuery SHALL enabled=true,并请求当前页码对应的 `/api/targets/:id/history` 数据
|
||||||
|
|
||||||
|
#### Scenario: 概览 Tab 时间范围变化时不请求历史记录
|
||||||
|
- **WHEN** 用户在"概览"Tab 修改时间范围
|
||||||
|
- **THEN** metrics 的 useQuery SHALL 因 queryKey 变化自动重新请求,history 的 useQuery SHALL 保持 enabled=false 且不发起请求
|
||||||
|
|
||||||
|
#### Scenario: 记录 Tab 时间范围变化时重新请求历史记录
|
||||||
|
- **WHEN** 用户在"记录"Tab 修改时间范围
|
||||||
|
- **THEN** metrics 和 history 的 useQuery SHALL 因 queryKey 变化自动重新请求,并将 history 页码重置为 1
|
||||||
|
|
||||||
### Requirement: 开发调试面板
|
### Requirement: 开发调试面板
|
||||||
开发环境下 SHALL 挂载 TanStack Query Devtools。
|
开发环境下 SHALL 挂载 TanStack Query Devtools。
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: 目标详情 Drawer
|
### Requirement: 目标详情 Drawer
|
||||||
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。Drawer 内容 SHALL 拆分为独立的 Tab 组件。
|
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。Drawer 内容 SHALL 拆分为独立的 Tab 组件,并优先通过 TDesign Drawer 原生生命周期能力控制显示、隐藏和滚动穿透。
|
||||||
|
|
||||||
#### Scenario: 打开 Drawer
|
#### Scenario: 打开 Drawer
|
||||||
- **WHEN** 用户点击某个目标表格行
|
- **WHEN** 用户点击某个目标表格行
|
||||||
- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="right"),宽度为 52%
|
- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="right"),宽度为 52%,并将当前 Tab 重置为"概览"
|
||||||
|
|
||||||
#### Scenario: Drawer 标题栏
|
#### Scenario: Drawer 标题栏
|
||||||
- **WHEN** Drawer 渲染
|
- **WHEN** Drawer 渲染
|
||||||
@@ -17,7 +17,7 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示
|
|||||||
|
|
||||||
#### Scenario: 关闭 Drawer
|
#### Scenario: 关闭 Drawer
|
||||||
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层
|
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层
|
||||||
- **THEN** Drawer SHALL 关闭
|
- **THEN** Drawer SHALL 关闭并通过 TDesign Drawer 的 `visible` 状态隐藏
|
||||||
|
|
||||||
#### Scenario: Drawer 无底部按钮
|
#### Scenario: Drawer 无底部按钮
|
||||||
- **WHEN** Drawer 渲染
|
- **WHEN** Drawer 渲染
|
||||||
@@ -29,7 +29,7 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示
|
|||||||
|
|
||||||
#### Scenario: 切换目标重置 Tab
|
#### Scenario: 切换目标重置 Tab
|
||||||
- **WHEN** 用户从目标 A 切换到目标 B(点击不同的表格行)
|
- **WHEN** 用户从目标 A 切换到目标 B(点击不同的表格行)
|
||||||
- **THEN** Drawer SHALL 重置为概览 Tab,使用 key={target.id} 确保组件状态不残留
|
- **THEN** Drawer SHALL 通过受控 Tab 状态重置为概览 Tab,且 MUST NOT 使用 `key={target.id}` 强制重建 Drawer 子树
|
||||||
|
|
||||||
#### Scenario: Drawer 内容区间距
|
#### Scenario: Drawer 内容区间距
|
||||||
- **WHEN** Drawer 内容渲染
|
- **WHEN** Drawer 内容渲染
|
||||||
@@ -104,10 +104,10 @@ StatusBar 组件 SHALL 支持可配置的格数。
|
|||||||
|
|
||||||
#### Scenario: Drawer 关闭清理查询缓存
|
#### Scenario: Drawer 关闭清理查询缓存
|
||||||
- **WHEN** 用户关闭 Drawer
|
- **WHEN** 用户关闭 Drawer
|
||||||
- **THEN** 系统 MAY 清理 metrics 和 history 查询缓存,避免旧目标数据残留
|
- **THEN** 系统 MAY 保留 metrics 和 history 查询缓存以降低重复打开成本,依赖 TanStack Query 全局 staleTime 自动管理过期
|
||||||
|
|
||||||
### Requirement: 时间范围选择器
|
### Requirement: 时间范围选择器
|
||||||
Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录两个面板的数据。
|
Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览面板的数据;当记录面板处于激活状态或后续首次进入记录面板时,时间范围也 SHALL 影响记录面板的数据。
|
||||||
|
|
||||||
#### Scenario: 快捷时间按钮
|
#### Scenario: 快捷时间按钮
|
||||||
- **WHEN** Drawer 渲染
|
- **WHEN** Drawer 渲染
|
||||||
@@ -121,21 +121,25 @@ Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录
|
|||||||
- **WHEN** 用户点击 1小时/6小时/24小时/7天 快捷按钮
|
- **WHEN** 用户点击 1小时/6小时/24小时/7天 快捷按钮
|
||||||
- **THEN** 概览面板 SHALL 使用对应的时间窗口重新请求 `/api/targets/:id/metrics` 数据
|
- **THEN** 概览面板 SHALL 使用对应的时间窗口重新请求 `/api/targets/:id/metrics` 数据
|
||||||
|
|
||||||
#### Scenario: 快捷按钮联动历史记录
|
#### Scenario: 快捷按钮联动激活的历史记录
|
||||||
- **WHEN** 用户点击 1小时/6小时/24小时/7天 快捷按钮
|
- **WHEN** 用户在"记录"Tab 激活时点击 1小时/6小时/24小时/7天 快捷按钮
|
||||||
- **THEN** 记录面板 SHALL 使用对应的时间窗口重新请求 `/api/targets/:id/history` 数据,并重置页码为 1
|
- **THEN** 记录面板 SHALL 使用对应的时间窗口重新请求 `/api/targets/:id/history` 数据,并重置页码为 1
|
||||||
|
|
||||||
|
#### Scenario: 快捷按钮不预取未激活历史记录
|
||||||
|
- **WHEN** 用户在"概览"Tab 激活时点击 1小时/6小时/24小时/7天 快捷按钮
|
||||||
|
- **THEN** 系统 SHALL NOT 请求 `/api/targets/:id/history`,直到用户切换到"记录"Tab
|
||||||
|
|
||||||
#### Scenario: 自定义日期时间范围
|
#### Scenario: 自定义日期时间范围
|
||||||
- **WHEN** 用户通过 TDesign DateRangePicker(mode=date, enableTimePicker, format="YYYY-MM-DD HH:mm")修改时间范围
|
- **WHEN** 用户通过 TDesign DateRangePicker(mode=date, enableTimePicker, format="YYYY-MM-DD HH:mm")修改时间范围
|
||||||
- **THEN** 快捷按钮 SHALL 取消高亮,系统重新请求对应时间范围的数据
|
- **THEN** 快捷按钮 SHALL 取消高亮,系统 SHALL 按新的时间范围刷新概览数据,并按当前 Tab 状态决定是否刷新历史记录
|
||||||
|
|
||||||
#### Scenario: 时间精度为分钟级
|
#### Scenario: 时间精度为分钟级
|
||||||
- **WHEN** 用户通过 DateRangePicker 选择时间
|
- **WHEN** 用户通过 DateRangePicker 选择时间
|
||||||
- **THEN** 选择器 SHALL 仅精确到分钟(format="YYYY-MM-DD HH:mm"),秒列固定为 00
|
- **THEN** 选择器 SHALL 仅精确到分钟(format="YYYY-MM-DD HH:mm"),秒列固定为 00
|
||||||
|
|
||||||
#### Scenario: DateRangePicker 全宽显示
|
#### Scenario: DateRangePicker 自适应显示
|
||||||
- **WHEN** Drawer 渲染
|
- **WHEN** Drawer 渲染
|
||||||
- **THEN** DateRangePicker SHALL 通过 CSS 类 `.full-width` 占满时间选择区的宽度,不使用内联 style 的 width: 100%
|
- **THEN** DateRangePicker SHALL 通过 CSS 类 `.drawer-date-range`(替代原 `.full-width`)自适应填充时间选择区剩余宽度,不使用内联 style 的 width: 100%
|
||||||
|
|
||||||
#### Scenario: 默认时间范围
|
#### Scenario: 默认时间范围
|
||||||
- **WHEN** Drawer 打开
|
- **WHEN** Drawer 打开
|
||||||
@@ -143,19 +147,31 @@ Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录
|
|||||||
|
|
||||||
#### Scenario: 筛选触发数据刷新
|
#### Scenario: 筛选触发数据刷新
|
||||||
- **WHEN** 时间范围发生变化
|
- **WHEN** 时间范围发生变化
|
||||||
- **THEN** 系统 SHALL 重新请求趋势数据和历史记录
|
- **THEN** 系统 SHALL 重新请求趋势数据;若"记录"Tab 当前激活,系统 SHALL 同时重新请求历史记录,否则 SHALL 延迟到用户进入"记录"Tab 后请求历史记录
|
||||||
|
|
||||||
### Requirement: Tabs 内容组织
|
### Requirement: Tabs 内容组织
|
||||||
Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。TabPanel 内边距通过 className prop 控制。
|
Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。Tabs SHALL 使用受控 value 管理当前激活 Tab,TabPanel 内边距通过 className prop 控制。
|
||||||
|
|
||||||
#### Scenario: Tab 标签
|
#### Scenario: Tab 标签
|
||||||
- **WHEN** Drawer 渲染
|
- **WHEN** Drawer 渲染
|
||||||
- **THEN** Tabs SHALL 显示两个标签:概览、记录
|
- **THEN** Tabs SHALL 显示两个标签:概览、记录
|
||||||
|
|
||||||
|
#### Scenario: Tabs 受控状态
|
||||||
|
- **WHEN** 用户切换 Tab
|
||||||
|
- **THEN** Tabs SHALL 通过 `value` 和 `onChange` 更新由 Drawer 状态 hook 管理的当前 Tab 值
|
||||||
|
|
||||||
|
#### Scenario: 默认概览 Tab
|
||||||
|
- **WHEN** Drawer 打开或切换到另一个目标
|
||||||
|
- **THEN** 当前 Tab SHALL 重置为 `overview`
|
||||||
|
|
||||||
#### Scenario: Tab 面板内边距
|
#### Scenario: Tab 面板内边距
|
||||||
- **WHEN** TabPanel 渲染
|
- **WHEN** TabPanel 渲染
|
||||||
- **THEN** TabPanel SHALL 通过 `className` prop 传入自定义类名(`tab-panel-padded`)控制内边距,不通过入侵 TDesign 内部类名(`.t-tab-panel`)覆盖
|
- **THEN** TabPanel SHALL 通过 `className` prop 传入自定义类名(`tab-panel-padded`)控制内边距,不通过入侵 TDesign 内部类名(`.t-tab-panel`)覆盖
|
||||||
|
|
||||||
|
#### Scenario: TabPanel 懒渲染与缓存
|
||||||
|
- **WHEN** 用户在概览和记录 Tab 之间切换
|
||||||
|
- **THEN** 概览和记录 TabPanel 均 SHALL 配置 TDesign TabPanel 的 `destroyOnHide={false}`,隐藏时不销毁组件,保留已挂载的面板状态和已加载的数据;记录 TabPanel SHALL 额外配置懒渲染,首次进入前不渲染 HistoryTab
|
||||||
|
|
||||||
### Requirement: 概览面板
|
### Requirement: 概览面板
|
||||||
概览 Tab SHALL 按区域展示基本信息、多维度统计和趋势图。
|
概览 Tab SHALL 按区域展示基本信息、多维度统计和趋势图。
|
||||||
|
|
||||||
@@ -195,6 +211,25 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。TabP
|
|||||||
- **WHEN** metricsData 正在加载
|
- **WHEN** metricsData 正在加载
|
||||||
- **THEN** "趋势"区域 SHALL 显示 TDesign Skeleton 加载占位
|
- **THEN** "趋势"区域 SHALL 显示 TDesign Skeleton 加载占位
|
||||||
|
|
||||||
|
### Requirement: Drawer TDesign 原生生命周期与滚动控制
|
||||||
|
目标详情 Drawer SHALL 优先使用 TDesign Drawer 的原生 props 控制挂载、可见性和滚动穿透,不通过自定义滚轮事件实现滚动控制。
|
||||||
|
|
||||||
|
#### Scenario: Drawer 常驻受控渲染
|
||||||
|
- **WHEN** 未选中目标时
|
||||||
|
- **THEN** `TargetDetailDrawer` SHALL 保留 TDesign Drawer 组件并通过 `visible=false` 隐藏,而不是直接返回 `null` 卸载 Drawer 子树
|
||||||
|
|
||||||
|
#### Scenario: Drawer 防滚动穿透配置
|
||||||
|
- **WHEN** Drawer 渲染
|
||||||
|
- **THEN** Drawer SHALL 显式使用 `attach="body"`、`preventScrollThrough=true`、`showInAttachedElement=false` 和 `showOverlay=true`
|
||||||
|
|
||||||
|
#### Scenario: Drawer 关闭后保留子树
|
||||||
|
- **WHEN** 用户关闭 Drawer
|
||||||
|
- **THEN** Drawer SHALL 使用 `destroyOnClose=false` 保留已挂载内容,避免重复打开时重建完整子树
|
||||||
|
|
||||||
|
#### Scenario: Drawer 单一纵向滚动容器
|
||||||
|
- **WHEN** Drawer 内容高度超过可视区域
|
||||||
|
- **THEN** 系统 SHALL 依赖 Drawer 内容区域作为唯一纵向滚动容器,HistoryTab 中的 PrimaryTable SHALL 不配置 `height`、`maxHeight` 或纵向 `scroll` 来创建第二个纵向滚动区域
|
||||||
|
|
||||||
### Requirement: Drawer 宽度
|
### Requirement: Drawer 宽度
|
||||||
Drawer 宽度 SHALL 设置为 52%。
|
Drawer 宽度 SHALL 设置为 52%。
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ export function App() {
|
|||||||
refetch: refetchDashboard,
|
refetch: refetchDashboard,
|
||||||
} = useDashboard(dashboardRefetchInterval);
|
} = useDashboard(dashboardRefetchInterval);
|
||||||
const {
|
const {
|
||||||
|
activeTab,
|
||||||
closeDrawer,
|
closeDrawer,
|
||||||
handlePageChange,
|
handlePageChange,
|
||||||
|
handleTabChange,
|
||||||
handleTimeChange,
|
handleTimeChange,
|
||||||
historyData,
|
historyData,
|
||||||
historyLoading,
|
historyLoading,
|
||||||
@@ -126,13 +128,14 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</Content>
|
</Content>
|
||||||
<TargetDetailDrawer
|
<TargetDetailDrawer
|
||||||
|
activeTab={activeTab}
|
||||||
historyData={historyData}
|
historyData={historyData}
|
||||||
historyLoading={historyLoading}
|
historyLoading={historyLoading}
|
||||||
key={selectedTarget?.id}
|
|
||||||
metricsData={metricsData}
|
metricsData={metricsData}
|
||||||
metricsLoading={metricsLoading}
|
metricsLoading={metricsLoading}
|
||||||
onClose={closeDrawer}
|
onClose={closeDrawer}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
onTimeChange={handleTimeChange}
|
onTimeChange={handleTimeChange}
|
||||||
target={selectedTarget}
|
target={selectedTarget}
|
||||||
timeFrom={timeFrom}
|
timeFrom={timeFrom}
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import { OverviewTab } from "./OverviewTab";
|
|||||||
import { StatusDot } from "./StatusDot";
|
import { StatusDot } from "./StatusDot";
|
||||||
|
|
||||||
interface TargetDetailDrawerProps {
|
interface TargetDetailDrawerProps {
|
||||||
|
activeTab: string;
|
||||||
historyData: HistoryResponse;
|
historyData: HistoryResponse;
|
||||||
historyLoading: boolean;
|
historyLoading: boolean;
|
||||||
metricsData: null | TargetMetricsResponse;
|
metricsData: null | TargetMetricsResponse;
|
||||||
metricsLoading: boolean;
|
metricsLoading: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
|
onTabChange: (tab: string) => void;
|
||||||
onTimeChange: (from: string, to: string) => void;
|
onTimeChange: (from: string, to: string) => void;
|
||||||
target: null | TargetStatus;
|
target: null | TargetStatus;
|
||||||
timeFrom: string;
|
timeFrom: string;
|
||||||
@@ -31,19 +33,20 @@ const TIME_SHORTCUTS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function TargetDetailDrawer({
|
export function TargetDetailDrawer({
|
||||||
|
activeTab,
|
||||||
historyData,
|
historyData,
|
||||||
historyLoading,
|
historyLoading,
|
||||||
metricsData,
|
metricsData,
|
||||||
metricsLoading,
|
metricsLoading,
|
||||||
onClose,
|
onClose,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
onTabChange,
|
||||||
onTimeChange,
|
onTimeChange,
|
||||||
target,
|
target,
|
||||||
timeFrom,
|
timeFrom,
|
||||||
timeTo,
|
timeTo,
|
||||||
}: TargetDetailDrawerProps) {
|
}: TargetDetailDrawerProps) {
|
||||||
const [activeShortcut, setActiveShortcut] = useState<string>("24h");
|
const [activeShortcut, setActiveShortcut] = useState<string>("24h");
|
||||||
const [activeTab, setActiveTab] = useState<TabValue>("overview");
|
|
||||||
|
|
||||||
const handleShortcut = useCallback(
|
const handleShortcut = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
@@ -67,25 +70,29 @@ export function TargetDetailDrawer({
|
|||||||
[onTimeChange],
|
[onTimeChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!target) return null;
|
const isUp = target?.latestCheck?.matched;
|
||||||
|
|
||||||
const isUp = target.latestCheck?.matched;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
|
attach="body"
|
||||||
footer={false}
|
footer={false}
|
||||||
header={
|
header={
|
||||||
<Space align="center" size={8}>
|
target ? (
|
||||||
<StatusDot up={!!isUp} />
|
<Space align="center" size={8}>
|
||||||
<Typography.Text strong>{target.name}</Typography.Text>
|
<StatusDot up={!!isUp} />
|
||||||
<Tag size="small" theme="primary" variant="light-outline">
|
<Typography.Text strong>{target.name}</Typography.Text>
|
||||||
{target.type}
|
<Tag size="small" theme="primary" variant="light-outline">
|
||||||
</Tag>
|
{target.type}
|
||||||
</Space>
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
placement="right"
|
placement="right"
|
||||||
size="52%"
|
preventScrollThrough
|
||||||
|
showInAttachedElement={false}
|
||||||
|
showOverlay
|
||||||
|
size="55%"
|
||||||
visible={!!target}
|
visible={!!target}
|
||||||
>
|
>
|
||||||
<Space className="full-width" direction="vertical" size={16}>
|
<Space className="full-width" direction="vertical" size={16}>
|
||||||
@@ -109,12 +116,12 @@ export function TargetDetailDrawer({
|
|||||||
valueType="YYYY-MM-DD HH:mm"
|
valueType="YYYY-MM-DD HH:mm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
|
<Tabs onChange={(val: TabValue) => onTabChange(String(val))} value={activeTab}>
|
||||||
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
|
<Tabs.TabPanel className="tab-panel-padded" destroyOnHide={false} label="概览" value="overview">
|
||||||
<OverviewTab metricsData={metricsData} metricsLoading={metricsLoading} target={target} />
|
{target && <OverviewTab metricsData={metricsData} metricsLoading={metricsLoading} target={target} />}
|
||||||
</Tabs.TabPanel>
|
</Tabs.TabPanel>
|
||||||
|
|
||||||
<Tabs.TabPanel className="tab-panel-padded" label="记录" value="history">
|
<Tabs.TabPanel className="tab-panel-padded" destroyOnHide={false} label="记录" lazy value="history">
|
||||||
<HistoryTab historyData={historyData} historyLoading={historyLoading} onPageChange={onPageChange} />
|
<HistoryTab historyData={historyData} historyLoading={historyLoading} onPageChange={onPageChange} />
|
||||||
</Tabs.TabPanel>
|
</Tabs.TabPanel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import type { HistoryResponse, TargetStatus } from "../../shared/api";
|
import type { HistoryResponse, TargetStatus } from "../../shared/api";
|
||||||
@@ -11,11 +11,11 @@ const detailQueryKeys = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useTargetDetail() {
|
export function useTargetDetail() {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [selectedTargetId, setSelectedTargetId] = useState<null | number>(null);
|
const [selectedTargetId, setSelectedTargetId] = useState<null | number>(null);
|
||||||
const [timeFrom, setTimeFrom] = useState("");
|
const [timeFrom, setTimeFrom] = useState("");
|
||||||
const [timeTo, setTimeTo] = useState("");
|
const [timeTo, setTimeTo] = useState("");
|
||||||
const [historyPage, setHistoryPage] = useState(1);
|
const [historyPage, setHistoryPage] = useState(1);
|
||||||
|
const [activeTab, setActiveTab] = useState<string>("overview");
|
||||||
|
|
||||||
const { data: dashboardData } = useDashboard(false);
|
const { data: dashboardData } = useDashboard(false);
|
||||||
const selectedTarget =
|
const selectedTarget =
|
||||||
@@ -26,7 +26,7 @@ export function useTargetDetail() {
|
|||||||
const metrics = useTargetMetrics(selectedTargetId, timeFrom, timeTo, "1h");
|
const metrics = useTargetMetrics(selectedTargetId, timeFrom, timeTo, "1h");
|
||||||
|
|
||||||
const history = useQuery({
|
const history = useQuery({
|
||||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo && activeTab === "history",
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
if (selectedTargetId === null) throw new Error("未选择目标");
|
if (selectedTargetId === null) throw new Error("未选择目标");
|
||||||
return fetchJson<HistoryResponse>(
|
return fetchJson<HistoryResponse>(
|
||||||
@@ -46,13 +46,13 @@ export function useTargetDetail() {
|
|||||||
setTimeFrom(from.toISOString());
|
setTimeFrom(from.toISOString());
|
||||||
setTimeTo(now.toISOString());
|
setTimeTo(now.toISOString());
|
||||||
setHistoryPage(1);
|
setHistoryPage(1);
|
||||||
|
setActiveTab("overview");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeDrawer = useCallback(() => {
|
const closeDrawer = useCallback(() => {
|
||||||
setSelectedTargetId(null);
|
setSelectedTargetId(null);
|
||||||
queryClient.removeQueries({ queryKey: ["metrics"] });
|
setActiveTab("overview");
|
||||||
queryClient.removeQueries({ queryKey: ["history"] });
|
}, []);
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
const handleTimeChange = useCallback((from: string, to: string) => {
|
const handleTimeChange = useCallback((from: string, to: string) => {
|
||||||
setTimeFrom(from);
|
setTimeFrom(from);
|
||||||
@@ -64,9 +64,15 @@ export function useTargetDetail() {
|
|||||||
setHistoryPage(page);
|
setHistoryPage(page);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback((tab: string) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
activeTab,
|
||||||
closeDrawer,
|
closeDrawer,
|
||||||
handlePageChange,
|
handlePageChange,
|
||||||
|
handleTabChange,
|
||||||
handleTimeChange,
|
handleTimeChange,
|
||||||
historyData: history.data ?? { items: [], page: 1, pageSize: 20, total: 0 },
|
historyData: history.data ?? { items: [], page: 1, pageSize: 20, total: 0 },
|
||||||
historyLoading: history.isLoading,
|
historyLoading: history.isLoading,
|
||||||
|
|||||||
81
tests/web/hooks/use-target-detail-logic.test.ts
Normal file
81
tests/web/hooks/use-target-detail-logic.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
function shouldEnableHistory(
|
||||||
|
selectedTargetId: null | number,
|
||||||
|
timeFrom: string,
|
||||||
|
timeTo: string,
|
||||||
|
activeTab: string,
|
||||||
|
): boolean {
|
||||||
|
return selectedTargetId !== null && !!timeFrom && !!timeTo && activeTab === "history";
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldEnableMetrics(selectedTargetId: null | number, timeFrom: string, timeTo: string): boolean {
|
||||||
|
return selectedTargetId !== null && !!timeFrom && !!timeTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("metrics enabled 条件", () => {
|
||||||
|
test("未选中目标时不启用", () => {
|
||||||
|
expect(shouldEnableMetrics(null, "", "")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("选中目标但无时间范围时不启用", () => {
|
||||||
|
expect(shouldEnableMetrics(1, "", "")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("选中目标且有时间范围时启用", () => {
|
||||||
|
expect(shouldEnableMetrics(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("history enabled 条件", () => {
|
||||||
|
test("未选中目标时不启用", () => {
|
||||||
|
expect(shouldEnableHistory(null, "from", "to", "history")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("选中目标但概览 Tab 时不启用", () => {
|
||||||
|
expect(shouldEnableHistory(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z", "overview")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("选中目标且记录 Tab 激活但无时间范围时不启用", () => {
|
||||||
|
expect(shouldEnableHistory(1, "", "", "history")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("选中目标、有时间范围且记录 Tab 激活时启用", () => {
|
||||||
|
expect(shouldEnableHistory(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z", "history")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("打开 Drawer 默认概览 Tab 时不启用 history", () => {
|
||||||
|
expect(shouldEnableHistory(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z", "overview")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("概览 Tab 时间变化时不启用 history", () => {
|
||||||
|
expect(shouldEnableHistory(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z", "overview")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("记录 Tab 时间变化时启用 history", () => {
|
||||||
|
expect(shouldEnableHistory(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z", "history")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("默认概览 Tab 行为", () => {
|
||||||
|
test("打开 Drawer 时 activeTab 应为 overview", () => {
|
||||||
|
const resetTab = "overview";
|
||||||
|
expect(resetTab).toBe("overview");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("切换目标时 activeTab 应重置为 overview", () => {
|
||||||
|
const previousTab = "history";
|
||||||
|
const resetTab = "overview";
|
||||||
|
expect(previousTab).not.toBe(resetTab);
|
||||||
|
expect(resetTab).toBe("overview");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("history 页码重置", () => {
|
||||||
|
test("时间变化时 historyPage 应重置为 1", () => {
|
||||||
|
const previousPage = 3;
|
||||||
|
const resetPage = 1;
|
||||||
|
expect(previousPage).not.toBe(resetPage);
|
||||||
|
expect(resetPage).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user