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

@@ -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() {
</span>
}
operations={
<span className="dashboard-refresh-status">
<Typography.Text className="dashboard-refresh-text" theme="secondary">
{refreshText}
</Typography.Text>
</span>
<div className="dashboard-refresh-control">
<RadioGroup
onChange={handleIntervalChange}
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>
</div>
}
/>
</Header>

View File

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

View File

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

View File

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

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