434 lines
20 KiB
TypeScript
434 lines
20 KiB
TypeScript
import { Button, Card, Col, Form, Row, Select, Space, Spin, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||
import { IconSearch } from '@douyinfe/semi-icons';
|
||
import { useEffect, useState } from 'react';
|
||
import { api } from '../api/client';
|
||
import type { DashboardSummary, LinkHealth, ProtocolStat, QualityIssueRow, ServiceStatusStat, VehicleCoverageRow, VehicleRealtimeRow, VehicleServiceSummary } from '../api/types';
|
||
import { PageHeader } from '../components/PageHeader';
|
||
import { SourceStatusTags } from '../components/SourceStatusTags';
|
||
import { StatusTag } from '../components/StatusTag';
|
||
import { qualityIssueVehicleLookup } from '../domain/vehicleLookup';
|
||
|
||
const statusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
||
ok: 'green',
|
||
warning: 'orange',
|
||
error: 'red'
|
||
};
|
||
|
||
const serviceStatusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
||
healthy: 'green',
|
||
degraded: 'orange',
|
||
offline: 'red',
|
||
no_data: 'orange',
|
||
identity_required: 'orange'
|
||
};
|
||
|
||
const serviceStatusTitle: Record<string, string> = {
|
||
healthy: '服务正常',
|
||
degraded: '来源不完整',
|
||
offline: '车辆离线',
|
||
no_data: '暂无数据来源',
|
||
identity_required: '身份未绑定'
|
||
};
|
||
|
||
const missingProtocolTitle: Record<string, string> = {
|
||
GB32960: '缺 GB32960',
|
||
JT808: '缺 JT808',
|
||
YUTONG_MQTT: '缺 YUTONG_MQTT'
|
||
};
|
||
|
||
function formatLag(value?: number | null) {
|
||
return value == null ? '未接入' : value.toLocaleString();
|
||
}
|
||
|
||
function formatCount(value?: number | null) {
|
||
return value == null ? '0' : value.toLocaleString();
|
||
}
|
||
|
||
function vehicleServiceOnlineText(serviceSummary: VehicleServiceSummary | null, summary: DashboardSummary | null) {
|
||
const onlineVehicles = serviceSummary?.onlineVehicles ?? summary?.onlineVehicles;
|
||
const totalVehicles = serviceSummary?.totalVehicles;
|
||
if (onlineVehicles == null || totalVehicles == null) {
|
||
return '未接入';
|
||
}
|
||
return `${onlineVehicles.toLocaleString()} / ${totalVehicles.toLocaleString()} 在线`;
|
||
}
|
||
|
||
function formatProtocolRate(row: ProtocolStat) {
|
||
if (!Number.isFinite(row.total) || row.total <= 0) {
|
||
return '0%';
|
||
}
|
||
return `${Math.round((row.online / row.total) * 100)}%`;
|
||
}
|
||
|
||
function rowServiceStatus(row: { serviceStatus?: { title: string; severity: string }; onlineSourceCount: number; sourceCount: number }) {
|
||
if (row.serviceStatus) {
|
||
return {
|
||
label: row.serviceStatus.title,
|
||
color: row.serviceStatus.severity === 'ok' ? 'green' as const : row.serviceStatus.severity === 'error' ? 'red' as const : 'orange' as const
|
||
};
|
||
}
|
||
if (row.onlineSourceCount <= 0) {
|
||
return { label: '车辆离线', color: 'red' as const };
|
||
}
|
||
if (row.onlineSourceCount < row.sourceCount) {
|
||
return { label: '来源不完整', color: 'orange' as const };
|
||
}
|
||
return { label: '服务正常', color: 'green' as const };
|
||
}
|
||
|
||
function sourceEvidenceText(row: { onlineSourceCount: number; sourceCount: number }) {
|
||
return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`;
|
||
}
|
||
|
||
function sourceConsistencyAction(row: VehicleCoverageRow, onFilter: (filters: Record<string, string>) => void) {
|
||
const consistency = row.sourceConsistency;
|
||
if (!consistency) {
|
||
return <Tag color="grey">未诊断</Tag>;
|
||
}
|
||
const color = consistency.severity === 'ok' ? 'green' as const : consistency.severity === 'error' ? 'red' as const : 'orange' as const;
|
||
const label = consistency.title || consistency.status;
|
||
if ((consistency.missingProtocols ?? []).length > 0) {
|
||
return (
|
||
<Button
|
||
size="small"
|
||
theme="light"
|
||
type={color === 'red' ? 'danger' : color === 'orange' ? 'warning' : 'primary'}
|
||
onClick={() => onFilter({ serviceStatus: 'degraded', missingProtocol: consistency.missingProtocols[0] })}
|
||
>
|
||
{label}
|
||
</Button>
|
||
);
|
||
}
|
||
return <Tag color={color}>{label}</Tag>;
|
||
}
|
||
|
||
export function Dashboard({ onOpenVehicle, onOpenQuality, onOpenVehicles }: { onOpenVehicle: (vin: string, protocol?: string) => void; onOpenQuality: () => void; onOpenVehicles: (filters?: Record<string, string>) => void }) {
|
||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||
const [serviceSummary, setServiceSummary] = useState<VehicleServiceSummary | null>(null);
|
||
const [coverage, setCoverage] = useState<VehicleCoverageRow[]>([]);
|
||
const [locations, setLocations] = useState<VehicleRealtimeRow[]>([]);
|
||
const [qualityIssues, setQualityIssues] = useState<QualityIssueRow[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [coverageLoading, setCoverageLoading] = useState(false);
|
||
const [coverageServiceStatusTitle, setCoverageServiceStatusTitle] = useState('');
|
||
const [coverageFilters, setCoverageFilters] = useState<Record<string, string>>({});
|
||
|
||
const loadCoverage = (values?: Record<string, string>) => {
|
||
const nextValues = values ?? {};
|
||
setCoverageLoading(true);
|
||
setCoverageFilters(nextValues);
|
||
const scopeTitle = [
|
||
nextValues.serviceStatus ? serviceStatusTitle[nextValues.serviceStatus] ?? nextValues.serviceStatus : '',
|
||
nextValues.missingProtocol ? missingProtocolTitle[nextValues.missingProtocol] ?? `缺 ${nextValues.missingProtocol}` : ''
|
||
].filter(Boolean).join(' / ');
|
||
setCoverageServiceStatusTitle(scopeTitle);
|
||
const params = new URLSearchParams({ limit: '8' });
|
||
if (nextValues.keyword) params.set('keyword', nextValues.keyword);
|
||
if (nextValues.coverage) params.set('coverage', nextValues.coverage);
|
||
if (nextValues.missingProtocol) params.set('missingProtocol', nextValues.missingProtocol);
|
||
if (nextValues.online) params.set('online', nextValues.online);
|
||
if (nextValues.bindingStatus) params.set('bindingStatus', nextValues.bindingStatus);
|
||
if (nextValues.serviceStatus) params.set('serviceStatus', nextValues.serviceStatus);
|
||
api.vehicleCoverage(params)
|
||
.then((page) => setCoverage(page.items))
|
||
.catch((error: Error) => Toast.error(error.message))
|
||
.finally(() => setCoverageLoading(false));
|
||
};
|
||
|
||
useEffect(() => {
|
||
const tasks = [
|
||
api.dashboardSummary().then(setSummary),
|
||
api.vehicleServiceSummary().then(setServiceSummary),
|
||
api.vehicleCoverage(new URLSearchParams({ limit: '8' })).then((page) => setCoverage(page.items)),
|
||
api.vehicleRealtime(new URLSearchParams({ limit: '8' })).then((page) => setLocations(page.items)),
|
||
api.qualityIssues(new URLSearchParams({ limit: '5' })).then((page) => setQualityIssues(page.items))
|
||
];
|
||
Promise.allSettled(tasks)
|
||
.then((results) => {
|
||
const failed = results.find((result) => result.status === 'rejected');
|
||
if (failed?.status === 'rejected') {
|
||
const reason = failed.reason;
|
||
Toast.error(reason instanceof Error ? reason.message : '总览数据加载失败');
|
||
}
|
||
})
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
const kpis: Array<{ label: string; value: string; filters: Record<string, string> }> = [
|
||
{ label: '总车辆', value: formatCount(serviceSummary?.totalVehicles), filters: {} },
|
||
{ label: '在线车辆', value: formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles), filters: { online: 'online' } },
|
||
{ label: '单源车辆', value: formatCount(serviceSummary?.singleSourceVehicles), filters: { coverage: 'single' } },
|
||
{ label: '多源车辆', value: formatCount(serviceSummary?.multiSourceVehicles), filters: { coverage: 'multi' } },
|
||
{ label: '暂无来源车辆', value: formatCount(serviceSummary?.noDataVehicles), filters: { serviceStatus: 'no_data' } },
|
||
{ label: '身份未绑定', value: formatCount(serviceSummary?.identityRequiredVehicles), filters: { serviceStatus: 'identity_required' } }
|
||
];
|
||
const missingSourceCounts = new Map((serviceSummary?.missingSources ?? []).map((item) => [item.protocol, item.count]));
|
||
|
||
return (
|
||
<div className="vp-page">
|
||
<PageHeader title="总览工作台" description="以车辆服务为中心汇总在线状态、数据来源覆盖、质量问题和链路健康" />
|
||
<Spin spinning={loading}>
|
||
<div className="vp-kpi-grid">
|
||
{kpis.map((item) => (
|
||
<Card key={item.label} bordered className="vp-kpi-card" bodyStyle={{ padding: 0 }}>
|
||
<button className="vp-kpi-button" type="button" onClick={() => onOpenVehicles(item.filters)} aria-label={`${item.label} ${item.value}`}>
|
||
<div className="vp-kpi-value">{item.value}</div>
|
||
<div className="vp-kpi-label">{item.label}</div>
|
||
</button>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
<Card bordered title="统一车辆服务" style={{ marginBottom: 16 }}>
|
||
<Space wrap>
|
||
<Tag color="blue">{vehicleServiceOnlineText(serviceSummary, summary)}</Tag>
|
||
<Tag color="green">{formatCount(serviceSummary?.multiSourceVehicles)} 多源覆盖</Tag>
|
||
<Tag color={(summary?.issueVehicles ?? 0) > 0 ? 'orange' : 'green'}>
|
||
{formatCount(summary?.issueVehicles)} 质量关注
|
||
</Tag>
|
||
<Tag color={(summary?.kafkaLag ?? 0) > 0 ? 'orange' : 'green'}>Kafka Lag {formatLag(summary?.kafkaLag)}</Tag>
|
||
</Space>
|
||
</Card>
|
||
<Row gutter={16}>
|
||
<Col span={8}>
|
||
<Card title="车辆服务状态" bordered>
|
||
<Table
|
||
pagination={false}
|
||
dataSource={serviceSummary?.serviceStatuses ?? summary?.serviceStatuses ?? []}
|
||
columns={[
|
||
{
|
||
title: '状态',
|
||
render: (_: unknown, row: ServiceStatusStat) => <Tag color={serviceStatusColor[row.status] ?? 'grey'}>{row.title}</Tag>
|
||
},
|
||
{ title: '车辆数', dataIndex: 'count' },
|
||
{
|
||
title: '操作',
|
||
width: 90,
|
||
render: (_: unknown, row: ServiceStatusStat) => (
|
||
<Button
|
||
aria-label={`查看${row.title}`}
|
||
icon={<IconSearch />}
|
||
size="small"
|
||
onClick={() => loadCoverage({ serviceStatus: row.status })}
|
||
/>
|
||
)
|
||
}
|
||
]}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col span={8}>
|
||
<Card title="来源证据在线分布" bordered>
|
||
<Table
|
||
pagination={false}
|
||
dataSource={serviceSummary?.protocols ?? summary?.protocols ?? []}
|
||
columns={[
|
||
{ title: '来源证据', dataIndex: 'protocol' },
|
||
{ title: '在线', dataIndex: 'online' },
|
||
{ title: '总数', dataIndex: 'total' },
|
||
{
|
||
title: '在线率',
|
||
render: (_: unknown, row: ProtocolStat) => formatProtocolRate(row)
|
||
},
|
||
{
|
||
title: '缺失车辆',
|
||
render: (_: unknown, row: ProtocolStat) => {
|
||
const missingCount = missingSourceCounts.get(row.protocol);
|
||
if (missingCount == null) {
|
||
return '-';
|
||
}
|
||
return (
|
||
<Button
|
||
aria-label={`查看缺 ${row.protocol}`}
|
||
size="small"
|
||
onClick={() => loadCoverage({ missingProtocol: row.protocol })}
|
||
>
|
||
{formatCount(missingCount)}
|
||
</Button>
|
||
);
|
||
}
|
||
}
|
||
]}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col span={8}>
|
||
<Card title="链路健康" bordered>
|
||
<Table
|
||
pagination={false}
|
||
dataSource={summary?.linkHealth ?? []}
|
||
columns={[
|
||
{ title: '链路', dataIndex: 'name' },
|
||
{
|
||
title: '状态',
|
||
render: (_: unknown, row: LinkHealth) => <Tag color={statusColor[row.status] ?? 'grey'}>{row.status}</Tag>
|
||
},
|
||
{ title: '说明', dataIndex: 'detail' }
|
||
]}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
<Card title="实时积压" bordered style={{ marginTop: 16 }}>
|
||
<Typography.Text>Kafka 当前消费积压:{formatLag(summary?.kafkaLag)}</Typography.Text>
|
||
</Card>
|
||
<Card
|
||
title={<Space><span>质量问题预览</span><Button size="small" onClick={onOpenQuality}>查看全部</Button></Space>}
|
||
bordered
|
||
style={{ marginTop: 16 }}
|
||
>
|
||
<Table
|
||
pagination={false}
|
||
rowKey={(row?: QualityIssueRow) => `${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.lastSeen ?? ''}-${row?.detail ?? ''}`}
|
||
dataSource={qualityIssues}
|
||
columns={[
|
||
{ title: 'VIN', dataIndex: 'vin', width: 160 },
|
||
{ title: '车牌', dataIndex: 'plate', width: 110 },
|
||
{ title: '手机号', dataIndex: 'phone', width: 130 },
|
||
{ title: '来源地址', dataIndex: 'sourceEndpoint', width: 180 },
|
||
{ title: '来源', dataIndex: 'protocol', width: 110 },
|
||
{ title: '问题', dataIndex: 'issueType', width: 140 },
|
||
{ title: '级别', width: 90, render: (_: unknown, row: QualityIssueRow) => <Tag color={row.severity === 'error' ? 'red' : 'orange'}>{row.severity}</Tag> },
|
||
{ title: '最后时间', dataIndex: 'lastSeen', width: 170 },
|
||
{
|
||
title: '操作',
|
||
width: 130,
|
||
render: (_: unknown, row: QualityIssueRow) => {
|
||
const lookup = qualityIssueVehicleLookup(row);
|
||
return <Button disabled={!lookup.key} onClick={() => onOpenVehicle(lookup.key, row.protocol)}>{lookup.label}</Button>;
|
||
}
|
||
}
|
||
]}
|
||
/>
|
||
</Card>
|
||
<Card
|
||
title={<Space><span>车辆服务覆盖</span><Button size="small" onClick={() => onOpenVehicles(coverageFilters)}>查看全部车辆</Button></Space>}
|
||
bordered
|
||
style={{ marginTop: 16 }}
|
||
>
|
||
{coverageServiceStatusTitle ? (
|
||
<div className="vp-scope-bar" style={{ marginBottom: 12 }}>
|
||
<Tag color="blue">当前筛选:{coverageServiceStatusTitle}</Tag>
|
||
</div>
|
||
) : null}
|
||
<Form layout="horizontal" onSubmit={(values) => loadCoverage(values as Record<string, string>)} style={{ marginBottom: 12 }}>
|
||
<Form.Input field="keyword" label="关键词" placeholder="VIN / 车牌 / 手机号 / OEM" style={{ width: 240 }} />
|
||
<Form.Select field="coverage" label="来源覆盖" placeholder="全部" style={{ width: 130 }}>
|
||
<Select.Option value="single">单源</Select.Option>
|
||
<Select.Option value="multi">多源</Select.Option>
|
||
</Form.Select>
|
||
<Form.Select field="missingProtocol" label="缺失来源" placeholder="全部" style={{ width: 170 }} data-testid="dashboard-missing-protocol-filter">
|
||
<Select.Option value="GB32960">缺 GB32960</Select.Option>
|
||
<Select.Option value="JT808">缺 JT808</Select.Option>
|
||
<Select.Option value="YUTONG_MQTT">缺 YUTONG_MQTT</Select.Option>
|
||
</Form.Select>
|
||
<Form.Select field="online" label="在线" placeholder="全部" style={{ width: 130 }}>
|
||
<Select.Option value="online">在线</Select.Option>
|
||
<Select.Option value="offline">离线</Select.Option>
|
||
</Form.Select>
|
||
<Form.Select field="bindingStatus" label="绑定" placeholder="全部" style={{ width: 130 }}>
|
||
<Select.Option value="bound">已绑定</Select.Option>
|
||
<Select.Option value="unbound">未绑定</Select.Option>
|
||
</Form.Select>
|
||
<Form.Select field="serviceStatus" label="服务状态" placeholder="全部" style={{ width: 150 }} data-testid="dashboard-service-status-filter">
|
||
<Select.Option value="healthy">服务正常</Select.Option>
|
||
<Select.Option value="degraded">来源不完整</Select.Option>
|
||
<Select.Option value="offline">车辆离线</Select.Option>
|
||
<Select.Option value="no_data">暂无数据来源</Select.Option>
|
||
<Select.Option value="identity_required">身份未绑定</Select.Option>
|
||
</Form.Select>
|
||
<Space>
|
||
<Button htmlType="submit" theme="solid" type="primary">筛选</Button>
|
||
<Button onClick={() => loadCoverage({})}>重置</Button>
|
||
</Space>
|
||
</Form>
|
||
<Table
|
||
loading={coverageLoading}
|
||
pagination={false}
|
||
rowKey="vin"
|
||
dataSource={coverage}
|
||
columns={[
|
||
{ title: '车牌', dataIndex: 'plate', width: 110 },
|
||
{ title: 'VIN', dataIndex: 'vin', width: 190 },
|
||
{
|
||
title: '来源证据',
|
||
width: 300,
|
||
render: (_: unknown, row: VehicleCoverageRow) => (
|
||
<SourceStatusTags sourceStatus={row.sourceStatus} protocols={row.protocols} lastSeen={row.lastSeen} />
|
||
)
|
||
},
|
||
{
|
||
title: '证据覆盖',
|
||
width: 130,
|
||
render: (_: unknown, row: VehicleCoverageRow) => sourceEvidenceText(row)
|
||
},
|
||
{
|
||
title: '服务状态',
|
||
width: 130,
|
||
render: (_: unknown, row: VehicleCoverageRow) => {
|
||
const status = rowServiceStatus(row);
|
||
return <Tag color={status.color}>{status.label}</Tag>;
|
||
}
|
||
},
|
||
{
|
||
title: '来源一致性',
|
||
width: 140,
|
||
render: (_: unknown, row: VehicleCoverageRow) => sourceConsistencyAction(row, loadCoverage)
|
||
},
|
||
{ title: '在线', width: 90, render: (_: unknown, row: VehicleCoverageRow) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
|
||
{ title: '绑定', width: 90, render: (_: unknown, row: VehicleCoverageRow) => <Tag color={row.bindingStatus === 'bound' ? 'green' : 'orange'}>{row.bindingStatus === 'bound' ? '已绑定' : '未绑定'}</Tag> },
|
||
{ title: '最后时间', dataIndex: 'lastSeen', width: 170 },
|
||
{ title: '操作', width: 110, render: (_: unknown, row: VehicleCoverageRow) => <Button onClick={() => onOpenVehicle(row.vin)}>车辆服务</Button> }
|
||
]}
|
||
/>
|
||
</Card>
|
||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||
<Col span={12}>
|
||
<Card title="实时位置预览" bordered>
|
||
<div className="vp-map" style={{ height: 260 }}>
|
||
{locations.map((row, index) => (
|
||
<span
|
||
key={row.vin}
|
||
className="vp-map-dot"
|
||
title={`${row.plate} ${row.primaryProtocol}`}
|
||
style={{ left: `${18 + index * 13}%`, top: `${24 + (index % 4) * 15}%` }}
|
||
/>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Card title="最新车辆" bordered>
|
||
<Table
|
||
pagination={false}
|
||
rowKey="vin"
|
||
dataSource={locations}
|
||
columns={[
|
||
{ title: '车牌', dataIndex: 'plate' },
|
||
{ title: 'VIN', dataIndex: 'vin' },
|
||
{
|
||
title: '来源证据',
|
||
render: (_: unknown, row: VehicleRealtimeRow) => (
|
||
<Space spacing={4} wrap>
|
||
{row.protocols.map((protocol) => <Tag key={protocol} color={protocol === row.primaryProtocol ? 'blue' : 'grey'}>{protocol}</Tag>)}
|
||
</Space>
|
||
)
|
||
},
|
||
{
|
||
title: '服务状态',
|
||
render: (_: unknown, row: VehicleRealtimeRow) => {
|
||
const status = rowServiceStatus(row);
|
||
return <Tag color={status.color}>{status.label}</Tag>;
|
||
}
|
||
},
|
||
{ title: '最后时间', dataIndex: 'lastSeen' },
|
||
{ title: '操作', width: 110, render: (_: unknown, row: VehicleRealtimeRow) => <Button onClick={() => onOpenVehicle(row.vin, row.primaryProtocol)}>车辆服务</Button> }
|
||
]}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
</Spin>
|
||
</div>
|
||
);
|
||
}
|