diff --git a/openspec/specs/refresh-control/spec.md b/openspec/specs/refresh-control/spec.md
index 707c08e..3c2b041 100644
--- a/openspec/specs/refresh-control/spec.md
+++ b/openspec/specs/refresh-control/spec.md
@@ -20,7 +20,15 @@ HeadMenu operations 区域 SHALL 提供 RadioGroup 组件供用户选择刷新
- **THEN** 系统 SHALL 立即触发一次数据刷新,然后应用新的刷新间隔
### Requirement: 倒计时显示
-RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时文本。
+RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时文本。倒计时逻辑 SHALL 封装在独立的 `RefreshCountdown` 组件中,App 组件 SHALL NOT 持有每秒更新的 `now` state。
+
+#### Scenario: RefreshCountdown 组件封装
+- **WHEN** Dashboard 页面渲染
+- **THEN** 倒计时显示 SHALL 由独立的 `RefreshCountdown` 组件负责,该组件内部持有 `now` state 和每秒 `setInterval`,渲染边界限制在该组件内部
+
+#### Scenario: RefreshCountdown props
+- **WHEN** RefreshCountdown 组件渲染
+- **THEN** 组件 SHALL 接收 `dashboardUpdatedAt: number`、`refreshInterval: number`、`isFetching: boolean`、`isManualRefresh: boolean`、`onRefresh: () => void` 作为 props
#### Scenario: 短时间格式
- **WHEN** 距下次刷新剩余时间小于 60 秒
@@ -38,6 +46,17 @@ RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时文本。
- **WHEN** 数据正在刷新(isFetching=true 且 isLoading=false)
- **THEN** 倒计时文本 SHALL 显示为"刷新中..."
+### Requirement: App 组件渲染隔离
+App 组件 SHALL NOT 持有任何高频更新的 state(如每秒更新的时钟),确保 App 的重渲染频率与数据刷新频率一致(默认 30 秒一次)。
+
+#### Scenario: App 无 now state
+- **WHEN** App 组件渲染
+- **THEN** App SHALL NOT 包含 `useState` 管理的时钟 state,也 SHALL NOT 包含每秒触发的 `setInterval`
+
+#### Scenario: App 重渲染频率
+- **WHEN** Dashboard 处于自动刷新模式
+- **THEN** App 组件的重渲染 SHALL 仅由 TanStack Query 的 refetch 触发(频率等于用户选择的刷新间隔),而非每秒触发
+
### Requirement: 手动刷新按钮
选择"手动"模式时,倒计时区域 SHALL 替换为刷新按钮。
diff --git a/openspec/specs/target-detail-drawer/spec.md b/openspec/specs/target-detail-drawer/spec.md
index 0b25a83..9708a39 100644
--- a/openspec/specs/target-detail-drawer/spec.md
+++ b/openspec/specs/target-detail-drawer/spec.md
@@ -72,6 +72,36 @@ TrendChart 组件 SHALL 仅接收数据 props,不处理 loading 状态。
- **WHEN** TrendChart 接收空数组
- **THEN** 组件 SHALL 显示"暂无趋势数据"占位文本
+#### Scenario: TrendChart memo 包裹
+- **WHEN** TrendChart 的父组件重渲染但 data prop 引用未变
+- **THEN** TrendChart SHALL 跳过重渲染(通过 React.memo shallow compare)
+
+#### Scenario: chartData useMemo
+- **WHEN** TrendChart 渲染
+- **THEN** 内部 `chartData` 转换结果 SHALL 通过 `useMemo` 缓存,依赖为 `[data]`,data 引用不变时不重新计算
+
+### Requirement: TargetBoard 分组 memoize
+TargetBoard 组件的分组计算 SHALL 使用 useMemo 缓存,避免 targets 引用不变时重复计算分组。
+
+#### Scenario: 分组结果 useMemo
+- **WHEN** TargetBoard 渲染
+- **THEN** 分组逻辑(Map 构建 + sort)SHALL 通过 `useMemo` 缓存,依赖为 `[targets]`
+
+#### Scenario: targets 引用不变时跳过分组
+- **WHEN** TargetBoard 因父组件重渲染而重渲染,但 targets prop 引用未变
+- **THEN** 分组计算 SHALL 返回缓存结果,不重新执行 Map 构建和排序
+
+### Requirement: TargetGroup 渲染优化
+TargetGroup 组件 SHALL 使用 React.memo 包裹,在 props 引用不变时跳过重渲染。
+
+#### Scenario: TargetGroup memo 包裹
+- **WHEN** TargetBoard 重渲染但某个分组的 targets 数组引用未变
+- **THEN** 对应的 TargetGroup SHALL 跳过重渲染(通过 React.memo shallow compare)
+
+#### Scenario: TargetGroup props 稳定性
+- **WHEN** TargetGroup 渲染
+- **THEN** 其 props(columns、name、targets、onTargetClick)SHALL 全部具有引用稳定性:columns 通过 useMemo、name 为 string 原始值、targets 通过分组 useMemo、onTargetClick 通过 useCallback
+
### Requirement: StatusBar 参数化
StatusBar 组件 SHALL 支持可配置的格数。
@@ -189,7 +219,7 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。Tabs
#### Scenario: 统计区左右布局卡片
- **WHEN** 概览面板渲染且有统计数据
-- **THEN** 面板 SHALL 在"统计"区域使用 4 列 × 2 行的 Row/Col 布局,每个统计项使用 Card 包裹,Card 内标题左对齐、数值右对齐,数值使用普通文本字号
+- **THEN** 面板 SHALL 在"统计"区域使用 4 列 × 2 行的 Row/Col 布局,每个统计项使用 `
` 包裹,通过 CSS 类实现背景色和内边距视觉效果
#### Scenario: 统计区内容
- **WHEN** 概览面板渲染
diff --git a/src/web/app.tsx b/src/web/app.tsx
index f988af7..f5c64bc 100644
--- a/src/web/app.tsx
+++ b/src/web/app.tsx
@@ -1,15 +1,14 @@
import type { SkeletonProps } from "tdesign-react";
-import { useEffect, useState } from "react";
-import { RefreshIcon } from "tdesign-icons-react";
-import { Alert, Button, Layout, Menu, RadioGroup, Skeleton, Typography } from "tdesign-react";
+import { useState } from "react";
+import { Alert, Layout, Menu, RadioGroup, Skeleton } from "tdesign-react";
+import { RefreshCountdown } from "./components/RefreshCountdown";
import { SummaryCards } from "./components/SummaryCards";
import { TargetBoard } from "./components/TargetBoard";
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
import { useDashboard } from "./hooks/use-queries";
import { useTargetDetail } from "./hooks/use-target-detail";
-import { formatCountdown } from "./utils/time";
const { Content, Header } = Layout;
const DEFAULT_REFRESH_INTERVAL_MS = 30000;
@@ -27,7 +26,6 @@ const REFRESH_OPTIONS = [
] as const;
export function App() {
- const [now, setNow] = useState(() => new Date());
const [refreshInterval, setRefreshInterval] = useState(DEFAULT_REFRESH_INTERVAL_MS);
const dashboardRefetchInterval = refreshInterval === 0 ? false : refreshInterval;
const {
@@ -54,27 +52,12 @@ export function App() {
timeTo,
} = useTargetDetail();
const isManualRefresh = refreshInterval === 0;
- const nextRefreshSeconds =
- dashboardUpdatedAt > 0 && !isManualRefresh
- ? Math.max(0, Math.ceil((dashboardUpdatedAt + refreshInterval - now.getTime()) / 1000))
- : null;
- const refreshText =
- dashboardUpdatedAt > 0
- ? dashboardFetching && !dashboardLoading
- ? "刷新中..."
- : formatCountdown(nextRefreshSeconds ?? 0)
- : "等待首次刷新";
const handleIntervalChange = (value: number) => {
void refetchDashboard();
setRefreshInterval(value);
};
- useEffect(() => {
- const timer = window.setInterval(() => setNow(new Date()), 1000);
- return () => window.clearInterval(timer);
- }, []);
-
return (
@@ -95,19 +78,13 @@ export function App() {
variant="default-filled"
/>
- {isManualRefresh ? (
- }
- loading={dashboardFetching}
- onClick={() => void refetchDashboard()}
- shape="circle"
- variant="outline"
- />
- ) : (
- {refreshText}
- )}
+ void refetchDashboard()}
+ refreshInterval={refreshInterval}
+ />
}
diff --git a/src/web/components/OverviewTab.tsx b/src/web/components/OverviewTab.tsx
index 9683806..a9628cf 100644
--- a/src/web/components/OverviewTab.tsx
+++ b/src/web/components/OverviewTab.tsx
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
-import { Card, Col, Descriptions, Divider, Row, Skeleton, Space, Statistic, Typography } from "tdesign-react";
+import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic, Typography } from "tdesign-react";
import type { TargetMetricsResponse, TargetStatus } from "../../shared/api";
@@ -97,11 +97,11 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
function OverviewStatItem({ color, suffix, title, value }: OverviewStatItemProps) {
return (
-
+
);
}
diff --git a/src/web/components/RefreshCountdown.tsx b/src/web/components/RefreshCountdown.tsx
new file mode 100644
index 0000000..d38a877
--- /dev/null
+++ b/src/web/components/RefreshCountdown.tsx
@@ -0,0 +1,52 @@
+import { useEffect, useState } from "react";
+import { RefreshIcon } from "tdesign-icons-react";
+import { Button, Typography } from "tdesign-react";
+
+import { formatCountdown } from "../utils/time";
+
+interface RefreshCountdownProps {
+ dashboardUpdatedAt: number;
+ isFetching: boolean;
+ isManualRefresh: boolean;
+ onRefresh: () => void;
+ refreshInterval: number;
+}
+
+export function RefreshCountdown({
+ dashboardUpdatedAt,
+ isFetching,
+ isManualRefresh,
+ onRefresh,
+ refreshInterval,
+}: RefreshCountdownProps) {
+ const [now, setNow] = useState(() => new Date());
+
+ useEffect(() => {
+ const timer = window.setInterval(() => setNow(new Date()), 1000);
+ return () => window.clearInterval(timer);
+ }, []);
+
+ const nextRefreshSeconds =
+ dashboardUpdatedAt > 0 && !isManualRefresh
+ ? Math.max(0, Math.ceil((dashboardUpdatedAt + refreshInterval - now.getTime()) / 1000))
+ : null;
+
+ if (isManualRefresh) {
+ return (
+ }
+ loading={isFetching}
+ onClick={() => void onRefresh()}
+ shape="circle"
+ variant="outline"
+ />
+ );
+ }
+
+ const refreshText =
+ dashboardUpdatedAt > 0 ? (isFetching ? "刷新中..." : formatCountdown(nextRefreshSeconds ?? 0)) : "等待首次刷新";
+
+ return {refreshText};
+}
diff --git a/src/web/components/TargetBoard.tsx b/src/web/components/TargetBoard.tsx
index 8f9f54e..9f63bc4 100644
--- a/src/web/components/TargetBoard.tsx
+++ b/src/web/components/TargetBoard.tsx
@@ -19,21 +19,22 @@ export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
const checkerTypes = meta?.checkerTypes ?? EMPTY_CHECKER_TYPES;
const columns = useMemo(() => createTargetTableColumns(checkerTypes), [checkerTypes]);
- const groups = new Map();
- for (const target of targets) {
- const group = target.group;
- const list = groups.get(group);
- if (list) {
- list.push(target);
- } else {
- groups.set(group, [target]);
+ const sortedGroups = useMemo(() => {
+ const groups = new Map();
+ for (const target of targets) {
+ const group = target.group;
+ const list = groups.get(group);
+ if (list) {
+ list.push(target);
+ } else {
+ groups.set(group, [target]);
+ }
}
- }
-
- const sortedGroups = Array.from(groups.entries()).sort(([a]) => {
- if (a === "default") return -1;
- return 0;
- });
+ return Array.from(groups.entries()).sort(([a]) => {
+ if (a === "default") return -1;
+ return 0;
+ });
+ }, [targets]);
return (
diff --git a/src/web/components/TargetGroup.tsx b/src/web/components/TargetGroup.tsx
index ecd12f4..a83010b 100644
--- a/src/web/components/TargetGroup.tsx
+++ b/src/web/components/TargetGroup.tsx
@@ -1,5 +1,6 @@
import type { PrimaryTableCol } from "tdesign-react";
+import { memo } from "react";
import { Card, PrimaryTable, Space, Tag } from "tdesign-react";
import type { TargetStatus } from "../../shared/api";
@@ -11,7 +12,7 @@ interface TargetGroupProps {
targets: TargetStatus[];
}
-export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGroupProps) {
+export const TargetGroup = memo(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;
@@ -48,4 +49,4 @@ export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGro
/>
);
-}
+});
diff --git a/src/web/components/TrendChart.tsx b/src/web/components/TrendChart.tsx
index 5bf43f4..3333c89 100644
--- a/src/web/components/TrendChart.tsx
+++ b/src/web/components/TrendChart.tsx
@@ -1,3 +1,4 @@
+import { memo, useMemo } from "react";
import { Area, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import type { TrendPoint } from "../../shared/api";
@@ -12,18 +13,24 @@ interface TrendChartProps {
data: TrendPoint[];
}
-export function TrendChart({ data }: TrendChartProps) {
+export const TrendChart = memo(function TrendChart({ data }: TrendChartProps) {
+ const chartData = useMemo(
+ () =>
+ data.map((point) => ({
+ ...point,
+ durationRange:
+ point.minDurationMs !== null && point.maxDurationMs !== null
+ ? [point.minDurationMs, point.maxDurationMs]
+ : null,
+ label: formatBucketLabel(point.bucketStart),
+ })),
+ [data],
+ );
+
if (data.length === 0) {
return 暂无趋势数据
;
}
- const chartData = data.map((point) => ({
- ...point,
- durationRange:
- point.minDurationMs !== null && point.maxDurationMs !== null ? [point.minDurationMs, point.maxDurationMs] : null,
- label: formatBucketLabel(point.bucketStart),
- }));
-
return (
@@ -69,7 +76,7 @@ export function TrendChart({ data }: TrendChartProps) {
);
-}
+});
function formatBucketLabel(bucketStart: string): string {
return new Date(bucketStart).toLocaleTimeString("zh-CN", { hour: "2-digit", hour12: false, minute: "2-digit" });
diff --git a/src/web/styles.css b/src/web/styles.css
index efa22ac..146a298 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -172,6 +172,8 @@
.overview-stat-card {
background: var(--td-bg-color-container-hover);
+ border-radius: var(--td-radius-default);
+ padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
}
.overview-stat-item {