1
0

refactor: 全面重构前端 Dashboard 为 TDesign + TanStack Query 分组表格布局

- 卡片式布局改为分组 PrimaryTable,Modal 改为 Drawer
- 手写 hooks 替换为 TanStack Query(轮询/缓存/条件查询)
- CSS 607行精简至73行,颜色迁移至 TDesign tokens
- 可用率进度条颜色按 10% 一档红→绿渐变
- 新增纯函数测试 34 项全通过(排序/筛选/色阶阈值)
- 同步更新主 specs 并归档变更文档
This commit is contained in:
2026-05-12 01:06:53 +08:00
parent 48b40238b8
commit f48e39a615
41 changed files with 1314 additions and 1302 deletions

View File

@@ -1,17 +0,0 @@
import type { TargetStatus } from "../../shared/api";
import { TargetCard } from "./TargetCard";
interface CardGridProps {
targets: TargetStatus[];
onTargetClick: (target: TargetStatus) => void;
}
export function CardGrid({ targets, onTargetClick }: CardGridProps) {
return (
<div className="card-grid">
{targets.map((target) => (
<TargetCard key={target.id} target={target} onClick={() => onTargetClick(target)} />
))}
</div>
);
}

View File

@@ -1,3 +1,5 @@
import { Space, Tag } from "tdesign-react";
interface GroupHeaderProps {
name: string;
total: number;
@@ -9,19 +11,17 @@ export function GroupHeader({ name, total, up, down }: GroupHeaderProps) {
const displayName = name === "default" ? "默认分组" : name;
return (
<div className="group-header">
<h2 className="group-title">{displayName}</h2>
<div className="group-stats">
<span className="stat-badge stat-badge-total" title="总数">
{total}
</span>
<span className="stat-badge stat-badge-up" title="正常">
{up}
</span>
<span className="stat-badge stat-badge-down" title="异常">
{down}
</span>
</div>
</div>
<Space align="center" size={8} style={{ marginBottom: 12 }}>
<h2 style={{ margin: 0, fontSize: "1.1rem", fontWeight: 600 }}>{displayName}</h2>
<Tag theme="primary" variant="light" title="总数">
{total}
</Tag>
<Tag theme="success" variant="light" title="正常">
{up}
</Tag>
<Tag theme="danger" variant="light" title="异常">
{down}
</Tag>
</Space>
);
}

View File

@@ -1,25 +0,0 @@
import { Line, LineChart, ResponsiveContainer } from "recharts";
import type { RecentSample } from "../../shared/api";
interface MiniSparklineProps {
data: RecentSample[];
}
export function MiniSparkline({ data }: MiniSparklineProps) {
const chartData = data
.filter((s) => s.durationMs !== null)
.map((s) => ({ duration: s.durationMs! }))
.reverse();
if (chartData.length === 0) {
return <span className="sparkline-empty">-</span>;
}
return (
<ResponsiveContainer width="100%" height={40}>
<LineChart data={chartData}>
<Line type="monotone" dataKey="duration" stroke="#356dd2" strokeWidth={1.5} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -1,47 +0,0 @@
interface PaginationProps {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
}
export function Pagination({ page, pageSize, total, onPageChange }: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (totalPages <= 1) return null;
const pages: number[] = [];
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
const visiblePages = pages.filter((p) => {
if (totalPages <= 7) return true;
if (p === 1 || p === totalPages) return true;
if (Math.abs(p - page) <= 1) return true;
return false;
});
return (
<div className="pagination">
<button className="pagination-btn" disabled={page <= 1} onClick={() => onPageChange(page - 1)}>
&lt;
</button>
{visiblePages.map((p, idx) => {
const prev = visiblePages[idx - 1];
const showEllipsis = prev !== undefined && p - prev > 1;
return (
<span key={p} className="pagination-items">
{showEllipsis && <span className="pagination-ellipsis">...</span>}
<button className={`pagination-btn ${p === page ? "active" : ""}`} onClick={() => onPageChange(p)}>
{p}
</button>
</span>
);
})}
<button className="pagination-btn" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}>
&gt;
</button>
</div>
);
}

View File

@@ -7,9 +7,17 @@ export function StatusBar({ samples }: StatusBarProps) {
for (let i = 0; i < 30; i++) {
const sample = samples[i];
if (sample) {
blocks.push(<span key={i} className={`status-bar-block ${sample.up ? "status-bar-up" : "status-bar-down"}`} />);
blocks.push(
<span
key={i}
className="status-bar-block"
style={{ background: sample.up ? "var(--td-success-color)" : "var(--td-error-color)" }}
/>,
);
} else {
blocks.push(<span key={i} className="status-bar-block status-bar-empty" />);
blocks.push(
<span key={i} className="status-bar-block" style={{ background: "var(--td-bg-color-component-disabled)" }} />,
);
}
}

View File

@@ -5,9 +5,9 @@ interface StatusDonutProps {
down: number;
}
const UP_COLOR = "#1fbf75";
const DOWN_COLOR = "#e5484d";
const EMPTY_COLOR = "#e2e8f0";
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({ up, down }: StatusDonutProps) {
const total = up + down;

View File

@@ -3,5 +3,12 @@ interface StatusDotProps {
}
export function StatusDot({ up }: StatusDotProps) {
return <span className={`status-dot ${up ? "status-up" : "status-down"}`} />;
const color = up ? "var(--td-success-color)" : "var(--td-error-color)";
const shadow = up ? "var(--td-success-color)" : "var(--td-error-color)";
return (
<span
className="status-dot"
style={{ background: color, boxShadow: `0 0 0 6px color-mix(in srgb, ${shadow} 14%, transparent)` }}
/>
);
}

View File

@@ -1,31 +1,28 @@
import { Row, Col, Card, Statistic } from "tdesign-react";
import type { SummaryResponse } from "../../shared/api";
interface SummaryCardsProps {
summary: SummaryResponse | null;
loading: boolean;
}
export function SummaryCards({ summary, loading }: SummaryCardsProps) {
if (loading && !summary) {
return <div className="summary-cards">...</div>;
}
export function SummaryCards({ summary }: SummaryCardsProps) {
if (!summary) return null;
const cards = [
{ label: "全部目标", value: summary.total, className: "card-total" },
{ label: "正常", value: summary.up, className: "card-up" },
{ label: "异常", value: summary.down, className: "card-down" },
{ label: "全部目标", value: summary.total, color: "blue" as const },
{ label: "正常", value: summary.up, color: "green" as const },
{ label: "异常", value: summary.down, color: "red" as const },
];
return (
<div className="summary-cards">
<Row gutter={16} style={{ marginBottom: 32 }}>
{cards.map((card) => (
<div key={card.className} className={`summary-card ${card.className}`}>
<div className="card-value">{card.value}</div>
<div className="card-label">{card.label}</div>
</div>
<Col key={card.label} span={4}>
<Card bordered>
<Statistic title={card.label} value={card.value} color={card.color} />
</Card>
</Col>
))}
</div>
</Row>
);
}

View File

@@ -1,3 +1,4 @@
import { Space } from "tdesign-react";
import type { TargetStatus } from "../../shared/api";
import { TargetGroup } from "./TargetGroup";
@@ -18,11 +19,16 @@ export function TargetBoard({ targets, onTargetClick }: TargetBoardProps) {
}
}
const sortedGroups = Array.from(groups.entries()).sort(([a]) => {
if (a === "default") return -1;
return 0;
});
return (
<div className="target-board">
{Array.from(groups.entries()).map(([name, groupTargets]) => (
<Space direction="vertical" size={32} style={{ width: "100%" }}>
{sortedGroups.map(([name, groupTargets]) => (
<TargetGroup key={name} name={name} targets={groupTargets} onTargetClick={onTargetClick} />
))}
</div>
</Space>
);
}

View File

@@ -1,32 +0,0 @@
import type { TargetStatus } from "../../shared/api";
import { StatusDot } from "./StatusDot";
import { StatusBar } from "./StatusBar";
import { MiniSparkline } from "./MiniSparkline";
import { getTargetTypeDisplay } from "../constants/target-type-display";
interface TargetCardProps {
target: TargetStatus;
onClick: () => void;
}
export function TargetCard({ target, onClick }: TargetCardProps) {
const isUp = target.latestCheck?.matched;
return (
<div className="target-card" onClick={onClick} role="button" tabIndex={0}>
<div className="card-header">
<StatusDot up={!!isUp} />
<span className="card-name" title={target.name}>
{target.name}
</span>
<span className="card-type-badge">{getTargetTypeDisplay(target.type)}</span>
</div>
<div className="card-status-bar">
<StatusBar samples={target.recentSamples} />
</div>
<div className="card-sparkline">
<MiniSparkline data={target.recentSamples} />
</div>
</div>
);
}

View File

@@ -0,0 +1,215 @@
import { useState, useCallback } from "react";
import {
Drawer,
Tabs,
RadioGroup,
DateRangePicker,
Tag,
Row,
Col,
Statistic,
Descriptions,
Skeleton,
PrimaryTable,
} from "tdesign-react";
import type { TabValue } from "tdesign-react";
import type { CheckResult, TargetStatus, TrendPoint, HistoryResponse } from "../../shared/api";
import { StatusDot } from "./StatusDot";
import { StatusDonut } from "./StatusDonut";
import { TrendChart } from "./TrendChart";
import { getTargetTypeDisplay } from "../constants/target-type-display";
import { subtractHours } from "../utils/time";
interface TargetDetailDrawerProps {
target: TargetStatus | null;
trendData: TrendPoint[];
trendLoading: boolean;
historyData: HistoryResponse;
historyLoading: boolean;
timeFrom: string;
timeTo: string;
onTimeChange: (from: string, to: string) => void;
onPageChange: (page: number) => void;
onClose: () => void;
}
const TIME_SHORTCUTS = [
{ label: "1h", hours: 1, value: "1h" },
{ label: "6h", hours: 6, value: "6h" },
{ label: "24h", hours: 24, value: "24h" },
{ label: "7d", hours: 168, value: "7d" },
] as const;
const HISTORY_COLUMNS = [
{
colKey: "matched",
title: "状态",
width: 72,
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) => (
<Tag theme={row.matched ? "success" : "danger"} size="small">
{row.matched ? "UP" : "DOWN"}
</Tag>
),
},
{
colKey: "timestamp",
title: "时间",
width: 170,
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) =>
new Date(row.timestamp).toLocaleString("zh-CN"),
},
{
colKey: "statusDetail",
title: "详情",
width: 100,
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) => row.statusDetail ?? "-",
},
{
colKey: "durationMs",
title: "耗时",
width: 80,
align: "right" as const,
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) =>
row.durationMs !== null ? `${Math.round(row.durationMs)}ms` : "-",
},
{
colKey: "failure",
title: "错误信息",
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) =>
row.failure?.message ?? "",
},
];
export function TargetDetailDrawer({
target,
trendData,
trendLoading,
historyData,
historyLoading,
timeFrom,
timeTo,
onTimeChange,
onPageChange,
onClose,
}: TargetDetailDrawerProps) {
const [activeShortcut, setActiveShortcut] = useState<string>("24h");
const [activeTab, setActiveTab] = useState<TabValue>("overview");
const handleShortcut = useCallback(
(value: string) => {
const shortcut = TIME_SHORTCUTS.find((s) => s.value === value);
if (!shortcut) return;
const now = new Date();
const from = subtractHours(now, shortcut.hours);
onTimeChange(from.toISOString(), now.toISOString());
setActiveShortcut(value);
},
[onTimeChange],
);
const handleDateRangeChange = useCallback(
(value: Array<string | number | Date>) => {
if (value && value.length === 2) {
onTimeChange(new Date(value[0]!).toISOString(), new Date(value[1]!).toISOString());
setActiveShortcut("");
}
},
[onTimeChange],
);
if (!target) return null;
const isUp = target.latestCheck?.matched;
const totalChecks = trendData.reduce((sum, p) => sum + p.totalChecks, 0);
const upChecks = trendData.reduce((sum, p) => sum + Math.round((p.availability / 100) * p.totalChecks), 0);
const downChecks = totalChecks - upChecks;
return (
<Drawer
visible={!!target}
placement="right"
size="60%"
onClose={onClose}
header={
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<StatusDot up={!!isUp} />
<span style={{ fontWeight: 600 }}>{target.name}</span>
<Tag size="small" theme="primary" variant="light-outline">
{getTargetTypeDisplay(target.type)}
</Tag>
</div>
}
>
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 16 }}>
<RadioGroup
variant="default-filled"
value={activeShortcut}
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
onChange={handleShortcut}
/>
<DateRangePicker
mode="date"
enableTimePicker
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
onChange={handleDateRangeChange}
/>
</div>
<Tabs value={activeTab} onChange={(val: TabValue) => setActiveTab(val)}>
<Tabs.TabPanel value="overview" label="概览">
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={3}>
<Statistic title="总检查" value={totalChecks} color="blue" />
</Col>
<Col span={3}>
<Statistic title="正常" value={upChecks} color="green" />
</Col>
<Col span={3}>
<Statistic title="异常" value={downChecks} color="red" />
</Col>
<Col span={3}>
<Statistic title="可用率" value={target.stats?.availability ?? 0} color="green" suffix="%" />
</Col>
</Row>
<Descriptions
items={[
{ label: "目标地址", content: target.target },
{ label: "检查间隔", content: target.interval },
{
label: "最新检查时间",
content: target.latestCheck ? new Date(target.latestCheck.timestamp).toLocaleString("zh-CN") : "-",
},
{ label: "状态详情", content: target.latestCheck?.statusDetail ?? "-" },
]}
style={{ marginBottom: 16 }}
/>
<StatusDonut up={upChecks} down={downChecks} />
</Tabs.TabPanel>
<Tabs.TabPanel value="trend" label="趋势">
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} loading={false} />}
</Tabs.TabPanel>
<Tabs.TabPanel value="history" label="记录">
<PrimaryTable
columns={HISTORY_COLUMNS}
data={historyData.items}
rowKey="timestamp"
loading={historyLoading}
disableDataPage
pagination={{
current: historyData.page,
pageSize: historyData.pageSize,
total: historyData.total,
}}
onPageChange={({ current }) => {
if (current) onPageChange(current);
}}
/>
</Tabs.TabPanel>
</Tabs>
</Drawer>
);
}

View File

@@ -1,122 +0,0 @@
import type { CheckResult, TargetStatus, TrendPoint } from "../../shared/api";
import { TrendChart } from "./TrendChart";
import { StatusDonut } from "./StatusDonut";
import { StatusDot } from "./StatusDot";
import { TimeRangePicker } from "./TimeRangePicker";
import { Pagination } from "./Pagination";
import { getTargetTypeDisplay } from "../constants/target-type-display";
interface TargetDetailModalProps {
target: TargetStatus;
trendData: TrendPoint[];
trendLoading: boolean;
historyData: HistoryData;
historyLoading: boolean;
timeFrom: string;
timeTo: string;
onTimeChange: (from: string, to: string) => void;
onPageChange: (page: number) => void;
onClose: () => void;
}
interface HistoryData {
items: CheckResult[];
total: number;
page: number;
pageSize: number;
}
export function TargetDetailModal({
target,
trendData,
trendLoading,
historyData,
historyLoading,
timeFrom,
timeTo,
onTimeChange,
onPageChange,
onClose,
}: TargetDetailModalProps) {
const isUp = target.latestCheck?.matched;
const totalChecks = trendData.reduce((sum, p) => sum + p.totalChecks, 0);
const upChecks = trendData.reduce((sum, p) => sum + Math.round((p.availability / 100) * p.totalChecks), 0);
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title-row">
<StatusDot up={!!isUp} />
<h3 className="modal-title">{target.name}</h3>
<span className="card-type-badge">{getTargetTypeDisplay(target.type)}</span>
</div>
<button className="modal-close-btn" onClick={onClose}>
&times;
</button>
</div>
<TimeRangePicker from={timeFrom} to={timeTo} onChange={onTimeChange} />
<div className="modal-body">
<div className="modal-charts">
<div className="modal-chart-section">
<h4></h4>
<StatusDonut up={upChecks} down={totalChecks - upChecks} />
</div>
<div className="modal-chart-section">
<h4></h4>
<TrendChart data={trendData} loading={trendLoading} />
</div>
</div>
<div className="modal-history-section">
<h4></h4>
{historyLoading ? (
<div className="history-loading">...</div>
) : historyData.items.length > 0 ? (
<>
<table className="history-table">
<thead>
<tr>
<th className="ht-col-status"></th>
<th className="ht-col-time"></th>
<th className="ht-col-detail"></th>
<th className="ht-col-latency"></th>
<th className="ht-col-error"></th>
</tr>
</thead>
<tbody>
{historyData.items.map((item, idx) => (
<tr key={idx}>
<td>
<span className={`ht-status ${item.matched ? "text-up" : "text-down"}`}>
{item.matched ? "UP" : "DOWN"}
</span>
</td>
<td className="ht-time">{new Date(item.timestamp).toLocaleString("zh-CN")}</td>
<td className="ht-detail">{item.statusDetail ?? "-"}</td>
<td className="ht-latency">
{item.durationMs !== null ? `${Math.round(item.durationMs)}ms` : "-"}
</td>
<td className="ht-error">{item.failure?.message ?? ""}</td>
</tr>
))}
</tbody>
</table>
<Pagination
page={historyData.page}
pageSize={historyData.pageSize}
total={historyData.total}
onPageChange={onPageChange}
/>
</>
) : (
<div className="history-empty"></div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import type { TargetStatus } from "../../shared/api";
import { GroupHeader } from "./GroupHeader";
import { CardGrid } from "./CardGrid";
import { PrimaryTable } from "tdesign-react";
import { TARGET_TABLE_COLUMNS } from "../constants/target-table-columns";
interface TargetGroupProps {
name: string;
@@ -13,9 +14,24 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
const down = targets.length - up;
return (
<div className="target-group">
<div>
<GroupHeader name={name} total={targets.length} up={up} down={down} />
<CardGrid targets={targets} onTargetClick={onTargetClick} />
<PrimaryTable
columns={TARGET_TABLE_COLUMNS}
data={targets}
rowKey="id"
size="small"
stripe
hover
bordered
defaultSort={[{ sortBy: "latestCheck.matched", descending: true }]}
onRowClick={({ row }) => onTargetClick(row as TargetStatus)}
rowClassName={({ row }) => {
const target = row as TargetStatus;
return target.latestCheck?.matched === false ? "row-down" : "";
}}
style={{ cursor: "pointer" }}
/>
</div>
);
}

View File

@@ -1,75 +0,0 @@
import { useState } from "react";
import { subtractHours } from "../utils/time";
interface TimeRangePickerProps {
from: string;
to: string;
onChange: (from: string, to: string) => void;
}
const SHORTCUTS = [
{ label: "1h", hours: 1 },
{ label: "6h", hours: 6 },
{ label: "24h", hours: 24 },
{ label: "7d", hours: 168 },
];
function toLocalDatetimeInput(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
export function TimeRangePicker({ from, to, onChange }: TimeRangePickerProps) {
const [activeShortcut, setActiveShortcut] = useState<string | null>("24h");
const handleShortcut = (label: string, hours: number) => {
const now = new Date();
const newFrom = subtractHours(now, hours);
onChange(newFrom.toISOString(), now.toISOString());
setActiveShortcut(label);
};
const handleFromChange = (value: string) => {
onChange(new Date(value).toISOString(), to);
setActiveShortcut(null);
};
const handleToChange = (value: string) => {
onChange(from, new Date(value).toISOString());
setActiveShortcut(null);
};
const fromDate = new Date(from);
const toDate = new Date(to);
return (
<div className="time-range-picker">
<div className="time-shortcuts">
{SHORTCUTS.map((s) => (
<button
key={s.label}
className={`time-shortcut-btn ${activeShortcut === s.label ? "active" : ""}`}
onClick={() => handleShortcut(s.label, s.hours)}
>
{s.label}
</button>
))}
</div>
<div className="time-inputs">
<input
type="datetime-local"
className="time-input"
value={toLocalDatetimeInput(fromDate)}
onChange={(e) => handleFromChange(e.target.value)}
/>
<span className="time-separator">~</span>
<input
type="datetime-local"
className="time-input"
value={toLocalDatetimeInput(toDate)}
onChange={(e) => handleToChange(e.target.value)}
/>
</div>
</div>
);
}

View File

@@ -24,12 +24,12 @@ export function TrendChart({ data, loading }: TrendChartProps) {
<div className="trend-chart">
<ResponsiveContainer width="100%" height={240}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="hour" tick={{ fontSize: 12 }} stroke="#94a3b8" />
<CartesianGrid strokeDasharray="3 3" stroke="var(--td-border-level-2-color)" />
<XAxis dataKey="hour" tick={{ fontSize: 12 }} stroke="var(--td-text-color-secondary)" />
<YAxis
yAxisId="duration"
tick={{ fontSize: 12 }}
stroke="#94a3b8"
stroke="var(--td-text-color-secondary)"
label={{ value: "ms", position: "insideTopRight", fontSize: 11 }}
/>
<YAxis
@@ -37,7 +37,7 @@ export function TrendChart({ data, loading }: TrendChartProps) {
orientation="right"
domain={[0, 100]}
tick={{ fontSize: 12 }}
stroke="#94a3b8"
stroke="var(--td-text-color-secondary)"
label={{ value: "%", position: "insideTopLeft", fontSize: 11 }}
/>
<Tooltip
@@ -53,7 +53,7 @@ export function TrendChart({ data, loading }: TrendChartProps) {
yAxisId="duration"
type="monotone"
dataKey="avgDurationMs"
stroke="#356dd2"
stroke="var(--td-brand-color)"
strokeWidth={2}
dot={false}
name="avgDurationMs"
@@ -62,7 +62,7 @@ export function TrendChart({ data, loading }: TrendChartProps) {
yAxisId="availability"
type="monotone"
dataKey="availability"
stroke="#1fbf75"
stroke="var(--td-success-color)"
strokeWidth={2}
dot={false}
name="availability"