1
0

feat: 优化目标详情 Drawer 性能 — TDesign 生命周期控制、Tab 感知延迟加载、滚动穿透修复

This commit is contained in:
2026-05-15 00:53:41 +08:00
parent 9904f198aa
commit 28e46b8431
7 changed files with 204 additions and 62 deletions

View File

@@ -561,10 +561,11 @@ main.tsx
│ ├── useMeta() ───── GET /api/meta应用生命周期内缓存 │ ├── useMeta() ───── GET /api/meta应用生命周期内缓存
│ └── TargetGroup[]Card 包裹 PrimaryTableheaderBordered │ └── TargetGroup[]Card 包裹 PrimaryTableheaderBordered
│ └── 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.tsDrawer 状态与详情级条件查询) hooks/use-target-detail.tsDrawer 状态与详情级条件查询)
├── 内部复用 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` | 单个分组 Cardtitle+actions+headerBordered+ PrimaryTable | | `TargetGroup` | `components/TargetGroup.tsx` | 单个分组 Cardtitle+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 新增功能开发步骤

View File

@@ -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。

View File

@@ -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 从右侧滑出 Drawerplacement="right"),宽度为 52% - **THEN** 系统 SHALL 从右侧滑出 Drawerplacement="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 DateRangePickermode=date, enableTimePicker, format="YYYY-MM-DD HH:mm")修改时间范围 - **WHEN** 用户通过 TDesign DateRangePickermode=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 管理当前激活 TabTabPanel 内边距通过 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%。

View File

@@ -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}

View File

@@ -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>

View File

@@ -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,

View 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);
});
});