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,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>
);
}