1
0

refactor: 前端视觉重构 — Layout/HeadMenu 骨架、SummaryCards 合并、Card 分组、Drawer 概览重设计

This commit is contained in:
2026-05-14 15:51:39 +08:00
parent 1c5cfafda6
commit c61a4a6091
20 changed files with 530 additions and 427 deletions

View File

@@ -1,13 +1,30 @@
import { Alert, Loading, Typography } from "tdesign-react";
import type { SkeletonProps } from "tdesign-react";
import { useEffect, useState } from "react";
import { Alert, Layout, Menu, Skeleton, Typography } from "tdesign-react";
import { SummaryCards } from "./components/SummaryCards";
import { TargetBoard } from "./components/TargetBoard";
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
import { useDashboard } from "./hooks/use-queries";
import { DASHBOARD_REFRESH_INTERVAL_MS, useDashboard } from "./hooks/use-queries";
import { useTargetDetail } from "./hooks/use-target-detail";
const { Content, Header } = Layout;
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%" }],
];
export function App() {
const { data: dashboard, error: dashboardError, isLoading: dashboardLoading } = useDashboard();
const [now, setNow] = useState(() => new Date());
const {
data: dashboard,
dataUpdatedAt: dashboardUpdatedAt,
error: dashboardError,
isFetching: dashboardFetching,
isLoading: dashboardLoading,
} = useDashboard();
const {
closeDrawer,
handlePageChange,
@@ -21,25 +38,55 @@ export function App() {
timeFrom,
timeTo,
} = useTargetDetail();
const nextRefreshSeconds =
dashboardUpdatedAt > 0
? Math.max(0, Math.ceil((dashboardUpdatedAt + DASHBOARD_REFRESH_INTERVAL_MS - now.getTime()) / 1000))
: null;
const refreshText =
dashboardUpdatedAt > 0
? dashboardFetching && !dashboardLoading
? "刷新中..."
: `下一次刷新:${nextRefreshSeconds}`
: "等待首次刷新";
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(timer);
}, []);
return (
<main className="dashboard">
<header className="dashboard-header">
<Typography.Title level="h1">DiAL</Typography.Title>
<Typography.Text theme="secondary"></Typography.Text>
</header>
{dashboardError && <Alert closeBtn message={`请求失败: ${dashboardError.message}`} theme="error" />}
{dashboardLoading ? (
<Loading />
) : (
<>
<SummaryCards summary={dashboard?.summary ?? null} />
<TargetBoard onTargetClick={openDrawer} targets={dashboard?.targets ?? []} />
</>
)}
<Layout className="dashboard">
<Header>
<Menu.HeadMenu
logo={
<span className="dashboard-brand">
<span className="dashboard-logo">DiAL</span>
<span className="dashboard-subtitle"></span>
</span>
}
operations={
<span className="dashboard-refresh-status">
<Typography.Text className="dashboard-refresh-text" theme="secondary">
{refreshText}
</Typography.Text>
</span>
}
/>
</Header>
<Content>
<div className="dashboard-content">
{dashboardError && <Alert closeBtn message={`请求失败: ${dashboardError.message}`} theme="error" />}
{dashboardLoading ? (
<Skeleton animation="gradient" rowCol={DASHBOARD_SKELETON_ROW_COL} />
) : (
<>
<SummaryCards summary={dashboard?.summary ?? null} />
<TargetBoard onTargetClick={openDrawer} targets={dashboard?.targets ?? []} />
</>
)}
</div>
</Content>
<TargetDetailDrawer
historyData={historyData}
historyLoading={historyLoading}
@@ -53,6 +100,6 @@ export function App() {
timeFrom={timeFrom}
timeTo={timeTo}
/>
</main>
</Layout>
);
}

View File

@@ -1,27 +0,0 @@
import { Tag, Typography } from "tdesign-react";
interface GroupHeaderProps {
down: number;
name: string;
total: number;
up: number;
}
export function GroupHeader({ down, name, total, up }: GroupHeaderProps) {
const displayName = name === "default" ? "默认分组" : name;
return (
<div className="group-header">
<Typography.Title level="h4">{displayName}</Typography.Title>
<Tag theme="primary" title="总数" variant="light">
{total}
</Tag>
<Tag theme="success" title="正常" variant="light">
{up}
</Tag>
<Tag theme="danger" title="异常" variant="light">
{down}
</Tag>
</div>
);
}

View File

@@ -1,11 +1,19 @@
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic } from "tdesign-react";
import type { ReactNode } from "react";
import { Card, Col, Descriptions, Divider, Row, Skeleton, Space, Statistic, Typography } from "tdesign-react";
import type { TargetMetricsResponse, TargetStatus } from "../../shared/api";
import { formatDurationUnit } from "../utils/time";
import { StatusDonut } from "./StatusDonut";
import { TrendChart } from "./TrendChart";
interface OverviewStatItemProps {
color?: string;
suffix?: ReactNode;
title: string;
value: number;
}
interface OverviewTabProps {
metricsData: null | TargetMetricsResponse;
metricsLoading: boolean;
@@ -20,70 +28,6 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
return (
<Space className="full-width" direction="vertical" size={16}>
<Divider align="left"></Divider>
{metricsLoading ? (
<Skeleton animation="gradient" />
) : stats ? (
<Space className="full-width" direction="vertical" size={16}>
<Row gutter={16}>
<Col span={3}>
<Statistic color="green" suffix="%" title="可用率" value={stats.availability} />
</Col>
<Col span={3}>
<Statistic
suffix={stats.avgDurationMs === null ? "" : "ms"}
title="平均延迟"
value={stats.avgDurationMs ?? 0}
/>
</Col>
<Col span={3}>
<Statistic
suffix={stats.p95DurationMs === null ? "" : "ms"}
title="P95 延迟"
value={stats.p95DurationMs ?? 0}
/>
</Col>
<Col span={3}>
<Statistic color="blue" title="检查总数" value={stats.totalChecks} />
</Col>
</Row>
<Row gutter={16}>
<Col span={3}>
<Statistic suffix={mttr.suffix} title="MTTR" value={mttr.value} />
</Col>
<Col span={3}>
<Statistic suffix={longestOutage.suffix} title="最长故障" value={longestOutage.value} />
</Col>
<Col span={3}>
<Statistic color="red" suffix="次" title="故障次数" value={stats.incidentCount} />
</Col>
<Col span={3}>
<Statistic color="green" suffix="次" title="连续正常" value={currentUpStreak} />
</Col>
</Row>
</Space>
) : (
<div className="trend-empty"></div>
)}
<Divider align="left"></Divider>
{metricsLoading ? (
<Skeleton animation="gradient" />
) : metricsData ? (
<TrendChart data={metricsData.trend} />
) : (
<div className="trend-empty"></div>
)}
<Divider align="left"></Divider>
{metricsLoading ? (
<Skeleton animation="gradient" />
) : stats ? (
<StatusDonut down={stats.downChecks} up={stats.upChecks} />
) : (
<div className="trend-empty"></div>
)}
<Divider align="left"></Divider>
<Descriptions
items={[
@@ -96,6 +40,68 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
]}
/>
<Divider align="left"></Divider>
{metricsLoading ? (
<Skeleton animation="gradient" />
) : stats ? (
<Row gutter={[16, 16]}>
<Col span={3}>
<OverviewStatItem color="green" suffix="%" title="可用率" value={stats.availability} />
</Col>
<Col span={3}>
<OverviewStatItem
suffix={stats.avgDurationMs === null ? "" : "ms"}
title="平均延迟"
value={stats.avgDurationMs ?? 0}
/>
</Col>
<Col span={3}>
<OverviewStatItem
suffix={stats.p95DurationMs === null ? "" : "ms"}
title="P95 延迟"
value={stats.p95DurationMs ?? 0}
/>
</Col>
<Col span={3}>
<OverviewStatItem color="blue" title="检查总数" value={stats.totalChecks} />
</Col>
<Col span={3}>
<OverviewStatItem suffix={mttr.suffix} title="MTTR" value={mttr.value} />
</Col>
<Col span={3}>
<OverviewStatItem suffix={longestOutage.suffix} title="最长故障" value={longestOutage.value} />
</Col>
<Col span={3}>
<OverviewStatItem color="red" suffix="次" title="故障次数" value={stats.incidentCount} />
</Col>
<Col span={3}>
<OverviewStatItem color="green" suffix="次" title="连续正常" value={currentUpStreak} />
</Col>
</Row>
) : (
<div className="trend-empty"></div>
)}
<Divider align="left"></Divider>
{metricsLoading ? (
<Skeleton animation="gradient" />
) : metricsData ? (
<TrendChart data={metricsData.trend} />
) : (
<div className="trend-empty"></div>
)}
</Space>
);
}
function OverviewStatItem({ color, suffix, title, value }: OverviewStatItemProps) {
return (
<Card bordered={false} className="overview-stat-card" size="small">
<div className="overview-stat-item">
<Typography.Text theme="secondary">{title}</Typography.Text>
<Statistic className="overview-stat-value" color={color} suffix={suffix} value={value} />
</div>
</Card>
);
}

View File

@@ -1,6 +1,12 @@
import { Tooltip } from "tdesign-react";
import type { RecentSample } from "../../shared/api";
import { formatRelativeTime } from "../utils/time";
interface StatusBarProps {
maxSlots?: number;
samples: Array<{ up: boolean }>;
samples: RecentSample[];
}
export function StatusBar({ maxSlots = 30, samples }: StatusBarProps) {
@@ -9,10 +15,13 @@ export function StatusBar({ maxSlots = 30, samples }: StatusBarProps) {
const sample = samples[i];
if (sample) {
blocks.push(
<span
className={`status-bar-block ${sample.up ? "status-bar-block--up" : "status-bar-block--down"}`}
<Tooltip
content={`${formatRelativeTime(sample.timestamp)}${sample.up ? "正常" : "异常"}`}
key={i}
/>,
placement="top"
>
<span className={`status-bar-block ${sample.up ? "status-bar-block--up" : "status-bar-block--down"}`} />
</Tooltip>,
);
} else {
blocks.push(<span className="status-bar-block status-bar-block--empty" key={i} />);

View File

@@ -1,40 +0,0 @@
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
interface StatusDonutProps {
down: number;
up: number;
}
const UP_COLOR = "var(--td-success-color)";
const DOWN_COLOR = "var(--td-error-color)";
const EMPTY_COLOR = "var(--td-bg-color-component-disabled)";
export function StatusDonut({ down, up }: StatusDonutProps) {
const total = up + down;
const availability = total > 0 ? ((up / total) * 100).toFixed(1) : "-";
const data =
total > 0
? [
{ name: "UP", value: up },
{ name: "DOWN", value: down },
]
: [{ name: "EMPTY", value: 1 }];
const colors = total > 0 ? [UP_COLOR, DOWN_COLOR] : [EMPTY_COLOR];
return (
<div className="status-donut">
<ResponsiveContainer height={180} width="100%">
<PieChart>
<Pie cx="50%" cy="50%" data={data} dataKey="value" innerRadius={50} outerRadius={70} stroke="none">
{data.map((item, index) => (
<Cell fill={colors[index % colors.length]} key={item.name} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
<div className="donut-center-label">{total > 0 ? `${availability}%` : "-"}</div>
</div>
);
}

View File

@@ -1,22 +1,12 @@
import { useEffect, useState } from "react";
import { Card, Col, Row, Statistic, Typography } from "tdesign-react";
import { Card, Col, Row, Statistic } from "tdesign-react";
import type { DashboardResponse } from "../../shared/api";
import { formatRelativeTime, isOlderThan } from "../utils/time";
interface SummaryCardsProps {
summary: DashboardResponse["summary"] | null;
}
export function SummaryCards({ summary }: SummaryCardsProps) {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(timer);
}, []);
if (!summary) return null;
const cards = [
@@ -25,25 +15,18 @@ export function SummaryCards({ summary }: SummaryCardsProps) {
{ color: "red" as const, label: "异常", value: summary.down },
{ color: "orange" as const, label: `${summary.window.label} 异常事件数`, value: summary.incidents },
];
const freshnessWarning = isOlderThan(summary.lastCheckTime, 60000, now);
return (
<section className="summary-cards-row">
<Row gutter={16}>
{cards.map((card) => (
<Col key={card.label} span={3}>
<Card bordered>
<Card bordered={false}>
<Row gutter={16}>
{cards.map((card) => (
<Col className="summary-stat-col" key={card.label} span={3}>
<Statistic color={card.color} title={card.label} value={card.value} />
</Card>
</Col>
))}
</Row>
<Typography.Text
className={freshnessWarning ? "summary-freshness summary-freshness--warning" : "summary-freshness"}
theme="secondary"
>
{summary.lastCheckTime ? `最后更新: ${formatRelativeTime(summary.lastCheckTime, now)}` : "尚无检查数据"}
</Typography.Text>
</Col>
))}
</Row>
</Card>
</section>
);
}

View File

@@ -36,7 +36,7 @@ export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
});
return (
<Space className="full-width" direction="vertical" size={32}>
<Space className="full-width" direction="vertical" size={24}>
{sortedGroups.map(([name, groupTargets]) => (
<TargetGroup columns={columns} key={name} name={name} onTargetClick={onTargetClick} targets={groupTargets} />
))}

View File

@@ -85,28 +85,30 @@ export function TargetDetailDrawer({
}
onClose={onClose}
placement="right"
size="60%"
size="52%"
visible={!!target}
>
<Space className="full-width" direction="vertical" size={16}>
<RadioGroup
onChange={handleShortcut}
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
theme="button"
value={activeShortcut}
variant="default-filled"
/>
<DateRangePicker
className="full-width"
defaultTime={["00:00:00", "23:59:00"]}
enableTimePicker
format="YYYY-MM-DD HH:mm"
mode="date"
onChange={handleDateRangeChange}
timePickerProps={{ format: "HH:mm", steps: [1, 1, 60] }}
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
valueType="YYYY-MM-DD HH:mm"
/>
<div className="drawer-time-controls">
<RadioGroup
onChange={handleShortcut}
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
theme="button"
value={activeShortcut}
variant="default-filled"
/>
<DateRangePicker
className="drawer-date-range"
defaultTime={["00:00:00", "23:59:00"]}
enableTimePicker
format="YYYY-MM-DD HH:mm"
mode="date"
onChange={handleDateRangeChange}
timePickerProps={{ format: "HH:mm", steps: [1, 1, 60] }}
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
valueType="YYYY-MM-DD HH:mm"
/>
</div>
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
<OverviewTab metricsData={metricsData} metricsLoading={metricsLoading} target={target} />

View File

@@ -1,11 +1,9 @@
import type { PrimaryTableCol } from "tdesign-react";
import { PrimaryTable } from "tdesign-react";
import { Card, PrimaryTable, Space, Tag } from "tdesign-react";
import type { TargetStatus } from "../../shared/api";
import { GroupHeader } from "./GroupHeader";
interface TargetGroupProps {
columns: Array<PrimaryTableCol<TargetStatus>>;
name: string;
@@ -16,12 +14,24 @@ interface TargetGroupProps {
export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGroupProps) {
const up = targets.filter((t) => t.latestCheck?.matched).length;
const down = targets.length - up;
const displayName = name === "default" ? "默认分组" : name;
return (
<div>
<GroupHeader down={down} name={name} total={targets.length} up={up} />
<Card
actions={
<Space size={8}>
<Tag theme="success" title="正常" variant="light">
{up}
</Tag>
<Tag theme="danger" title="异常" variant="light">
{down}
</Tag>
</Space>
}
headerBordered
title={displayName}
>
<PrimaryTable
bordered
className="clickable-table"
columns={columns}
data={targets}
@@ -36,6 +46,6 @@ export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGro
size="small"
stripe
/>
</div>
</Card>
);
}

View File

@@ -74,13 +74,13 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
return (
<Tag size="small" theme={streak.up ? "success" : "danger"} variant="light">
{streak.up ? "▲" : "▼"} {streak.count}
{streak.capped ? "+" : ""}
{streak.capped ? "+" : ""}
</Tag>
);
},
colKey: "currentStreak",
title: "连续",
width: 100,
title: "连续(次)",
width: 88,
},
{
align: "right",
@@ -88,19 +88,14 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
const ms = row.latestCheck?.durationMs;
if (ms === null || ms === undefined) return <span className="text-disabled">-</span>;
const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error";
return <span className={`${colorClass} tabular-nums`}>{Math.round(ms)}ms</span>;
const latencyText = ms > 9999 ? "9999+ms" : `${Math.round(ms)}ms`;
return <span className={`${colorClass} latency-value tabular-nums`}>{latencyText}</span>;
},
colKey: "latestCheck.durationMs",
sorter: latencySorter,
sortType: "all",
title: "延迟",
width: 80,
},
{
align: "center",
colKey: "interval",
title: "间隔",
width: 72,
width: 75,
},
];
}

View File

@@ -9,6 +9,8 @@ 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}`);
@@ -19,7 +21,7 @@ export function useDashboard() {
return useQuery({
queryFn: () => fetchJson<DashboardResponse>("/api/dashboard?window=24h&recentLimit=30"),
queryKey: queryKeys.dashboard(),
refetchInterval: 8000,
refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS,
refetchIntervalInBackground: false,
});
}

View File

@@ -12,17 +12,44 @@
}
.dashboard {
min-height: 100vh;
background: var(--td-bg-color-page);
width: 100%;
}
.dashboard-content {
box-sizing: border-box;
max-width: 1400px;
margin: 0 auto;
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
width: 100%;
}
.dashboard-header {
margin-bottom: var(--td-comp-margin-l);
.dashboard-brand {
display: inline-flex;
align-items: baseline;
justify-content: center;
gap: var(--td-comp-margin-s);
line-height: 1.2;
}
.dashboard-header .t-typography {
.dashboard-logo {
margin: 0;
line-height: 1.3;
color: var(--td-text-color-primary);
font-size: calc(var(--td-font-size-title-large) + 6px);
font-weight: 700;
}
.dashboard-subtitle {
color: var(--td-text-color-secondary);
font-size: var(--td-font-size-body-medium);
font-weight: 400;
}
.dashboard-refresh-status {
display: inline-flex;
align-items: center;
margin-right: var(--td-comp-margin-xxl);
}
.status-dot {
@@ -69,24 +96,6 @@
background: var(--td-bg-color-component-disabled);
}
.status-donut {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: fit-content;
margin: 0 auto;
}
.donut-center-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -60%);
font-size: 1.25rem;
font-weight: 700;
}
.trend-chart {
width: 100%;
}
@@ -104,6 +113,7 @@
}
.t-table tr.row-down {
border-left: 3px solid var(--td-error-color);
background: color-mix(in srgb, var(--td-error-color) 6%, transparent);
}
@@ -131,6 +141,58 @@
color: var(--td-error-color);
}
.latency-value {
display: inline-block;
min-width: 7ch;
white-space: nowrap;
}
.drawer-time-controls {
display: flex;
align-items: center;
gap: var(--td-comp-margin-m);
width: 100%;
}
.drawer-date-range {
flex: 1;
min-width: 360px;
}
.overview-stat-card {
background: var(--td-bg-color-container-hover);
}
.overview-stat-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--td-comp-margin-m);
}
.overview-stat-value {
font-size: var(--td-font-size-body-medium);
text-align: right;
}
.overview-stat-value .t-statistic-content {
font-size: var(--td-font-size-body-medium);
}
.summary-stat-col {
text-align: center;
}
@media (max-width: 768px) {
.drawer-time-controls {
flex-wrap: wrap;
}
.drawer-date-range {
min-width: 100%;
}
}
.full-width {
width: 100%;
}
@@ -139,33 +201,10 @@
cursor: pointer;
}
.group-header {
margin-bottom: var(--td-comp-margin-m);
display: flex;
align-items: center;
gap: 8px;
}
.group-header .t-typography {
margin: 0;
font-size: var(--td-font-size-title-medium);
font-weight: 600;
line-height: 1.5;
}
.summary-cards-row {
margin-bottom: var(--td-comp-margin-xl);
}
.summary-freshness {
display: block;
margin-top: var(--td-comp-margin-s);
}
.summary-freshness--warning {
color: var(--td-warning-color);
}
.error-boundary-fallback {
padding-top: 20vh;
width: 100%;