From f8d563c668ab8d62fd4a94842e40a0aadbfff910 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Sat, 16 May 2026 00:14:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Header=20=E5=80=92=E8=AE=A1=E6=97=B6?= =?UTF-8?q?=E6=95=B0=E5=AD=97=E6=BB=9A=E5=8A=A8=E5=8A=A8=E7=94=BB=20?= =?UTF-8?q?=E2=80=94=20@number-flow/react=20=E6=9B=BF=E6=8D=A2=E9=9D=99?= =?UTF-8?q?=E6=80=81=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT.md | 2 + bun.lock | 7 ++ openspec/specs/css-utility-classes/spec.md | 19 ++++ openspec/specs/refresh-control/spec.md | 40 ++++++--- package.json | 1 + src/web/components/RefreshCountdown.tsx | 56 ++++++++++-- src/web/styles.css | 18 ++++ tests/setup.ts | 20 +++++ .../web/components/RefreshCountdown.test.tsx | 90 +++++++++++++------ 9 files changed, 212 insertions(+), 41 deletions(-) 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}秒$/); }); });