1
0

feat: Header 倒计时数字滚动动画 — @number-flow/react 替换静态文本

This commit is contained in:
2026-05-16 00:14:35 +08:00
parent 88f4119a4e
commit f8d563c668
9 changed files with 212 additions and 41 deletions

View File

@@ -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 新增功能开发步骤

View File

@@ -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=="],

View File

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

View File

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

View File

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

View File

@@ -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)) : "等待首次刷新";
return <Typography.Text theme="secondary">{refreshText}</Typography.Text>;
if (dashboardUpdatedAt <= 0) {
return <Typography.Text theme="secondary"></Typography.Text>;
}
if (isFetching) {
return <Typography.Text theme="secondary">...</Typography.Text>;
}
const seconds = nextRefreshSeconds ?? 0;
const isMinuteMode = refreshInterval >= 60000;
if (isMinuteMode) {
const mm = Math.floor(seconds / 60);
const ss = seconds % 60;
return (
<span aria-label={formatAccessibleLabel(seconds, true)} className="refresh-countdown-flow">
<NumberFlow
className="refresh-countdown-flow__number"
format={{ minimumIntegerDigits: 1 }}
trend={-1}
value={mm}
/>
<span className="refresh-countdown-flow__unit"></span>
<NumberFlow
className="refresh-countdown-flow__number"
digits={{ 1: { max: 5 } }}
format={{ minimumIntegerDigits: 2 }}
trend={-1}
value={ss}
/>
<span className="refresh-countdown-flow__unit"></span>
</span>
);
}
return (
<span aria-label={formatAccessibleLabel(seconds, false)} className="refresh-countdown-flow">
<NumberFlow className="refresh-countdown-flow__number" trend={-1} value={seconds} />
<span className="refresh-countdown-flow__unit"></span>
</span>
);
}
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}`;
}

View File

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

View File

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

View File

@@ -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(
<RefreshCountdown
dashboardUpdatedAt={0}
isFetching={false}
isManualRefresh={true}
onRefresh={onRefresh}
refreshInterval={30000}
/>,
);
const button = screen.getByRole("button", { name: "刷新 Dashboard" });
expect(button).toBeTruthy();
});
test("等待首次刷新显示文本", () => {
render(
<RefreshCountdown
dashboardUpdatedAt={0}
isFetching={false}
isManualRefresh={false}
onRefresh={vi.fn()}
refreshInterval={30000}
/>,
);
expect(container.firstChild).not.toBeNull();
expect(screen.getByText("等待首次刷新")).toBeTruthy();
});
test("自动模式不崩溃", () => {
test("刷新中显示文本", () => {
render(
<RefreshCountdown
dashboardUpdatedAt={Date.now()}
isFetching={true}
isManualRefresh={false}
onRefresh={vi.fn()}
refreshInterval={30000}
/>,
);
expect(screen.getByText("刷新中...")).toBeTruthy();
});
test("秒级间隔渲染 NumberFlow 倒计时", () => {
const now = Date.now();
const { container } = render(
render(
<RefreshCountdown
dashboardUpdatedAt={now - 10000}
isFetching={false}
@@ -31,34 +61,42 @@ describe("RefreshCountdown", () => {
/>,
);
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(
<RefreshCountdown
dashboardUpdatedAt={1000}
isFetching={true}
isManualRefresh={false}
onRefresh={vi.fn()}
refreshInterval={30000}
/>,
);
expect(container.firstChild).not.toBeNull();
});
test("未刷新状态不崩溃", () => {
const { container } = render(
<RefreshCountdown
dashboardUpdatedAt={0}
dashboardUpdatedAt={now - 10000}
isFetching={false}
isManualRefresh={false}
onRefresh={vi.fn()}
refreshInterval={30000}
refreshInterval={60000}
/>,
);
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(
<RefreshCountdown
dashboardUpdatedAt={now - 290000}
isFetching={false}
isManualRefresh={false}
onRefresh={vi.fn()}
refreshInterval={300000}
/>,
);
const countdown = container.querySelector(".refresh-countdown-flow");
expect(countdown).toBeTruthy();
expect(countdown?.getAttribute("aria-label")).toMatch(/分\d{2}秒$/);
});
});