refactor: 前端架构重构 — hook拆分、组件拆分、类型筛选器动态化、Meta API
- 后端新增 GET /api/meta 端点(checkerRegistry.supportedTypes)及 MetaResponse 类型 - 前端 hook 拆分为 use-queries.ts(全局查询+useMeta)和 use-target-detail.ts(Drawer状态) - TargetDetailDrawer 拆分为 OverviewTab + HistoryTab + history-table-columns + stats.ts - 类型筛选器由 meta API 动态驱动,删除 target-type-display 静态映射 - 列定义改为工厂函数 createTargetTableColumns(checkerTypes),TargetGroup 新增 columns prop - 修复 StatusDonut key、StatusBar maxSlots prop、TrendChart 移除 loading prop - 补充 utils/time、utils/stats、动态列工厂测试,删除旧 mapping 测试 - 同步 delta specs 到主 specs,归档 frontend-architecture-refactor change
This commit is contained in:
31
src/web/components/HistoryTab.tsx
Normal file
31
src/web/components/HistoryTab.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { PrimaryTable } from "tdesign-react";
|
||||
|
||||
import type { HistoryResponse } from "../../shared/api";
|
||||
|
||||
import { HISTORY_COLUMNS } from "../constants/history-table-columns";
|
||||
|
||||
interface HistoryTabProps {
|
||||
historyData: HistoryResponse;
|
||||
historyLoading: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function HistoryTab({ historyData, historyLoading, onPageChange }: HistoryTabProps) {
|
||||
return (
|
||||
<PrimaryTable
|
||||
columns={HISTORY_COLUMNS}
|
||||
data={historyData.items}
|
||||
disableDataPage
|
||||
loading={historyLoading}
|
||||
onPageChange={({ current }) => {
|
||||
if (current) onPageChange(current);
|
||||
}}
|
||||
pagination={{
|
||||
current: historyData.page,
|
||||
pageSize: historyData.pageSize,
|
||||
total: historyData.total,
|
||||
}}
|
||||
rowKey="timestamp"
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
src/web/components/OverviewTab.tsx
Normal file
57
src/web/components/OverviewTab.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from "react";
|
||||
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic } from "tdesign-react";
|
||||
|
||||
import type { TargetStatus, TrendPoint } from "../../shared/api";
|
||||
|
||||
import { computeTrendStats } from "../utils/stats";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
|
||||
interface OverviewTabProps {
|
||||
target: TargetStatus;
|
||||
trendData: TrendPoint[];
|
||||
trendLoading: boolean;
|
||||
}
|
||||
|
||||
export function OverviewTab({ target, trendData, trendLoading }: OverviewTabProps) {
|
||||
const { downChecks, totalChecks, upChecks } = useMemo(() => computeTrendStats(trendData), [trendData]);
|
||||
|
||||
return (
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<Divider align="left">统计</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={3}>
|
||||
<Statistic color="blue" title="总检查" value={totalChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" title="正常" value={upChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="red" title="异常" value={downChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" suffix="%" title="可用率" value={target.stats?.availability ?? 0} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider align="left">趋势</Divider>
|
||||
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} />}
|
||||
|
||||
<Divider align="left">状态分布</Divider>
|
||||
<StatusDonut down={downChecks} up={upChecks} />
|
||||
|
||||
<Divider align="left">基本信息</Divider>
|
||||
<Descriptions
|
||||
items={[
|
||||
{ content: target.target, label: "目标地址" },
|
||||
{ content: target.interval, label: "检查间隔" },
|
||||
{
|
||||
content: target.latestCheck ? new Date(target.latestCheck.timestamp).toLocaleString("zh-CN") : "-",
|
||||
label: "最新检查时间",
|
||||
},
|
||||
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
interface StatusBarProps {
|
||||
maxSlots?: number;
|
||||
samples: Array<{ up: boolean }>;
|
||||
}
|
||||
|
||||
export function StatusBar({ samples }: StatusBarProps) {
|
||||
export function StatusBar({ maxSlots = 30, samples }: StatusBarProps) {
|
||||
const blocks = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
for (let i = 0; i < maxSlots; i++) {
|
||||
const sample = samples[i];
|
||||
if (sample) {
|
||||
blocks.push(
|
||||
|
||||
@@ -28,8 +28,8 @@ export function StatusDonut({ down, up }: StatusDonutProps) {
|
||||
<ResponsiveContainer height={180} width="100%">
|
||||
<PieChart>
|
||||
<Pie cx="50%" cy="50%" data={data} dataKey="value" innerRadius={50} outerRadius={70} stroke="none">
|
||||
{data.map((_, index) => (
|
||||
<Cell fill={colors[index % colors.length]} key={index} />
|
||||
{data.map((item, index) => (
|
||||
<Cell fill={colors[index % colors.length]} key={item.name} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { useMemo } from "react";
|
||||
import { Space } from "tdesign-react";
|
||||
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
import { createTargetTableColumns } from "../constants/target-table-columns";
|
||||
import { useMeta } from "../hooks/use-queries";
|
||||
import { TargetGroup } from "./TargetGroup";
|
||||
|
||||
const EMPTY_CHECKER_TYPES: string[] = [];
|
||||
|
||||
interface TargetBoardProps {
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
targets: TargetStatus[];
|
||||
}
|
||||
|
||||
export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
|
||||
const { data: meta } = useMeta();
|
||||
const checkerTypes = meta?.checkerTypes ?? EMPTY_CHECKER_TYPES;
|
||||
const columns = useMemo(() => createTargetTableColumns(checkerTypes), [checkerTypes]);
|
||||
|
||||
const groups = new Map<string, TargetStatus[]>();
|
||||
for (const target of targets) {
|
||||
const group = target.group;
|
||||
@@ -29,7 +38,7 @@ export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
|
||||
return (
|
||||
<Space className="full-width" direction="vertical" size={32}>
|
||||
{sortedGroups.map(([name, groupTargets]) => (
|
||||
<TargetGroup key={name} name={name} onTargetClick={onTargetClick} targets={groupTargets} />
|
||||
<TargetGroup columns={columns} key={name} name={name} onTargetClick={onTargetClick} targets={groupTargets} />
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
|
||||
@@ -1,30 +1,14 @@
|
||||
import type { TabValue } from "tdesign-react";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Col,
|
||||
DateRangePicker,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Drawer,
|
||||
PrimaryTable,
|
||||
RadioGroup,
|
||||
Row,
|
||||
Skeleton,
|
||||
Space,
|
||||
Statistic,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from "tdesign-react";
|
||||
import { DateRangePicker, Drawer, RadioGroup, Space, Tabs, Tag, Typography } from "tdesign-react";
|
||||
|
||||
import type { CheckResult, HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
import type { HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
|
||||
import { getTargetTypeDisplay } from "../constants/target-type-display";
|
||||
import { subtractHours } from "../utils/time";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { HistoryTab } from "./HistoryTab";
|
||||
import { OverviewTab } from "./OverviewTab";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
|
||||
interface TargetDetailDrawerProps {
|
||||
historyData: HistoryResponse;
|
||||
@@ -46,43 +30,6 @@ const TIME_SHORTCUTS = [
|
||||
{ hours: 168, label: "7天", value: "7d" },
|
||||
] as const;
|
||||
|
||||
const HISTORY_COLUMNS = [
|
||||
{
|
||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => (
|
||||
<StatusDot up={!!row.matched} />
|
||||
),
|
||||
colKey: "matched",
|
||||
title: "#",
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => {
|
||||
const d = new Date(row.timestamp);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
},
|
||||
colKey: "timestamp",
|
||||
title: "时间",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
align: "center" as const,
|
||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) =>
|
||||
row.durationMs !== null ? Math.round(row.durationMs) : "-",
|
||||
colKey: "durationMs",
|
||||
title: "耗时(ms)",
|
||||
width: 96,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => {
|
||||
const parts = [row.statusDetail, row.failure?.message].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(":") : "-";
|
||||
},
|
||||
colKey: "statusDetail",
|
||||
title: "详情",
|
||||
},
|
||||
];
|
||||
|
||||
export function TargetDetailDrawer({
|
||||
historyData,
|
||||
historyLoading,
|
||||
@@ -123,9 +70,6 @@ export function TargetDetailDrawer({
|
||||
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
|
||||
@@ -135,7 +79,7 @@ export function TargetDetailDrawer({
|
||||
<StatusDot up={!!isUp} />
|
||||
<Typography.Text strong>{target.name}</Typography.Text>
|
||||
<Tag size="small" theme="primary" variant="light-outline">
|
||||
{getTargetTypeDisplay(target.type)}
|
||||
{target.type}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
@@ -163,66 +107,16 @@ export function TargetDetailDrawer({
|
||||
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
|
||||
valueType="YYYY-MM-DD HH:mm"
|
||||
/>
|
||||
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
|
||||
<OverviewTab target={target} trendData={trendData} trendLoading={trendLoading} />
|
||||
</Tabs.TabPanel>
|
||||
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="记录" value="history">
|
||||
<HistoryTab historyData={historyData} historyLoading={historyLoading} onPageChange={onPageChange} />
|
||||
</Tabs.TabPanel>
|
||||
</Tabs>
|
||||
</Space>
|
||||
|
||||
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<Divider align="left">统计</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={3}>
|
||||
<Statistic color="blue" title="总检查" value={totalChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" title="正常" value={upChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="red" title="异常" value={downChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" suffix="%" title="可用率" value={target.stats?.availability ?? 0} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider align="left">趋势</Divider>
|
||||
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} loading={false} />}
|
||||
|
||||
<Divider align="left">状态分布</Divider>
|
||||
<StatusDonut down={downChecks} up={upChecks} />
|
||||
|
||||
<Divider align="left">基本信息</Divider>
|
||||
<Descriptions
|
||||
items={[
|
||||
{ content: target.target, label: "目标地址" },
|
||||
{ content: target.interval, label: "检查间隔" },
|
||||
{
|
||||
content: target.latestCheck ? new Date(target.latestCheck.timestamp).toLocaleString("zh-CN") : "-",
|
||||
label: "最新检查时间",
|
||||
},
|
||||
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Tabs.TabPanel>
|
||||
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="记录" value="history">
|
||||
<PrimaryTable
|
||||
columns={HISTORY_COLUMNS}
|
||||
data={historyData.items}
|
||||
disableDataPage
|
||||
loading={historyLoading}
|
||||
onPageChange={({ current }) => {
|
||||
if (current) onPageChange(current);
|
||||
}}
|
||||
pagination={{
|
||||
current: historyData.page,
|
||||
pageSize: historyData.pageSize,
|
||||
total: historyData.total,
|
||||
}}
|
||||
rowKey="timestamp"
|
||||
/>
|
||||
</Tabs.TabPanel>
|
||||
</Tabs>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import type { PrimaryTableCol } from "tdesign-react";
|
||||
|
||||
import { PrimaryTable } from "tdesign-react";
|
||||
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
import { TARGET_TABLE_COLUMNS } from "../constants/target-table-columns";
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
|
||||
interface TargetGroupProps {
|
||||
columns: Array<PrimaryTableCol<TargetStatus>>;
|
||||
name: string;
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
targets: TargetStatus[];
|
||||
}
|
||||
|
||||
export function TargetGroup({ name, onTargetClick, targets }: TargetGroupProps) {
|
||||
export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGroupProps) {
|
||||
const up = targets.filter((t) => t.latestCheck?.matched).length;
|
||||
const down = targets.length - up;
|
||||
|
||||
@@ -21,7 +23,7 @@ export function TargetGroup({ name, onTargetClick, targets }: TargetGroupProps)
|
||||
<PrimaryTable
|
||||
bordered
|
||||
className="clickable-table"
|
||||
columns={TARGET_TABLE_COLUMNS}
|
||||
columns={columns}
|
||||
data={targets}
|
||||
defaultSort={[{ descending: true, sortBy: "latestCheck.matched" }]}
|
||||
hover
|
||||
|
||||
@@ -4,14 +4,9 @@ import type { TrendPoint } from "../../shared/api";
|
||||
|
||||
interface TrendChartProps {
|
||||
data: TrendPoint[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
if (loading) {
|
||||
return <div className="trend-loading">加载趋势数据...</div>;
|
||||
}
|
||||
|
||||
export function TrendChart({ data }: TrendChartProps) {
|
||||
if (data.length === 0) {
|
||||
return <div className="trend-empty">暂无趋势数据</div>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user