1
0

feat: Dashboard 刷新频率可配置 — RadioGroup 选择器、动态轮询间隔、手动刷新按钮

- useDashboard hook 改为接受 refetchInterval 动态参数,移除固定 8 秒常量
- Header operations 区域重构为 RadioGroup(手动/10秒/30秒/1分钟/5分钟)+ 倒计时/刷新按钮
- 新增 formatCountdown 工具函数及单元测试
- 新增 .dashboard-refresh-control 和 .dashboard-countdown CSS 类
- 同步更新 DEVELOPMENT.md、README.md、主 specs
This commit is contained in:
2026-05-14 18:03:42 +08:00
parent c61a4a6091
commit 9904f198aa
12 changed files with 176 additions and 44 deletions

View File

@@ -554,7 +554,7 @@ main.tsx
└── ErrorBoundaryReact 错误边界) └── ErrorBoundaryReact 错误边界)
└── QueryClientProviderTanStack Query 全局挂载) └── QueryClientProviderTanStack Query 全局挂载)
├── App根组件Layout + HeadMenu 骨架) ├── App根组件Layout + HeadMenu 骨架)
│ ├── useDashboard() ─── GET /api/dashboard?window=24h&recentLimit=308s 轮询dataUpdatedAt 倒计时 │ ├── useDashboard(refreshInterval) ─── GET /api/dashboard?window=24h&recentLimit=30动态刷新间隔RadioGroup 频率选择 + 倒计时/手动刷新按钮
│ ├── SummaryCards单 Card 内嵌居中 Statistic无 shadow │ ├── SummaryCards单 Card 内嵌居中 Statistic无 shadow
│ └── TargetBoard目标列表Space 24px 间距) │ └── TargetBoard目标列表Space 24px 间距)
│ ├── DashboardResponse.targets │ ├── DashboardResponse.targets
@@ -573,12 +573,12 @@ main.tsx
``` ```
hooks/use-queries.ts全局面板级查询 hooks/use-queries.ts全局面板级查询
├── queryKeysdashboard/meta/metrics 结构化 query key ├── queryKeysdashboard/meta/metrics 结构化 query key
├── useDashboard() → /api/dashboard?window=24h&recentLimit=308s 自动轮询 ├── useDashboard(refetchInterval) → /api/dashboard?window=24h&recentLimit=30动态刷新间隔,由调用方传入
├── useTargetMetrics() → /api/targets/:id/metrics详情按需加载 ├── useTargetMetrics() → /api/targets/:id/metrics详情按需加载
└── useMeta() → /api/metastaleTime: Infinity └── useMeta() → /api/metastaleTime: Infinity
hooks/use-target-detail.tsDrawer 状态与详情级条件查询) hooks/use-target-detail.tsDrawer 状态与详情级条件查询)
├── 内部复用 useDashboard() 的缓存来查找 selectedTarget ├── 内部复用 useDashboard(false) 的缓存来查找 selectedTarget
├── useTargetMetrics(/api/targets/:id/metrics)条件查询enabled 仅当 Drawer 打开且时间范围有效) ├── useTargetMetrics(/api/targets/:id/metrics)条件查询enabled 仅当 Drawer 打开且时间范围有效)
└── useQuery(/api/targets/:id/history)(条件查询:含分页) └── useQuery(/api/targets/:id/history)(条件查询:含分页)
``` ```
@@ -608,7 +608,7 @@ const queryKeys = {
useQuery({ useQuery({
queryKey: queryKeys.dashboard(), queryKey: queryKeys.dashboard(),
queryFn: () => fetchJson<DashboardResponse>("/api/dashboard?window=24h&recentLimit=30"), queryFn: () => fetchJson<DashboardResponse>("/api/dashboard?window=24h&recentLimit=30"),
refetchInterval: 8000, // 自动轮询间隔 refetchInterval, // 由调用方传入的动态刷新间隔false 禁用轮询)
refetchIntervalInBackground: false, // 切后台不轮询 refetchIntervalInBackground: false, // 切后台不轮询
}); });

View File

@@ -1,6 +1,6 @@
# DiAL # DiAL
基于 Bun + TypeScript 的多类型拨测监控工具。通过 YAML 配置文件定义 HTTP 和命令行拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite前端 Dashboard 展示各目标实时状态、可用率、耗时趋势等。 基于 Bun + TypeScript 的多类型拨测监控工具。通过 YAML 配置文件定义 HTTP 和命令行拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite前端 Dashboard 展示各目标实时状态、可用率、耗时趋势等并支持手动、10 秒、30 秒、1 分钟、5 分钟刷新频率切换
## 快速开始 ## 快速开始

View File

@@ -73,9 +73,13 @@ styles.css SHALL 定义前端组件复用的工具类,包含页面布局相关
- **WHEN** HeadMenu logo 区域渲染品牌名和副标题 - **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) - **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: 刷新状态 #### Scenario: 刷新控制区域
- **WHEN** HeadMenu operations 区域渲染刷新倒计时 - **WHEN** HeadMenu operations 区域渲染刷新频率选择器和倒计时/按钮
- **THEN** 容器 SHALL 使用 `.dashboard-refresh-status`display: inline-flex; align-items: center; margin-right: var(--td-comp-margin-xxl) - **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 居中类 #### Scenario: SummaryCard 居中类
- **WHEN** SummaryCards 内 Statistic 需要居中 - **WHEN** SummaryCards 内 Statistic 需要居中

View File

@@ -1,6 +1,6 @@
## Purpose ## Purpose
定义 Dashboard 页面骨架布局:顶部导航栏(含品牌标识和刷新倒计时)、内容区域居中与最大宽度、页面背景色。 定义 Dashboard 页面骨架布局:顶部导航栏(含品牌标识和刷新频率选择器/倒计时控件)、内容区域居中与最大宽度、页面背景色。
## Requirements ## Requirements
@@ -13,21 +13,13 @@ Dashboard SHALL 使用 TDesign Layout 组件体系构建页面骨架,包含顶
#### Scenario: 顶部导航栏 #### Scenario: 顶部导航栏
- **WHEN** Dashboard 页面渲染 - **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: 刷新倒计时 #### Scenario: 刷新控制区域
- **WHEN** Dashboard 数据已成功获取dataUpdatedAt > 0 - **WHEN** Dashboard 页面渲染
- **THEN** HeadMenu operations 区域 SHALL 展示刷新倒计时文本(如"下一次刷新5秒"),使用 TDesign Typography.Texttheme="secondary"),基于 React Query `dataUpdatedAt` 和轮询间隔常量计算 - **THEN** HeadMenu operations 区域 SHALL 包含 RadioGroup 刷新频率选择器和倒计时文本(或手动刷新按钮),两者水平排列并垂直居中
#### Scenario: 刷新中状态 #### Scenario: 刷新控制区域位置
- **WHEN** Dashboard 正在重新获取数据isFetching=true 且 isLoading=false
- **THEN** 刷新倒计时文本 SHALL 展示为"刷新中..."
#### Scenario: 首次加载状态
- **WHEN** Dashboard 尚未获取过数据dataUpdatedAt = 0
- **THEN** 刷新倒计时文本 SHALL 展示为"等待首次刷新"
#### Scenario: 刷新倒计时位置
- **WHEN** HeadMenu 渲染 - **WHEN** HeadMenu 渲染
- **THEN** operations 区域 SHALL 使用右侧 margin 向内收缩,避免紧贴浏览器右边缘 - **THEN** operations 区域 SHALL 使用右侧 margin 向内收缩,避免紧贴浏览器右边缘

View File

@@ -0,0 +1,65 @@
## Purpose
定义 Header 刷新频率选择器组件的交互行为:频率切换、倒计时显示、手动刷新按钮、布局稳定性。
## Requirements
### Requirement: 刷新频率选择器
HeadMenu operations 区域 SHALL 提供 RadioGroup 组件供用户选择刷新频率。
#### Scenario: RadioGroup 渲染
- **WHEN** Dashboard 页面渲染
- **THEN** HeadMenu operations 区域 SHALL 显示 RadioGrouptheme="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 位移

View File

@@ -71,9 +71,13 @@
### Requirement: Summary 轮询查询 ### Requirement: Summary 轮询查询
系统 SHALL 使用 useQuery 实现总览统计的自动轮询。 系统 SHALL 使用 useQuery 实现总览统计的自动轮询。
#### Scenario: summary 自动轮询 #### Scenario: summary 动态轮询间隔
- **WHEN** Dashboard 页面处于打开状态 - **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 后台刷新 #### Scenario: summary 后台刷新
- **WHEN** 页面处于后台标签页 - **WHEN** 页面处于后台标签页
@@ -82,9 +86,9 @@
### Requirement: Targets 轮询查询 ### Requirement: Targets 轮询查询
系统 SHALL 使用 useQuery 实现目标列表的自动轮询。 系统 SHALL 使用 useQuery 实现目标列表的自动轮询。
#### Scenario: targets 自动轮询 #### Scenario: targets 动态轮询间隔
- **WHEN** Dashboard 页面处于打开状态 - **WHEN** Dashboard 页面处于打开状态
- **THEN** 系统 SHALL 每 8 秒自动请求 /api/targets使用 refetchInterval=8000 - **THEN** 系统 SHALL 按用户选择的刷新间隔自动请求数据,轮询间隔与 summary 查询保持一致
### Requirement: 条件查询 ### Requirement: 条件查询
趋势和历史记录查询 SHALL 使用 enabled 条件控制,仅在目标被选中时触发。 趋势和历史记录查询 SHALL 使用 enabled 条件控制,仅在目标被选中时触发。

View File

@@ -1,30 +1,43 @@
import type { SkeletonProps } from "tdesign-react"; import type { SkeletonProps } from "tdesign-react";
import { useEffect, useState } from "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 { SummaryCards } from "./components/SummaryCards";
import { TargetBoard } from "./components/TargetBoard"; import { TargetBoard } from "./components/TargetBoard";
import { TargetDetailDrawer } from "./components/TargetDetailDrawer"; 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 { useTargetDetail } from "./hooks/use-target-detail";
import { formatCountdown } from "./utils/time";
const { Content, Header } = Layout; const { Content, Header } = Layout;
const DEFAULT_REFRESH_INTERVAL_MS = 30000;
const DASHBOARD_SKELETON_ROW_COL: SkeletonProps["rowCol"] = [ const DASHBOARD_SKELETON_ROW_COL: SkeletonProps["rowCol"] = [
[{ height: "112px", type: "rect", width: "100%" }], [{ height: "112px", type: "rect", width: "100%" }],
[{ height: "56px", type: "rect", width: "100%" }], [{ height: "56px", type: "rect", width: "100%" }],
[{ height: "320px", 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() { export function App() {
const [now, setNow] = useState(() => new Date()); const [now, setNow] = useState(() => new Date());
const [refreshInterval, setRefreshInterval] = useState(DEFAULT_REFRESH_INTERVAL_MS);
const dashboardRefetchInterval = refreshInterval === 0 ? false : refreshInterval;
const { const {
data: dashboard, data: dashboard,
dataUpdatedAt: dashboardUpdatedAt, dataUpdatedAt: dashboardUpdatedAt,
error: dashboardError, error: dashboardError,
isFetching: dashboardFetching, isFetching: dashboardFetching,
isLoading: dashboardLoading, isLoading: dashboardLoading,
} = useDashboard(); refetch: refetchDashboard,
} = useDashboard(dashboardRefetchInterval);
const { const {
closeDrawer, closeDrawer,
handlePageChange, handlePageChange,
@@ -38,17 +51,23 @@ export function App() {
timeFrom, timeFrom,
timeTo, timeTo,
} = useTargetDetail(); } = useTargetDetail();
const isManualRefresh = refreshInterval === 0;
const nextRefreshSeconds = const nextRefreshSeconds =
dashboardUpdatedAt > 0 dashboardUpdatedAt > 0 && !isManualRefresh
? Math.max(0, Math.ceil((dashboardUpdatedAt + DASHBOARD_REFRESH_INTERVAL_MS - now.getTime()) / 1000)) ? Math.max(0, Math.ceil((dashboardUpdatedAt + refreshInterval - now.getTime()) / 1000))
: null; : null;
const refreshText = const refreshText =
dashboardUpdatedAt > 0 dashboardUpdatedAt > 0
? dashboardFetching && !dashboardLoading ? dashboardFetching && !dashboardLoading
? "刷新中..." ? "刷新中..."
: `下一次刷新:${nextRefreshSeconds}` : formatCountdown(nextRefreshSeconds ?? 0)
: "等待首次刷新"; : "等待首次刷新";
const handleIntervalChange = (value: number) => {
void refetchDashboard();
setRefreshInterval(value);
};
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 1000); const timer = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
@@ -65,11 +84,30 @@ export function App() {
</span> </span>
} }
operations={ operations={
<span className="dashboard-refresh-status"> <div className="dashboard-refresh-control">
<Typography.Text className="dashboard-refresh-text" theme="secondary"> <RadioGroup
{refreshText} onChange={handleIntervalChange}
</Typography.Text> options={REFRESH_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
theme="button"
value={refreshInterval}
variant="default-filled"
/>
<span className="dashboard-countdown">
{isManualRefresh ? (
<Button
aria-label="刷新 Dashboard"
disabled={dashboardFetching}
icon={<RefreshIcon />}
loading={dashboardFetching}
onClick={() => void refetchDashboard()}
shape="circle"
variant="outline"
/>
) : (
<Typography.Text theme="secondary">{refreshText}</Typography.Text>
)}
</span> </span>
</div>
} }
/> />
</Header> </Header>

View File

@@ -9,19 +9,17 @@ const queryKeys = {
["metrics", targetId, from, to, bucket] as const, ["metrics", targetId, from, to, bucket] as const,
}; };
export const DASHBOARD_REFRESH_INTERVAL_MS = 8000;
export async function fetchJson<T>(url: string): Promise<T> { export async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
export function useDashboard() { export function useDashboard(refetchInterval: false | number) {
return useQuery({ return useQuery({
queryFn: () => fetchJson<DashboardResponse>("/api/dashboard?window=24h&recentLimit=30"), queryFn: () => fetchJson<DashboardResponse>("/api/dashboard?window=24h&recentLimit=30"),
queryKey: queryKeys.dashboard(), queryKey: queryKeys.dashboard(),
refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS, refetchInterval,
refetchIntervalInBackground: false, refetchIntervalInBackground: false,
}); });
} }

View File

@@ -17,7 +17,7 @@ export function useTargetDetail() {
const [timeTo, setTimeTo] = useState(""); const [timeTo, setTimeTo] = useState("");
const [historyPage, setHistoryPage] = useState(1); const [historyPage, setHistoryPage] = useState(1);
const { data: dashboardData } = useDashboard(); const { data: dashboardData } = useDashboard(false);
const selectedTarget = const selectedTarget =
selectedTargetId !== null selectedTargetId !== null
? (dashboardData?.targets.find((target) => target.id === selectedTargetId) ?? null) ? (dashboardData?.targets.find((target) => target.id === selectedTargetId) ?? null)

View File

@@ -46,12 +46,23 @@
font-weight: 400; font-weight: 400;
} }
.dashboard-refresh-status { .dashboard-refresh-control {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--td-comp-margin-s);
margin-right: var(--td-comp-margin-xxl); 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 { .status-dot {
display: inline-block; display: inline-block;
width: 12px; width: 12px;

View File

@@ -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 } { export function formatDurationUnit(ms: null | number): { suffix: string; value: number } {
if (ms === null) return { suffix: "", value: 0 }; if (ms === null) return { suffix: "", value: 0 };
if (ms < 60000) return { suffix: "秒", value: roundToOne(ms / 1000) }; if (ms < 60000) return { suffix: "秒", value: roundToOne(ms / 1000) };

View File

@@ -1,6 +1,12 @@
import { describe, expect, test } from "bun:test"; 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", () => { describe("subtractHours", () => {
test("正常扣减小时", () => { 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", () => { describe("isOlderThan", () => {
test("判断时间是否超过阈值", () => { test("判断时间是否超过阈值", () => {
const now = new Date("2025-01-01T00:02:00.000Z"); const now = new Date("2025-01-01T00:02:00.000Z");