From 9904f198aa0e798c552b93a1b98eec67f3879bf5 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Thu, 14 May 2026 18:03:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Dashboard=20=E5=88=B7=E6=96=B0=E9=A2=91?= =?UTF-8?q?=E7=8E=87=E5=8F=AF=E9=85=8D=E7=BD=AE=20=E2=80=94=20RadioGroup?= =?UTF-8?q?=20=E9=80=89=E6=8B=A9=E5=99=A8=E3=80=81=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2=E9=97=B4=E9=9A=94=E3=80=81=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useDashboard hook 改为接受 refetchInterval 动态参数,移除固定 8 秒常量 - Header operations 区域重构为 RadioGroup(手动/10秒/30秒/1分钟/5分钟)+ 倒计时/刷新按钮 - 新增 formatCountdown 工具函数及单元测试 - 新增 .dashboard-refresh-control 和 .dashboard-countdown CSS 类 - 同步更新 DEVELOPMENT.md、README.md、主 specs --- DEVELOPMENT.md | 8 +-- README.md | 2 +- openspec/specs/css-utility-classes/spec.md | 10 ++- openspec/specs/dashboard-layout/spec.md | 20 ++---- openspec/specs/refresh-control/spec.md | 65 +++++++++++++++++++ .../specs/tanstack-query-data-layer/spec.md | 12 ++-- src/web/app.tsx | 60 +++++++++++++---- src/web/hooks/use-queries.ts | 6 +- src/web/hooks/use-target-detail.ts | 2 +- src/web/styles.css | 13 +++- src/web/utils/time.ts | 5 ++ tests/web/utils/time.test.ts | 17 ++++- 12 files changed, 176 insertions(+), 44 deletions(-) create mode 100644 openspec/specs/refresh-control/spec.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5ba3fb5..35d5783 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -554,7 +554,7 @@ main.tsx └── ErrorBoundary(React 错误边界) └── QueryClientProvider(TanStack Query 全局挂载) ├── App(根组件,Layout + HeadMenu 骨架) - │ ├── useDashboard() ─── GET /api/dashboard?window=24h&recentLimit=30(8s 轮询,dataUpdatedAt 倒计时) + │ ├── useDashboard(refreshInterval) ─── GET /api/dashboard?window=24h&recentLimit=30(动态刷新间隔,RadioGroup 频率选择 + 倒计时/手动刷新按钮) │ ├── SummaryCards(单 Card 内嵌居中 Statistic,无 shadow) │ └── TargetBoard(目标列表,Space 24px 间距) │ ├── DashboardResponse.targets @@ -573,12 +573,12 @@ main.tsx ``` hooks/use-queries.ts(全局面板级查询) ├── queryKeys(dashboard/meta/metrics 结构化 query key) -├── useDashboard() → /api/dashboard?window=24h&recentLimit=30(8s 自动轮询) +├── useDashboard(refetchInterval) → /api/dashboard?window=24h&recentLimit=30(动态刷新间隔,由调用方传入) ├── useTargetMetrics() → /api/targets/:id/metrics(详情按需加载) └── useMeta() → /api/meta(staleTime: Infinity) hooks/use-target-detail.ts(Drawer 状态与详情级条件查询) -├── 内部复用 useDashboard() 的缓存来查找 selectedTarget +├── 内部复用 useDashboard(false) 的缓存来查找 selectedTarget ├── useTargetMetrics(/api/targets/:id/metrics)(条件查询:enabled 仅当 Drawer 打开且时间范围有效) └── useQuery(/api/targets/:id/history)(条件查询:含分页) ``` @@ -608,7 +608,7 @@ const queryKeys = { useQuery({ queryKey: queryKeys.dashboard(), queryFn: () => fetchJson("/api/dashboard?window=24h&recentLimit=30"), - refetchInterval: 8000, // 自动轮询间隔 + refetchInterval, // 由调用方传入的动态刷新间隔(false 禁用轮询) refetchIntervalInBackground: false, // 切后台不轮询 }); diff --git a/README.md b/README.md index 28cebd1..ed212d9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DiAL -基于 Bun + TypeScript 的多类型拨测监控工具。通过 YAML 配置文件定义 HTTP 和命令行拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite,前端 Dashboard 展示各目标实时状态、可用率、耗时趋势等。 +基于 Bun + TypeScript 的多类型拨测监控工具。通过 YAML 配置文件定义 HTTP 和命令行拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite,前端 Dashboard 展示各目标实时状态、可用率、耗时趋势等,并支持手动、10 秒、30 秒、1 分钟、5 分钟刷新频率切换。 ## 快速开始 diff --git a/openspec/specs/css-utility-classes/spec.md b/openspec/specs/css-utility-classes/spec.md index b567520..dda2a11 100644 --- a/openspec/specs/css-utility-classes/spec.md +++ b/openspec/specs/css-utility-classes/spec.md @@ -73,9 +73,13 @@ styles.css SHALL 定义前端组件复用的工具类,包含页面布局相关 - **WHEN** HeadMenu logo 区域渲染品牌名和副标题 - **THEN** 品牌 SHALL 使用 `.dashboard-brand` 类(display: inline-flex; align-items: baseline; gap: var(--td-comp-margin-s)),品牌名 SHALL 使用 `.dashboard-logo` 类(font-size: calc(var(--td-font-size-title-large) + 6px); font-weight: 700),副标题 SHALL 使用 `.dashboard-subtitle` 类(font-size: var(--td-font-size-body-medium); color: var(--td-text-color-secondary)) -#### Scenario: 刷新状态类 -- **WHEN** HeadMenu operations 区域渲染刷新倒计时 -- **THEN** 容器 SHALL 使用 `.dashboard-refresh-status` 类(display: inline-flex; align-items: center; margin-right: var(--td-comp-margin-xxl)) +#### Scenario: 刷新控制区域类 +- **WHEN** HeadMenu operations 区域渲染刷新频率选择器和倒计时/按钮 +- **THEN** 容器 SHALL 使用 `.dashboard-refresh-control` 类(display: inline-flex; align-items: center; gap: var(--td-comp-margin-s); margin-right: var(--td-comp-margin-xxl)) + +#### Scenario: 倒计时文本类 +- **WHEN** 倒计时文本或刷新按钮渲染 +- **THEN** 容器 SHALL 使用 `.dashboard-countdown` 类(display: inline-flex; align-items: center; font-variant-numeric: tabular-nums; min-width: 5ch),确保数字等宽且格式切换不抖动 #### Scenario: SummaryCard 居中类 - **WHEN** SummaryCards 内 Statistic 需要居中 diff --git a/openspec/specs/dashboard-layout/spec.md b/openspec/specs/dashboard-layout/spec.md index 380818f..080c081 100644 --- a/openspec/specs/dashboard-layout/spec.md +++ b/openspec/specs/dashboard-layout/spec.md @@ -1,6 +1,6 @@ ## Purpose -定义 Dashboard 页面骨架布局:顶部导航栏(含品牌标识和刷新倒计时)、内容区域居中与最大宽度、页面背景色。 +定义 Dashboard 页面骨架布局:顶部导航栏(含品牌标识和刷新频率选择器/倒计时控件)、内容区域居中与最大宽度、页面背景色。 ## Requirements @@ -13,21 +13,13 @@ Dashboard SHALL 使用 TDesign Layout 组件体系构建页面骨架,包含顶 #### Scenario: 顶部导航栏 - **WHEN** Dashboard 页面渲染 -- **THEN** `Layout.Header` SHALL 内嵌 TDesign `HeadMenu` 组件,`logo` prop 渲染品牌名 "DiAL" 和副标题 "统一拨测平台"(水平排列),`operations` prop 渲染数据刷新倒计时文字 +- **THEN** `Layout.Header` SHALL 内嵌 TDesign `HeadMenu` 组件,`logo` prop 渲染品牌名 "DiAL" 和副标题 "统一拨测平台"(水平排列),`operations` prop 渲染刷新频率选择器和倒计时/刷新按钮组合控件 -#### Scenario: 刷新倒计时 -- **WHEN** Dashboard 数据已成功获取(dataUpdatedAt > 0) -- **THEN** HeadMenu operations 区域 SHALL 展示刷新倒计时文本(如"下一次刷新:5秒"),使用 TDesign Typography.Text(theme="secondary"),基于 React Query `dataUpdatedAt` 和轮询间隔常量计算 +#### Scenario: 刷新控制区域 +- **WHEN** Dashboard 页面渲染 +- **THEN** HeadMenu operations 区域 SHALL 包含 RadioGroup 刷新频率选择器和倒计时文本(或手动刷新按钮),两者水平排列并垂直居中 -#### Scenario: 刷新中状态 -- **WHEN** Dashboard 正在重新获取数据(isFetching=true 且 isLoading=false) -- **THEN** 刷新倒计时文本 SHALL 展示为"刷新中..." - -#### Scenario: 首次加载状态 -- **WHEN** Dashboard 尚未获取过数据(dataUpdatedAt = 0) -- **THEN** 刷新倒计时文本 SHALL 展示为"等待首次刷新" - -#### Scenario: 刷新倒计时位置 +#### Scenario: 刷新控制区域位置 - **WHEN** HeadMenu 渲染 - **THEN** operations 区域 SHALL 使用右侧 margin 向内收缩,避免紧贴浏览器右边缘 diff --git a/openspec/specs/refresh-control/spec.md b/openspec/specs/refresh-control/spec.md new file mode 100644 index 0000000..707c08e --- /dev/null +++ b/openspec/specs/refresh-control/spec.md @@ -0,0 +1,65 @@ +## Purpose + +定义 Header 刷新频率选择器组件的交互行为:频率切换、倒计时显示、手动刷新按钮、布局稳定性。 + +## Requirements + +### Requirement: 刷新频率选择器 +HeadMenu operations 区域 SHALL 提供 RadioGroup 组件供用户选择刷新频率。 + +#### Scenario: RadioGroup 渲染 +- **WHEN** Dashboard 页面渲染 +- **THEN** HeadMenu operations 区域 SHALL 显示 RadioGroup(theme="button", variant="default-filled"),选项为:手动、10秒、30秒、1分钟、5分钟 + +#### Scenario: 默认选中 +- **WHEN** 页面首次加载 +- **THEN** RadioGroup SHALL 默认选中"30秒" + +#### Scenario: 切换频率立即刷新 +- **WHEN** 用户切换刷新频率选项 +- **THEN** 系统 SHALL 立即触发一次数据刷新,然后应用新的刷新间隔 + +### Requirement: 倒计时显示 +RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时文本。 + +#### Scenario: 短时间格式 +- **WHEN** 距下次刷新剩余时间小于 60 秒 +- **THEN** 倒计时 SHALL 显示为"xx秒"格式(如"26秒") + +#### Scenario: 长时间格式 +- **WHEN** 距下次刷新剩余时间大于等于 60 秒 +- **THEN** 倒计时 SHALL 显示为"x分x秒"格式(如"4分30秒") + +#### Scenario: 无前缀 +- **WHEN** 倒计时显示 +- **THEN** 倒计时文本 SHALL 不包含任何前缀(如"下一次刷新:"),直接显示时间 + +#### Scenario: 刷新中状态 +- **WHEN** 数据正在刷新(isFetching=true 且 isLoading=false) +- **THEN** 倒计时文本 SHALL 显示为"刷新中..." + +### Requirement: 手动刷新按钮 +选择"手动"模式时,倒计时区域 SHALL 替换为刷新按钮。 + +#### Scenario: 手动模式显示按钮 +- **WHEN** 用户选择"手动"刷新频率 +- **THEN** 倒计时区域 SHALL 替换为刷新图标按钮 + +#### Scenario: 点击刷新 +- **WHEN** 用户点击刷新按钮 +- **THEN** 系统 SHALL 触发一次数据刷新 + +#### Scenario: 刷新中禁用 +- **WHEN** 数据正在刷新 +- **THEN** 刷新按钮 SHALL 显示 loading 状态且 disabled,防止连续点击 + +### Requirement: 布局稳定性 +倒计时/按钮容器 SHALL 保持布局稳定,避免内容变化导致的抖动。 + +#### Scenario: 数字等宽 +- **WHEN** 倒计时数字变化 +- **THEN** 容器 SHALL 使用 tabular-nums 字体特性,确保数字等宽不抖动 + +#### Scenario: 格式切换不抖动 +- **WHEN** 倒计时在"秒"和"分秒"格式间切换 +- **THEN** 容器 SHALL 使用 min-width 确保最小宽度,避免 RadioGroup 位移 diff --git a/openspec/specs/tanstack-query-data-layer/spec.md b/openspec/specs/tanstack-query-data-layer/spec.md index 11002d4..4b39d14 100644 --- a/openspec/specs/tanstack-query-data-layer/spec.md +++ b/openspec/specs/tanstack-query-data-layer/spec.md @@ -71,9 +71,13 @@ ### Requirement: Summary 轮询查询 系统 SHALL 使用 useQuery 实现总览统计的自动轮询。 -#### Scenario: summary 自动轮询 +#### Scenario: summary 动态轮询间隔 - **WHEN** Dashboard 页面处于打开状态 -- **THEN** 系统 SHALL 每 8 秒自动请求 /api/summary,使用 refetchInterval=8000 +- **THEN** 系统 SHALL 按用户选择的刷新间隔自动请求数据,`useDashboard` hook SHALL 接受 `refetchInterval` 参数(`false | number`),由调用方传入 + +#### Scenario: summary 禁用自动轮询 +- **WHEN** 用户选择"手动"刷新模式 +- **THEN** `useDashboard` SHALL 接收 `refetchInterval: false`,禁用自动轮询 #### Scenario: summary 后台刷新 - **WHEN** 页面处于后台标签页 @@ -82,9 +86,9 @@ ### Requirement: Targets 轮询查询 系统 SHALL 使用 useQuery 实现目标列表的自动轮询。 -#### Scenario: targets 自动轮询 +#### Scenario: targets 动态轮询间隔 - **WHEN** Dashboard 页面处于打开状态 -- **THEN** 系统 SHALL 每 8 秒自动请求 /api/targets,使用 refetchInterval=8000 +- **THEN** 系统 SHALL 按用户选择的刷新间隔自动请求数据,轮询间隔与 summary 查询保持一致 ### Requirement: 条件查询 趋势和历史记录查询 SHALL 使用 enabled 条件控制,仅在目标被选中时触发。 diff --git a/src/web/app.tsx b/src/web/app.tsx index 56bf69a..bcf45f6 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -1,30 +1,43 @@ import type { SkeletonProps } from "tdesign-react"; import { useEffect, useState } from "react"; -import { Alert, Layout, Menu, Skeleton, Typography } from "tdesign-react"; +import { RefreshIcon } from "tdesign-icons-react"; +import { Alert, Button, Layout, Menu, RadioGroup, Skeleton, Typography } from "tdesign-react"; import { SummaryCards } from "./components/SummaryCards"; import { TargetBoard } from "./components/TargetBoard"; import { TargetDetailDrawer } from "./components/TargetDetailDrawer"; -import { DASHBOARD_REFRESH_INTERVAL_MS, useDashboard } from "./hooks/use-queries"; +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; const DASHBOARD_SKELETON_ROW_COL: SkeletonProps["rowCol"] = [ [{ height: "112px", type: "rect", width: "100%" }], [{ height: "56px", type: "rect", width: "100%" }], [{ height: "320px", type: "rect", width: "100%" }], ]; +const REFRESH_OPTIONS = [ + { label: "手动", value: 0 }, + { label: "10秒", value: 10000 }, + { label: "30秒", value: 30000 }, + { label: "1分钟", value: 60000 }, + { label: "5分钟", value: 300000 }, +] 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 { data: dashboard, dataUpdatedAt: dashboardUpdatedAt, error: dashboardError, isFetching: dashboardFetching, isLoading: dashboardLoading, - } = useDashboard(); + refetch: refetchDashboard, + } = useDashboard(dashboardRefetchInterval); const { closeDrawer, handlePageChange, @@ -38,17 +51,23 @@ export function App() { timeFrom, timeTo, } = useTargetDetail(); + const isManualRefresh = refreshInterval === 0; const nextRefreshSeconds = - dashboardUpdatedAt > 0 - ? Math.max(0, Math.ceil((dashboardUpdatedAt + DASHBOARD_REFRESH_INTERVAL_MS - now.getTime()) / 1000)) + dashboardUpdatedAt > 0 && !isManualRefresh + ? Math.max(0, Math.ceil((dashboardUpdatedAt + refreshInterval - now.getTime()) / 1000)) : null; const refreshText = dashboardUpdatedAt > 0 ? dashboardFetching && !dashboardLoading ? "刷新中..." - : `下一次刷新:${nextRefreshSeconds}秒` + : formatCountdown(nextRefreshSeconds ?? 0) : "等待首次刷新"; + const handleIntervalChange = (value: number) => { + void refetchDashboard(); + setRefreshInterval(value); + }; + useEffect(() => { const timer = window.setInterval(() => setNow(new Date()), 1000); return () => window.clearInterval(timer); @@ -65,11 +84,30 @@ export function App() { } operations={ - - - {refreshText} - - +
+ ({ label: option.label, value: option.value }))} + theme="button" + value={refreshInterval} + variant="default-filled" + /> + + {isManualRefresh ? ( +
} /> diff --git a/src/web/hooks/use-queries.ts b/src/web/hooks/use-queries.ts index e5430cb..c7b29b4 100644 --- a/src/web/hooks/use-queries.ts +++ b/src/web/hooks/use-queries.ts @@ -9,19 +9,17 @@ const queryKeys = { ["metrics", targetId, from, to, bucket] as const, }; -export const DASHBOARD_REFRESH_INTERVAL_MS = 8000; - export async function fetchJson(url: string): Promise { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json() as Promise; } -export function useDashboard() { +export function useDashboard(refetchInterval: false | number) { return useQuery({ queryFn: () => fetchJson("/api/dashboard?window=24h&recentLimit=30"), queryKey: queryKeys.dashboard(), - refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS, + refetchInterval, refetchIntervalInBackground: false, }); } diff --git a/src/web/hooks/use-target-detail.ts b/src/web/hooks/use-target-detail.ts index 4071692..12cca59 100644 --- a/src/web/hooks/use-target-detail.ts +++ b/src/web/hooks/use-target-detail.ts @@ -17,7 +17,7 @@ export function useTargetDetail() { const [timeTo, setTimeTo] = useState(""); const [historyPage, setHistoryPage] = useState(1); - const { data: dashboardData } = useDashboard(); + const { data: dashboardData } = useDashboard(false); const selectedTarget = selectedTargetId !== null ? (dashboardData?.targets.find((target) => target.id === selectedTargetId) ?? null) diff --git a/src/web/styles.css b/src/web/styles.css index e5e726f..efa22ac 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -46,12 +46,23 @@ font-weight: 400; } -.dashboard-refresh-status { +.dashboard-refresh-control { display: inline-flex; align-items: center; + gap: var(--td-comp-margin-s); margin-right: var(--td-comp-margin-xxl); } +.dashboard-countdown { + display: inline-flex; + align-items: center; + justify-content: flex-end; + min-width: 4.5em; + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + .status-dot { display: inline-block; width: 12px; diff --git a/src/web/utils/time.ts b/src/web/utils/time.ts index 6150e65..824ad7b 100644 --- a/src/web/utils/time.ts +++ b/src/web/utils/time.ts @@ -1,3 +1,8 @@ +export function formatCountdown(seconds: number): string { + if (seconds < 60) return `${seconds}秒`; + return `${Math.floor(seconds / 60)}分${seconds % 60}秒`; +} + export function formatDurationUnit(ms: null | number): { suffix: string; value: number } { if (ms === null) return { suffix: "", value: 0 }; if (ms < 60000) return { suffix: "秒", value: roundToOne(ms / 1000) }; diff --git a/tests/web/utils/time.test.ts b/tests/web/utils/time.test.ts index 123ddef..7999c32 100644 --- a/tests/web/utils/time.test.ts +++ b/tests/web/utils/time.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { formatDurationUnit, formatRelativeTime, isOlderThan, subtractHours } from "../../../src/web/utils/time"; +import { + formatCountdown, + formatDurationUnit, + formatRelativeTime, + isOlderThan, + subtractHours, +} from "../../../src/web/utils/time"; describe("subtractHours", () => { test("正常扣减小时", () => { @@ -54,6 +60,15 @@ describe("formatDurationUnit", () => { }); }); +describe("formatCountdown", () => { + test("格式化秒级和分钟级倒计时", () => { + expect(formatCountdown(0)).toBe("0秒"); + expect(formatCountdown(59)).toBe("59秒"); + expect(formatCountdown(60)).toBe("1分0秒"); + expect(formatCountdown(299)).toBe("4分59秒"); + }); +}); + describe("isOlderThan", () => { test("判断时间是否超过阈值", () => { const now = new Date("2025-01-01T00:02:00.000Z");