diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 34b4742..7236449 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -543,6 +543,7 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs)
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
| 数据层 | TanStack Query (React Query) + React Query Devtools | 服务端状态管理与自动轮询 |
| 图表 | Recharts | 拨测趋势折线图 |
+| 动画 | @number-flow/react | 倒计时数字滚动过渡 |
| 路由 | 无(单页面 Dashboard) | 仅需 Drawer/Tab 做页面内导航 |
**不引入的依赖**:React Router(单页面场景不需要)、状态管理库(TanStack Query 即服务端状态层,组件内用 `useState` 足够)、Vite(已由 Bun 原生 fullstack 替代)
@@ -702,6 +703,7 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
| `TrendChart` | `components/TrendChart.tsx` | Recharts 趋势折线图(耗时+延迟范围) |
| `StatusDot` | `components/StatusDot.tsx` | 圆形状态指示点(绿/红) |
| `StatusBar` | `components/StatusBar.tsx` | 最近采样状态条(多色块 + Tooltip 提示时间和状态) |
+| `RefreshCountdown` | `components/RefreshCountdown.tsx` | Header 刷新倒计时(NumberFlow 数字滚动),手动刷新按钮,刷新中/等待首次刷新文本 |
### 2.5 新增功能开发步骤
diff --git a/bun.lock b/bun.lock
index a64022e..08312b3 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,6 +5,7 @@
"": {
"name": "gateway-checker",
"dependencies": {
+ "@number-flow/react": "^0.6.0",
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.10",
"@xmldom/xmldom": "^0.9.10",
@@ -186,6 +187,8 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
+ "@number-flow/react": ["@number-flow/react@0.6.0", "https://registry.npmmirror.com/@number-flow/react/-/react-0.6.0.tgz", { "dependencies": { "esm-env": "^1.1.4", "number-flow": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-77Yfc9+zkV2UDSP8phhZzxJGuwxi/Tt1TikmipL+1r3e9GFKEYDZ1XwInj67NoSt3OnOB0KLvvcl3lfPZgBHVQ=="],
+
"@oxc-project/types": ["@oxc-project/types@0.130.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.130.0.tgz", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="],
"@pkgr/core": ["@pkgr/core@0.2.9", "https://registry.npmmirror.com/@pkgr/core/-/core-0.2.9.tgz", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
@@ -584,6 +587,8 @@
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
+ "esm-env": ["esm-env@1.2.2", "https://registry.npmmirror.com/esm-env/-/esm-env-1.2.2.tgz", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
+
"espree": ["espree@11.2.0", "https://registry.npmmirror.com/espree/-/espree-11.2.0.tgz", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"esquery": ["esquery@1.7.0", "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
@@ -852,6 +857,8 @@
"nth-check": ["nth-check@2.1.1", "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
+ "number-flow": ["number-flow@0.6.0", "https://registry.npmmirror.com/number-flow/-/number-flow-0.6.0.tgz", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-K8flNq2Wqus53vjp/btVo3qXFkagF8dIdYavreBfE7hlvFFG/b1HMGEH6nZL+mlrJ+4lbLP9OmPv3t2rmRkpSQ=="],
+
"object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
diff --git a/openspec/specs/css-utility-classes/spec.md b/openspec/specs/css-utility-classes/spec.md
index 175b07b..06b9500 100644
--- a/openspec/specs/css-utility-classes/spec.md
+++ b/openspec/specs/css-utility-classes/spec.md
@@ -105,6 +105,25 @@ styles.css SHALL 定义前端组件复用的工具类,包含页面布局相关
- **WHEN** Drawer 概览统计区渲染
- **THEN** 统计卡片 SHALL 使用 `.overview-stat-card` 类(background: var(--td-bg-color-container-hover)),并使用 TDesign Statistic 组件自带的上下布局(title 在上、value 在下),通过 `.summary-stat-col` 类(text-align: center)实现内容居中。系统 SHALL NOT 使用已移除的 `.overview-stat-item` 和 `.overview-stat-value` 类。
+### Requirement: NumberFlow 倒计时样式类
+styles.css SHALL 定义 NumberFlow 倒计时相关样式类,供 Header 倒计时组件使用。样式 SHALL 继承 TDesign 文本颜色或使用 TDesign CSS tokens,不得使用组件内联 `style`、硬编码色值、`!important` 或覆盖 TDesign 内部类名。
+
+#### Scenario: 倒计时滚动容器类
+- **WHEN** Header 自动刷新倒计时以 NumberFlow 形式渲染
+- **THEN** 倒计时 SHALL 使用集中定义的滚动容器类,保持 inline-flex、baseline 对齐、nowrap 和 tabular-nums
+
+#### Scenario: 倒计时数字类
+- **WHEN** NumberFlow 数字渲染
+- **THEN** 数字 SHALL 使用集中定义的数字类配置 line-height 和 NumberFlow mask CSS 变量,减少滚动边缘突兀感
+
+#### Scenario: 倒计时单位类
+- **WHEN** 分钟或秒单位文本渲染
+- **THEN** 单位 SHALL 使用集中定义的单位类与数字保持基线对齐,并继承当前 TDesign 文本色
+
+#### Scenario: 不使用内联样式
+- **WHEN** RefreshCountdown 组件渲染 NumberFlow 倒计时
+- **THEN** 组件 SHALL 通过 `className` 引用 styles.css 中的样式类,不得通过 React `style` prop 设置 NumberFlow 展示样式
+
### Requirement: 异常行背景类
styles.css SHALL 定义 DOWN 行的背景色和左侧竖线,使用安全选择器且不使用 `!important`。
diff --git a/openspec/specs/refresh-control/spec.md b/openspec/specs/refresh-control/spec.md
index 3c2b041..d011954 100644
--- a/openspec/specs/refresh-control/spec.md
+++ b/openspec/specs/refresh-control/spec.md
@@ -20,7 +20,7 @@ HeadMenu operations 区域 SHALL 提供 RadioGroup 组件供用户选择刷新
- **THEN** 系统 SHALL 立即触发一次数据刷新,然后应用新的刷新间隔
### Requirement: 倒计时显示
-RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时文本。倒计时逻辑 SHALL 封装在独立的 `RefreshCountdown` 组件中,App 组件 SHALL NOT 持有每秒更新的 `now` state。
+RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时。倒计时逻辑 SHALL 封装在独立的 `RefreshCountdown` 组件中,App 组件 SHALL NOT 持有每秒更新的 `now` state。自动倒计时数字 SHALL 使用 `@number-flow/react` 提供滚动过渡,非倒计时状态 SHALL 保持普通文本或按钮语义。
#### Scenario: RefreshCountdown 组件封装
- **WHEN** Dashboard 页面渲染
@@ -30,22 +30,38 @@ RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时文本。倒计
- **WHEN** RefreshCountdown 组件渲染
- **THEN** 组件 SHALL 接收 `dashboardUpdatedAt: number`、`refreshInterval: number`、`isFetching: boolean`、`isManualRefresh: boolean`、`onRefresh: () => void` 作为 props
-#### Scenario: 短时间格式
-- **WHEN** 距下次刷新剩余时间小于 60 秒
+#### Scenario: NumberFlow 数字滚动
+- **WHEN** 自动刷新模式下已完成首次刷新且当前未处于刷新中状态
+- **THEN** 倒计时数字 SHALL 使用 `@number-flow/react` 的 `NumberFlow` 渲染,并使用向下滚动趋势表达倒计时递减
+
+#### Scenario: 秒级间隔格式
+- **WHEN** 自动刷新间隔小于 60 秒
- **THEN** 倒计时 SHALL 显示为"xx秒"格式(如"26秒")
-#### Scenario: 长时间格式
-- **WHEN** 距下次刷新剩余时间大于等于 60 秒
-- **THEN** 倒计时 SHALL 显示为"x分x秒"格式(如"4分30秒")
+#### Scenario: 分钟级稳定格式
+- **WHEN** 自动刷新间隔大于等于 60 秒
+- **THEN** 倒计时 SHALL 显示为"x分xx秒"格式,秒数 SHALL 固定为两位(如"4分30秒"、"0分09秒")
+
+#### Scenario: 时间数字边界
+- **WHEN** 分钟级倒计时中的秒数在 59 到 00 边界变化
+- **THEN** 秒数十位 SHALL 按时间显示规则限制在 0 到 5 之间滚动
#### Scenario: 无前缀
- **WHEN** 倒计时显示
-- **THEN** 倒计时文本 SHALL 不包含任何前缀(如"下一次刷新:"),直接显示时间
+- **THEN** 可见倒计时文本 SHALL 不包含任何前缀(如"下一次刷新:"),直接显示时间
+
+#### Scenario: 可访问文本
+- **WHEN** NumberFlow 倒计时渲染
+- **THEN** 倒计时容器 SHALL 暴露与当前倒计时等价的可访问文本,供测试和辅助技术读取
#### Scenario: 刷新中状态
- **WHEN** 数据正在刷新(isFetching=true 且 isLoading=false)
- **THEN** 倒计时文本 SHALL 显示为"刷新中..."
+#### Scenario: 等待首次刷新状态
+- **WHEN** 自动刷新模式下尚未完成首次刷新
+- **THEN** 倒计时文本 SHALL 显示为"等待首次刷新"
+
### Requirement: App 组件渲染隔离
App 组件 SHALL NOT 持有任何高频更新的 state(如每秒更新的时钟),确保 App 的重渲染频率与数据刷新频率一致(默认 30 秒一次)。
@@ -73,12 +89,16 @@ App 组件 SHALL NOT 持有任何高频更新的 state(如每秒更新的时
- **THEN** 刷新按钮 SHALL 显示 loading 状态且 disabled,防止连续点击
### Requirement: 布局稳定性
-倒计时/按钮容器 SHALL 保持布局稳定,避免内容变化导致的抖动。
+倒计时/按钮容器 SHALL 保持布局稳定,避免内容变化导致的抖动。NumberFlow 倒计时 SHALL 通过分组同步和等宽数字样式降低位数、单位和动画变化带来的布局偏移。
#### Scenario: 数字等宽
- **WHEN** 倒计时数字变化
-- **THEN** 容器 SHALL 使用 tabular-nums 字体特性,确保数字等宽不抖动
+- **THEN** 容器和 NumberFlow 倒计时 SHALL 使用 tabular-nums 字体特性,确保数字等宽不抖动
+
+#### Scenario: NumberFlow 分组同步
+- **WHEN** 分钟级倒计时同时渲染分钟和秒数
+- **THEN** 分钟和秒数 SHALL 使用 `NumberFlowGroup` 同步布局变化
#### Scenario: 格式切换不抖动
-- **WHEN** 倒计时在"秒"和"分秒"格式间切换
+- **WHEN** 倒计时在按钮、秒级文本和分钟级文本之间切换
- **THEN** 容器 SHALL 使用 min-width 确保最小宽度,避免 RadioGroup 位移
diff --git a/package.json b/package.json
index a720ede..bd7e4d9 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,7 @@
"vite": "^8.0.13"
},
"dependencies": {
+ "@number-flow/react": "^0.6.0",
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.10",
"@xmldom/xmldom": "^0.9.10",
diff --git a/src/web/components/RefreshCountdown.tsx b/src/web/components/RefreshCountdown.tsx
index d38a877..bc4b0fc 100644
--- a/src/web/components/RefreshCountdown.tsx
+++ b/src/web/components/RefreshCountdown.tsx
@@ -1,9 +1,8 @@
+import NumberFlow from "@number-flow/react";
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;
@@ -45,8 +44,55 @@ export function RefreshCountdown({
);
}
- const refreshText =
- dashboardUpdatedAt > 0 ? (isFetching ? "刷新中..." : formatCountdown(nextRefreshSeconds ?? 0)) : "等待首次刷新";
+ if (dashboardUpdatedAt <= 0) {
+ return 等待首次刷新;
+ }
- return {refreshText};
+ if (isFetching) {
+ return 刷新中...;
+ }
+
+ const seconds = nextRefreshSeconds ?? 0;
+ const isMinuteMode = refreshInterval >= 60000;
+
+ if (isMinuteMode) {
+ const mm = Math.floor(seconds / 60);
+ const ss = seconds % 60;
+
+ return (
+
+
+ 分
+
+ 秒
+
+ );
+ }
+
+ return (
+
+
+ 秒
+
+ );
+}
+
+function formatAccessibleLabel(seconds: number, isMinuteMode: boolean): string {
+ if (isMinuteMode) {
+ const mm = Math.floor(seconds / 60);
+ const ss = seconds % 60;
+ return `${mm}分${String(ss).padStart(2, "0")}秒`;
+ }
+ return `${seconds}秒`;
}
diff --git a/src/web/styles.css b/src/web/styles.css
index cd49fda..38f952b 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -63,6 +63,24 @@
white-space: nowrap;
}
+.refresh-countdown-flow {
+ display: inline-flex;
+ align-items: baseline;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ color: var(--td-text-color-secondary);
+}
+
+.refresh-countdown-flow__number {
+ line-height: 0.85;
+ --number-flow-mask-height: 0.15em;
+ --number-flow-mask-width: 0.25em;
+}
+
+.refresh-countdown-flow__unit {
+ margin: 0 1px;
+}
+
.status-dot {
display: inline-block;
width: 12px;
diff --git a/tests/setup.ts b/tests/setup.ts
index 63abb28..fbb4735 100644
--- a/tests/setup.ts
+++ b/tests/setup.ts
@@ -88,3 +88,23 @@ Object.defineProperty(dom.window, "matchMedia", {
dom.window.Element.prototype.scrollTo = () => {};
dom.window.Element.prototype.scrollIntoView = () => {};
+
+Object.defineProperty(dom.window, "customElements", {
+ value: {
+ define: () => {},
+ get: () => undefined,
+ },
+ writable: true,
+});
+
+globalThis.customElements = dom.window.customElements;
+
+// Mock @number-flow/react globally (custom elements not supported in jsdom)
+import { mock } from "bun:test";
+import { createElement } from "react";
+
+void mock.module("@number-flow/react", () => {
+ const NumberFlow = () => createElement("span", { "data-testid": "number-flow" });
+ const NumberFlowGroup = ({ children }: { children: unknown }) => children;
+ return { default: NumberFlow, NumberFlowGroup };
+});
diff --git a/tests/web/components/RefreshCountdown.test.tsx b/tests/web/components/RefreshCountdown.test.tsx
index c4f1b79..5dbd402 100644
--- a/tests/web/components/RefreshCountdown.test.tsx
+++ b/tests/web/components/RefreshCountdown.test.tsx
@@ -1,27 +1,57 @@
import "../../../tests/web/test-utils";
-import { render } from "@testing-library/react";
+import { render, screen } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { RefreshCountdown } from "../../../src/web/components/RefreshCountdown";
describe("RefreshCountdown", () => {
- test("手动模式不崩溃", () => {
- const { container } = render(
+ test("手动模式渲染刷新按钮", () => {
+ const onRefresh = vi.fn();
+ render(
,
+ );
+
+ const button = screen.getByRole("button", { name: "刷新 Dashboard" });
+ expect(button).toBeTruthy();
+ });
+
+ test("等待首次刷新显示文本", () => {
+ render(
+ ,
);
- expect(container.firstChild).not.toBeNull();
+ expect(screen.getByText("等待首次刷新")).toBeTruthy();
});
- test("自动模式不崩溃", () => {
+ test("刷新中显示文本", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("刷新中...")).toBeTruthy();
+ });
+
+ test("秒级间隔渲染 NumberFlow 倒计时", () => {
const now = Date.now();
- const { container } = render(
+ render(
{
/>,
);
- expect(container.firstChild).not.toBeNull();
+ const countdown = screen.getByLabelText(/秒$/);
+ expect(countdown).toBeTruthy();
+ expect(countdown.className).toContain("refresh-countdown-flow");
});
- test("fetching 状态不崩溃", () => {
- const { container } = render(
+ test("分钟级间隔渲染带分秒的 NumberFlow 倒计时", () => {
+ const now = Date.now();
+ render(
,
- );
-
- expect(container.firstChild).not.toBeNull();
- });
-
- test("未刷新状态不崩溃", () => {
- const { container } = render(
- ,
);
- expect(container.firstChild).not.toBeNull();
+ const countdown = screen.getByLabelText(/分\d{2}秒$/);
+ expect(countdown).toBeTruthy();
+ expect(countdown.className).toContain("refresh-countdown-flow");
+ });
+
+ test("5 分钟间隔使用稳定分钟格式", () => {
+ const now = Date.now();
+ const { container } = render(
+ ,
+ );
+
+ const countdown = container.querySelector(".refresh-countdown-flow");
+ expect(countdown).toBeTruthy();
+ expect(countdown?.getAttribute("aria-label")).toMatch(/分\d{2}秒$/);
});
});