Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx
2026-07-04 20:47:29 +08:00

1215 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, useMemo, 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 { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap';
import { isAMapConfigured } from '../config/appConfig';
import { buildAppHash } from '../domain/appRoute';
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
import { qualityIssueLabel, qualityProtocolLabel } from '../domain/qualityIssue';
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'
};
type DashboardSnapshotRow = {
section: string;
item: string;
value: string;
detail: string;
};
const dashboardSnapshotColumns: CsvColumn<DashboardSnapshotRow>[] = [
{ title: '模块', value: (row) => row.section },
{ title: '指标', value: (row) => row.item },
{ title: '值', value: (row) => row.value },
{ title: '说明', value: (row) => row.detail }
];
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 hasValidCoordinate(row: VehicleRealtimeRow) {
return Number.isFinite(row.longitude) && Number.isFinite(row.latitude) && row.longitude !== 0 && row.latitude !== 0;
}
function issueEvidenceDate(value?: string) {
const match = String(value ?? '').match(/^(\d{4})-(\d{2})-(\d{2})/);
return match ? `${match[1]}-${match[2]}-${match[3]}` : '';
}
function nextDate(value: string) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
if (!match) return '';
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + 1));
return date.toISOString().slice(0, 10);
}
function priorityIssueVehicleLabel(row: QualityIssueRow) {
const identity = row.vin?.trim() && row.vin !== 'unknown' ? row.vin.trim() : row.phone?.trim();
return [row.plate?.trim(), identity].filter(Boolean).join(' / ') || row.sourceEndpoint || '-';
}
function priorityIssueEvidenceFilters(row: QualityIssueRow) {
const lookup = qualityIssueVehicleLookup(row);
const dateFrom = issueEvidenceDate(row.lastSeen);
return {
keyword: lookup.key,
protocol: row.protocol,
...(dateFrom ? { dateFrom, dateTo: nextDate(dateFrom) } : {})
};
}
function appURL(hash: string) {
return `${window.location.origin}${window.location.pathname}${hash}`;
}
async function copyText(value: string, label: string) {
try {
await navigator.clipboard.writeText(value);
Toast.success(`已复制${label}`);
} catch {
Toast.error(`复制${label}失败`);
}
}
function priorityIssueNotificationText(row: QualityIssueRow) {
const lookup = qualityIssueVehicleLookup(row);
const filters = priorityIssueEvidenceFilters(row);
const rawFilters = { ...filters, tab: 'raw', includeFields: 'true' };
const priority = row.severity === 'error' ? 'P0' : 'P1';
return [
`${priority} 告警通知】${qualityIssueLabel(row.issueType)}`,
`车辆:${priorityIssueVehicleLabel(row)}`,
`数据来源:${qualityProtocolLabel(row.protocol)}`,
`问题:${qualityIssueLabel(row.issueType)}`,
`最后时间:${row.lastSeen || '-'}`,
`详情:${row.detail || '-'}`,
`实时定位:${appURL(buildAppHash({ page: 'realtime', keyword: lookup.key, protocol: row.protocol }))}`,
`轨迹证据:${appURL(buildAppHash({ page: 'history', keyword: filters.keyword, protocol: filters.protocol, filters }))}`,
`RAW证据${appURL(buildAppHash({ page: 'history-query', keyword: rawFilters.keyword, protocol: rawFilters.protocol, filters: rawFilters }))}`,
`车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: lookup.key, protocol: row.protocol }))}`,
`告警筛选:${appURL(buildAppHash({ page: 'alert-events', protocol: row.protocol, filters: { issueType: row.issueType } }))}`
].join('\n');
}
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,
onOpenMap,
onOpenRealtime,
onOpenVehicles,
onOpenHistory,
onOpenMileage
}: {
onOpenVehicle: (vin: string, protocol?: string) => void;
onOpenQuality: (filters?: Record<string, string>) => void;
onOpenMap: (filters?: Record<string, string>) => void;
onOpenRealtime: (filters?: Record<string, string>) => void;
onOpenVehicles: (filters?: Record<string, string>) => void;
onOpenHistory: (filters?: Record<string, string>) => void;
onOpenMileage: (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 amapConfigured = isAMapConfigured();
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.alertEvents(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' } },
{ label: '档案不完整', value: formatCount(serviceSummary?.archiveIncompleteVehicles), filters: { archiveStatus: 'incomplete' } }
];
const missingSourceCounts = new Map((serviceSummary?.missingSources ?? []).map((item) => [item.protocol, item.count]));
const serviceActionQueue = useMemo<Array<{ label: string; count: number; filters: Record<string, string>; detail: string }>>(() => {
const items: Array<{ label: string; count: number; filters: Record<string, string>; detail: string }> = [];
if ((serviceSummary?.noDataVehicles ?? 0) > 0) {
items.push({
label: '确认平台转发',
count: serviceSummary?.noDataVehicles ?? 0,
filters: { serviceStatus: 'no_data' },
detail: '车辆没有形成任何来源证据,优先确认上游平台是否持续转发。'
});
}
if ((serviceSummary?.identityRequiredVehicles ?? 0) > 0) {
items.push({
label: '维护身份绑定',
count: serviceSummary?.identityRequiredVehicles ?? 0,
filters: { serviceStatus: 'identity_required' },
detail: '已有数据但无法稳定归并到 VIN会影响车辆服务聚合。'
});
}
if ((serviceSummary?.archiveIncompleteVehicles ?? 0) > 0) {
items.push({
label: '完善车辆档案',
count: serviceSummary?.archiveIncompleteVehicles ?? 0,
filters: { archiveStatus: 'incomplete' },
detail: '车辆缺少车牌、手机号或 OEM 等基础档案,影响后续运营查询和治理。'
});
}
for (const field of serviceSummary?.archiveMissingFields ?? []) {
if (field.count <= 0) continue;
items.push({
label: `补齐${field.title}`,
count: field.count,
filters: { archiveMissing: field.field },
detail: `${field.title}会影响车辆档案检索、绑定确认和运营侧筛选。`
});
}
for (const source of serviceSummary?.missingSources ?? []) {
if (source.count <= 0) continue;
items.push({
label: `补齐 ${source.protocol} 来源`,
count: source.count,
filters: { serviceStatus: 'degraded', missingProtocol: source.protocol },
detail: `${source.protocol} 来源缺失会降低跨来源定位、里程和实时判断可信度。`
});
}
return items;
}, [serviceSummary]);
const commandOnlineCount = locations.filter((row) => row.online).length;
const commandLocatedCount = locations.filter(hasValidCoordinate).length;
const commandDegradedCount = locations.filter((row) => row.onlineSourceCount <= 0 || row.onlineSourceCount < row.sourceCount).length;
const highPriorityIssue = qualityIssues.find((item) => item.severity === 'error') ?? qualityIssues[0];
const highPriorityLookup = highPriorityIssue ? qualityIssueVehicleLookup(highPriorityIssue) : undefined;
const highPriorityEvidenceFilters = highPriorityIssue ? priorityIssueEvidenceFilters(highPriorityIssue) : undefined;
const unhealthyLinkCount = (summary?.linkHealth ?? []).filter((item) => item.status !== 'ok').length;
const commandMapPoints: VehicleMapPoint[] = locations.map((row, index) => ({
id: row.vin || `${row.primaryProtocol || 'source'}-${index}`,
label: row.plate || row.vin || 'unknown',
longitude: row.longitude,
latitude: row.latitude,
online: row.online,
title: `${row.plate || row.vin || '-'} ${row.primaryProtocol || ''} ${row.lastSeen || ''}`
}));
const capabilities = [
{
title: '实时地图',
status: `在线态势 ${formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles)}`,
color: 'green' as const,
description: '以高德地图为主视角查看车辆在线分布、定位有效性、来源一致性和地图接入状态。',
action: '打开地图',
onClick: () => onOpenMap({ online: 'online' })
},
{
title: '实时监控',
status: `在线 ${formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles)}`,
color: 'green' as const,
description: '按车辆聚合最新位置、在线状态和多来源实时字段,支持实时地图查看。',
action: '查看在线车辆',
onClick: () => onOpenRealtime({ online: 'online' })
},
{
title: '轨迹回放',
status: `有效定位 ${formatCount(commandLocatedCount)}`,
color: commandLocatedCount > 0 ? 'blue' as const : 'grey' as const,
description: '围绕车辆回放历史位置轨迹,结合高德线路和 RAW 证据定位异常。',
action: '打开轨迹',
onClick: () => onOpenHistory()
},
{
title: '历史数据查询',
status: `今日帧 ${formatCount(summary?.frameToday)}`,
color: 'blue' as const,
description: '查询位置历史、RAW 帧和解析字段,为车辆问题复盘提供证据链。',
action: '查询历史',
onClick: () => onOpenHistory({ tab: 'raw' })
},
{
title: '告警事件触发与通知',
status: `告警 ${formatCount(summary?.issueVehicles)}`,
color: (summary?.issueVehicles ?? 0) > 0 ? 'orange' as const : 'green' as const,
description: '围绕断链、无来源、VIN 缺失、字段缺失形成告警和通知闭环。',
action: '查看告警',
onClick: () => onOpenQuality()
},
{
title: '统计查询',
status: `统计车辆 ${formatCount(serviceSummary?.totalVehicles)}`,
color: 'green' as const,
description: '提供里程等运营统计查询,保证区间总值与每日统计口径闭合。',
action: '查看统计',
onClick: () => onOpenMileage()
}
];
const vehicleServicePrinciples = [
{
title: '一车一档',
value: formatCount(serviceSummary?.totalVehicles),
detail: '以 VIN 为主对象沉淀车牌、手机号、OEM、绑定状态和来源覆盖。'
},
{
title: '一车一实时',
value: formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles),
detail: '实时状态合并位置、在线、速度、SOC、里程和来源新鲜度。'
},
{
title: '一车一轨迹',
value: formatCount(commandLocatedCount),
detail: '轨迹回放围绕车辆展开,协议来源只作为证据过滤条件。'
},
{
title: '一车一统计',
value: formatCount(serviceSummary?.totalVehicles),
detail: '里程等运营指标按车辆口径闭合,再回溯到来源证据。'
}
];
const workflowSteps = [
{
title: '接入巡检',
value: unhealthyLinkCount > 0 ? `${unhealthyLinkCount.toLocaleString()} 项关注` : '链路正常',
color: unhealthyLinkCount > 0 ? 'orange' as const : 'green' as const,
detail: '检查平台转发、Kafka、Redis、MySQL、TDengine 等关键链路。',
action: '查看告警',
onClick: () => onOpenQuality()
},
{
title: '实时态势',
value: `${formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles)} 在线`,
color: 'green' as const,
detail: '确认车辆是否在线、是否有有效坐标以及来源是否完整。',
action: '实时监控',
onClick: () => onOpenRealtime({ online: 'online' })
},
{
title: '轨迹复盘',
value: `${formatCount(summary?.activeToday)} 活跃`,
color: 'blue' as const,
detail: '按车辆进入历史轨迹,复盘位置、速度、里程和断点。',
action: '轨迹回放',
onClick: () => onOpenHistory()
},
{
title: '历史证据',
value: `${formatCount(summary?.frameToday)}`,
color: 'blue' as const,
detail: '查询 RAW 帧和解析字段,形成可追溯的数据证据。',
action: '历史查询',
onClick: () => onOpenHistory({ tab: 'raw' })
},
{
title: '告警事件',
value: `${formatCount(summary?.issueVehicles)} 车辆`,
color: (summary?.issueVehicles ?? 0) > 0 ? 'orange' as const : 'green' as const,
detail: '把断链、缺 VIN、字段缺失等问题进入通知和处置队列。',
action: '告警事件',
onClick: () => onOpenQuality()
},
{
title: '统计复核',
value: `${formatCount(serviceSummary?.totalVehicles)} 车辆`,
color: 'green' as const,
detail: '核对里程统计口径,保证区间统计和每日统计闭合。',
action: '统计查询',
onClick: () => onOpenMileage()
}
];
const operationWorkbench = [
{
title: '实时监控',
value: `${formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles)} 在线`,
meta: `有效定位 ${commandLocatedCount.toLocaleString()}`,
color: 'green' as const,
detail: '面向值班人员确认车辆是否在线、坐标是否有效、来源是否完整。',
actions: [
{ label: '打开实时地图', onClick: () => onOpenMap({ online: 'online' }) },
{ label: '查看在线车辆', onClick: () => onOpenRealtime({ online: 'online' }) }
]
},
{
title: '轨迹回放',
value: `${formatCount(summary?.activeToday)} 今日活跃`,
meta: '高德轨迹证据',
color: 'blue' as const,
detail: '按车辆回看位置、速度、里程和断点,用于定位异常复盘。',
actions: [
{ label: '打开轨迹回放', onClick: () => onOpenHistory() },
{ label: '查 RAW 证据', onClick: () => onOpenHistory({ tab: 'raw', includeFields: 'true' }) }
]
},
{
title: '历史数据查询',
value: `${formatCount(summary?.frameToday)} 今日帧`,
meta: `Kafka Lag ${formatLag(summary?.kafkaLag)}`,
color: (summary?.kafkaLag ?? 0) > 0 ? 'orange' as const : 'blue' as const,
detail: '查询位置历史、RAW 帧和解析字段,为车辆服务提供可追溯证据。',
actions: [
{ label: '查询历史数据', onClick: () => onOpenHistory({ tab: 'location' }) },
{ label: '查询解析字段', onClick: () => onOpenHistory({ tab: 'raw', includeFields: 'true' }) }
]
},
{
title: '告警事件与通知',
value: `${formatCount(summary?.issueVehicles)} 告警车辆`,
meta: highPriorityIssue ? `${highPriorityIssue.severity === 'error' ? 'P0' : 'P1'} ${qualityIssueLabel(highPriorityIssue.issueType)}` : '暂无高优先级',
color: (summary?.issueVehicles ?? 0) > 0 ? 'orange' as const : 'green' as const,
detail: '把断链、无来源、VIN 缺失、字段缺失转成可通知、可闭环的事件。',
actions: [
{ label: '查看告警事件', onClick: () => onOpenQuality() },
{ label: '处理最高优先级', onClick: () => onOpenQuality(highPriorityIssue?.issueType ? { issueType: highPriorityIssue.issueType } : {}) }
]
},
{
title: '统计查询',
value: `${formatCount(serviceSummary?.totalVehicles)} 车辆口径`,
meta: '区间闭合复核',
color: 'green' as const,
detail: '里程等指标先按车辆口径闭合,再回溯轨迹和 RAW 证据。',
actions: [
{ label: '打开统计查询', onClick: () => onOpenMileage() },
{ label: '查看车辆中心', onClick: () => onOpenVehicles({}) }
]
}
];
const scenarioNavigation = [
{
title: '实时监控',
objective: '确认车辆是否在线、位置是否可信、三类来源是否齐全。',
evidence: `Redis 在线态 / 最新定位 / 有效坐标 ${commandLocatedCount.toLocaleString()}`,
sla: '目标 0-1 秒内进入实时视图',
primaryAction: '实时监控',
secondaryAction: '地图态势',
onPrimary: () => onOpenRealtime({ online: 'online' }),
onSecondary: () => onOpenMap({ online: 'online' })
},
{
title: '轨迹回放',
objective: '按车辆复盘位置、速度、里程和断点,定位平台转发或车辆异常。',
evidence: `高德轨迹底座 / 今日活跃 ${formatCount(summary?.activeToday)}`,
sla: '支持按车辆和时间窗快速回放',
primaryAction: '轨迹回放',
secondaryAction: 'RAW证据',
onPrimary: () => onOpenHistory(),
onSecondary: () => onOpenHistory({ tab: 'raw', includeFields: 'true' })
},
{
title: '历史数据查询',
objective: '围绕车辆查询位置、RAW、解析字段给 BI、运维和业务复盘提供证据。',
evidence: `TDengine / 今日帧 ${formatCount(summary?.frameToday)} / Kafka Lag ${formatLag(summary?.kafkaLag)}`,
sla: '优先返回必要字段,避免大 JSON 拖慢查询',
primaryAction: '历史查询',
secondaryAction: '字段证据',
onPrimary: () => onOpenHistory({ tab: 'location' }),
onSecondary: () => onOpenHistory({ tab: 'raw', includeFields: 'true' })
},
{
title: '告警事件触发和通知',
objective: '把断链、无来源、VIN 缺失、字段缺失转换为可通知、可闭环事件。',
evidence: `告警车辆 ${formatCount(summary?.issueVehicles)} / 最高优先级 ${highPriorityIssue ? qualityIssueLabel(highPriorityIssue.issueType) : '暂无'}`,
sla: 'P0 进入通知队列,超时升级',
primaryAction: '告警事件',
secondaryAction: '通知规则',
onPrimary: () => onOpenQuality(),
onSecondary: () => onOpenQuality(highPriorityIssue?.issueType ? { issueType: highPriorityIssue.issueType } : {})
},
{
title: '统计查询',
objective: '按车辆口径查询里程等指标,保证区间统计和日统计可闭合。',
evidence: `车辆口径 ${formatCount(serviceSummary?.totalVehicles)} / 多源覆盖 ${formatCount(serviceSummary?.multiSourceVehicles)}`,
sla: '统计值必须能追溯轨迹和 RAW 证据',
primaryAction: '统计查询',
secondaryAction: '车辆中心',
onPrimary: () => onOpenMileage(),
onSecondary: () => onOpenVehicles({})
}
];
const copyOperationsHandoff = () => {
const unhealthyLinks = (summary?.linkHealth ?? []).filter((item) => item.status !== 'ok');
const priorityAction = serviceActionQueue[0];
const highPriorityLookupKey = highPriorityLookup?.key;
const highPriorityFilters = highPriorityEvidenceFilters;
const lines = [
'【车辆数据中台运营交接摘要】',
`在线车辆:${formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles)} / ${formatCount(serviceSummary?.totalVehicles)}`,
`今日活跃:${formatCount(summary?.activeToday)}`,
`今日帧量:${formatCount(summary?.frameToday)}`,
`实时地图有效定位:${commandLocatedCount.toLocaleString()}`,
`告警车辆:${formatCount(summary?.issueVehicles)}`,
`Kafka Lag${formatLag(summary?.kafkaLag)}`,
`高德地图:${amapConfigured ? '已配置' : '待配置'}`,
`链路健康:${unhealthyLinks.length > 0 ? unhealthyLinks.map((item) => `${item.name}=${item.status}`).join('') : '正常'}`,
`优先动作:${priorityAction ? `${priorityAction.label} ${priorityAction.count.toLocaleString()}辆 - ${priorityAction.detail}` : '暂无待办'}`,
highPriorityIssue ? `最高告警:${highPriorityIssue.severity === 'error' ? 'P0' : 'P1'} ${qualityIssueLabel(highPriorityIssue.issueType)} / ${priorityIssueVehicleLabel(highPriorityIssue)} / ${highPriorityIssue.lastSeen || '-'}` : '最高告警:暂无',
highPriorityIssue ? `告警详情:${highPriorityIssue.detail || '-'}` : '',
`实时监控:${appURL(buildAppHash({ page: 'realtime', filters: { online: 'online' } }))}`,
`实时地图:${appURL(buildAppHash({ page: 'map', filters: { online: 'online' } }))}`,
`轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: highPriorityFilters?.keyword, protocol: highPriorityFilters?.protocol, filters: highPriorityFilters }))}`,
`历史RAW${appURL(buildAppHash({ page: 'history-query', keyword: highPriorityFilters?.keyword, protocol: highPriorityFilters?.protocol, filters: { ...highPriorityFilters, tab: 'raw', includeFields: 'true' } }))}`,
`告警事件:${appURL(buildAppHash({ page: 'alert-events', filters: highPriorityIssue?.issueType ? { issueType: highPriorityIssue.issueType } : {} }))}`,
`统计查询:${appURL(buildAppHash({ page: 'mileage', keyword: highPriorityLookupKey, protocol: highPriorityIssue?.protocol }))}`,
`车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: highPriorityLookupKey, protocol: highPriorityIssue?.protocol }))}`
].filter(Boolean);
copyText(lines.join('\n'), '运营交接摘要');
};
const copyScenarioBlueprint = () => {
const lines = [
'【车辆数据中台功能蓝图】',
'定位32960 / 808 / 宇通 MQTT 都只是车辆服务证据源,最终围绕一辆车提供实时、轨迹、历史、告警、统计能力。',
`高德地图:${amapConfigured ? '已配置,前端只使用运行时配置和安全代理' : '待配置'}`,
`车辆规模:${formatCount(serviceSummary?.totalVehicles)};在线:${formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles)};告警:${formatCount(summary?.issueVehicles)}`,
'',
...scenarioNavigation.map((item, index) => [
`${index + 1}. ${item.title}`,
` 目标:${item.objective}`,
` 证据:${item.evidence}`,
` SLA${item.sla}`
].join('\n')),
'',
`入口:${appURL(buildAppHash({ page: 'dashboard' }))}`
];
copyText(lines.join('\n'), '功能蓝图');
};
const buildDashboardSnapshotRows = () => {
const unhealthyLinks = (summary?.linkHealth ?? []).filter((item) => item.status !== 'ok');
const priorityAction = serviceActionQueue[0];
const rows: DashboardSnapshotRow[] = [
{
section: '车辆服务',
item: '在线车辆',
value: `${formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles)} / ${formatCount(serviceSummary?.totalVehicles)}`,
detail: '统一车辆服务在线态势'
},
{
section: '车辆服务',
item: '多源覆盖车辆',
value: formatCount(serviceSummary?.multiSourceVehicles),
detail: '同一车辆可由多个协议交叉验证'
},
{
section: '车辆服务',
item: '单源车辆',
value: formatCount(serviceSummary?.singleSourceVehicles),
detail: '仅有一个协议来源,需要持续补齐'
},
{
section: '车辆服务',
item: '暂无来源车辆',
value: formatCount(serviceSummary?.noDataVehicles),
detail: '车辆档案存在但暂无实时来源'
},
{
section: '实时监控',
item: '有效定位',
value: commandLocatedCount.toLocaleString(),
detail: `当前预览 ${locations.length.toLocaleString()} 条车辆实时数据`
},
{
section: '实时监控',
item: '来源异常',
value: commandDegradedCount.toLocaleString(),
detail: '当前预览中离线或来源不完整车辆'
},
{
section: '历史数据',
item: '今日帧量',
value: formatCount(summary?.frameToday),
detail: `Kafka Lag ${formatLag(summary?.kafkaLag)}`
},
{
section: '告警事件',
item: '告警车辆',
value: formatCount(summary?.issueVehicles),
detail: highPriorityIssue ? `${qualityIssueLabel(highPriorityIssue.issueType)} / ${priorityIssueVehicleLabel(highPriorityIssue)}` : '暂无最高优先级告警'
},
{
section: '链路健康',
item: '异常链路',
value: unhealthyLinks.length.toLocaleString(),
detail: unhealthyLinks.length > 0 ? unhealthyLinks.map((item) => `${item.name}=${item.status}`).join('') : '正常'
},
{
section: '地图能力',
item: '高德地图',
value: amapConfigured ? '已配置' : '待配置',
detail: '用于实时监控和轨迹回放地图底座'
},
{
section: '优先动作',
item: priorityAction?.label ?? '暂无待办',
value: priorityAction ? priorityAction.count.toLocaleString() : '0',
detail: priorityAction?.detail ?? '当前没有必须立即处理的车辆服务事项'
}
];
(serviceSummary?.protocols ?? summary?.protocols ?? []).forEach((item) => {
rows.push({
section: '协议来源',
item: item.protocol,
value: `${item.online.toLocaleString()} / ${item.total.toLocaleString()}`,
detail: `在线率 ${formatProtocolRate(item)}`
});
});
(serviceSummary?.serviceStatuses ?? summary?.serviceStatuses ?? []).forEach((item) => {
rows.push({
section: '服务状态',
item: item.title || serviceStatusTitle[item.status] || item.status,
value: item.count.toLocaleString(),
detail: serviceStatusTitle[item.status] || item.status
});
});
(summary?.linkHealth ?? []).forEach((item) => {
rows.push({
section: '链路健康',
item: item.name,
value: item.status,
detail: item.detail ?? ''
});
});
return rows;
};
const exportDashboardSnapshot = () => {
const rows = buildDashboardSnapshotRows();
if (rows.length === 0) {
Toast.warning('当前没有可导出的驾驶舱摘要');
return;
}
downloadCsv('dashboard-snapshot.csv', buildCsv(dashboardSnapshotColumns, rows));
Toast.success(`已导出 ${rows.length.toLocaleString()} 条驾驶舱摘要`);
};
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>
<Button size="small" theme="light" type="primary" onClick={() => onOpenVehicles({ online: 'online' })}>线</Button>
<Tag color="green">{formatCount(serviceSummary?.multiSourceVehicles)} </Tag>
<Button size="small" theme="light" type="primary" onClick={() => onOpenVehicles({ coverage: 'multi' })}></Button>
<Tag color={(summary?.issueVehicles ?? 0) > 0 ? 'orange' : 'green'}>
{formatCount(summary?.issueVehicles)}
</Tag>
<Button size="small" theme="light" type={(summary?.issueVehicles ?? 0) > 0 ? 'warning' : 'tertiary'} onClick={() => onOpenQuality()}></Button>
<Tag color={(summary?.kafkaLag ?? 0) > 0 ? 'orange' : 'green'}>Kafka Lag {formatLag(summary?.kafkaLag)}</Tag>
<Button size="small" theme="solid" type="primary" onClick={copyOperationsHandoff}></Button>
<Button size="small" theme="light" type="primary" onClick={exportDashboardSnapshot}> CSV</Button>
</Space>
</Card>
<Card bordered title="车辆服务作业台" style={{ marginBottom: 16 }}>
<div className="vp-workbench-grid">
{operationWorkbench.map((item) => (
<div key={item.title} className="vp-workbench-item">
<div className="vp-workbench-head">
<Tag color={item.color}>{item.title}</Tag>
<Typography.Text type="secondary">{item.meta}</Typography.Text>
</div>
<div className="vp-workbench-value">{item.value}</div>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
<Space wrap>
{item.actions.map((action) => (
<Button
key={action.label}
size="small"
theme="light"
type="primary"
aria-label={`作业台 ${item.title} ${action.label}`}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</Space>
</div>
))}
</div>
</Card>
<Card bordered title="一车一服务模型" style={{ marginBottom: 16 }}>
<div className="vp-service-model-grid">
{vehicleServicePrinciples.map((item) => (
<div key={item.title} className="vp-service-model-item">
<Tag color="blue">{item.title}</Tag>
<div className="vp-service-model-value">{item.value}</div>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
</div>
))}
</div>
</Card>
<Card
bordered
title={<Space><span></span><Button size="small" onClick={copyScenarioBlueprint}></Button></Space>}
style={{ marginBottom: 16 }}
>
<div className="vp-scenario-grid">
{scenarioNavigation.map((item) => (
<div key={item.title} className="vp-scenario-item">
<div className="vp-scenario-main">
<Typography.Title heading={6} style={{ margin: 0 }}>{item.title}</Typography.Title>
<Typography.Text type="secondary">{item.objective}</Typography.Text>
</div>
<div className="vp-scenario-evidence">{item.evidence}</div>
<Tag color="blue">{item.sla}</Tag>
<Space wrap>
<Button size="small" theme="solid" type="primary" aria-label={`场景导航 ${item.title} ${item.primaryAction}`} onClick={item.onPrimary}>
{item.primaryAction}
</Button>
<Button size="small" theme="light" aria-label={`场景导航 ${item.title} ${item.secondaryAction}`} onClick={item.onSecondary}>
{item.secondaryAction}
</Button>
</Space>
</div>
))}
</div>
</Card>
<Card bordered title="全链路值班闭环" style={{ marginBottom: 16 }}>
<div className="vp-operation-flow">
{workflowSteps.map((item, index) => (
<div key={item.title} className="vp-operation-step">
<div className="vp-operation-index">{index + 1}</div>
<div className="vp-operation-content">
<Space spacing={8} align="center">
<Typography.Title heading={6} style={{ margin: 0 }}>{item.title}</Typography.Title>
<Tag color={item.color}>{item.value}</Tag>
</Space>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
<Button
size="small"
theme="light"
type="primary"
aria-label={`闭环入口 ${item.title}`}
onClick={item.onClick}
>
{item.action}
</Button>
</div>
</div>
))}
</div>
</Card>
{highPriorityIssue ? (
<Card bordered title="最高优先级告警" style={{ marginBottom: 16 }}>
<div className="vp-priority-alert">
<div className="vp-priority-alert-main">
<Space wrap>
<Tag color={highPriorityIssue.severity === 'error' ? 'red' : 'orange'}>
{highPriorityIssue.severity === 'error' ? 'P0' : 'P1'}
</Tag>
<Tag color="orange">{qualityIssueLabel(highPriorityIssue.issueType)}</Tag>
<Tag color="blue">{qualityProtocolLabel(highPriorityIssue.protocol)}</Tag>
<Tag color="grey">{highPriorityIssue.lastSeen || '-'}</Tag>
</Space>
<Typography.Title heading={6} style={{ margin: '10px 0 4px' }}>
{priorityIssueVehicleLabel(highPriorityIssue)}
</Typography.Title>
<Typography.Text type="secondary">{highPriorityIssue.detail || '暂无告警详情'}</Typography.Text>
</div>
<Space wrap>
<Button
size="small"
theme="solid"
type="primary"
disabled={!highPriorityLookup?.key}
onClick={() => highPriorityLookup?.key && onOpenVehicle(highPriorityLookup.key, highPriorityIssue.protocol)}
>
</Button>
<Button
size="small"
theme="light"
aria-label="首页告警轨迹证据"
disabled={!highPriorityEvidenceFilters?.keyword}
onClick={() => highPriorityEvidenceFilters && onOpenHistory(highPriorityEvidenceFilters)}
>
</Button>
<Button
size="small"
theme="light"
disabled={!highPriorityEvidenceFilters?.keyword}
onClick={() => highPriorityEvidenceFilters && onOpenHistory({ ...highPriorityEvidenceFilters, tab: 'raw', includeFields: 'true' })}
>
RAW证据
</Button>
<Button
size="small"
theme="light"
aria-label="首页复制告警通知"
onClick={() => copyText(priorityIssueNotificationText(highPriorityIssue), '告警通知')}
>
</Button>
<Button size="small" theme="light" type="warning" onClick={() => onOpenQuality({ issueType: highPriorityIssue.issueType })}>
</Button>
</Space>
</div>
</Card>
) : null}
<Card bordered title="车辆服务能力矩阵" style={{ marginBottom: 16 }}>
<div className="vp-capability-grid">
{capabilities.map((item) => (
<div key={item.title} className="vp-capability-item">
<div>
<Typography.Title heading={6} style={{ margin: 0 }}>{item.title}</Typography.Title>
<Typography.Text type="secondary">{item.description}</Typography.Text>
</div>
<Tag className="vp-capability-status" color={item.color}>{item.status}</Tag>
<Button size="small" theme="light" type="primary" aria-label={`能力入口 ${item.title}`} onClick={item.onClick}>
{item.action}
</Button>
</div>
))}
</div>
</Card>
<Card
bordered
title="实时态势地图"
style={{ marginBottom: 16 }}
>
<div className="vp-monitor-layout">
<div className="vp-monitor-map">
<div className="vp-monitor-map-header">
<Space wrap>
<Tag color={amapConfigured ? 'green' : 'orange'}>
{amapConfigured ? '高德地图配置就绪' : '高德地图待配置'}
</Tag>
<Tag color="green">线 {commandOnlineCount.toLocaleString()} / {locations.length.toLocaleString()}</Tag>
<Tag color="blue"> {commandLocatedCount.toLocaleString()}</Tag>
<Tag color={commandDegradedCount > 0 ? 'orange' : 'green'}>/线 {commandDegradedCount.toLocaleString()}</Tag>
</Space>
</div>
<VehicleMap
points={commandMapPoints}
maxFallbackPoints={80}
fallbackLabel="高德地图未配置,显示车辆态势坐标预览"
/>
</div>
<div className="vp-monitor-side">
<div className="vp-monitor-metric">
<Tag color="green"></Tag>
<div className="vp-monitor-metric-value">{commandOnlineCount.toLocaleString()}</div>
<Typography.Text type="secondary">线</Typography.Text>
</div>
<div className="vp-monitor-metric">
<Tag color={commandDegradedCount > 0 ? 'orange' : 'green'}></Tag>
<div className="vp-monitor-metric-value">{commandDegradedCount.toLocaleString()}</div>
<Typography.Text type="secondary">线</Typography.Text>
</div>
<Space vertical align="start">
<Button theme="solid" type="primary" onClick={() => onOpenMap({ online: 'online' })}></Button>
<Button
disabled={!highPriorityIssue?.issueType}
theme="light"
type="warning"
onClick={() => onOpenQuality(highPriorityIssue?.issueType ? { issueType: highPriorityIssue.issueType } : {})}
>
</Button>
<Button theme="light" onClick={() => onOpenVehicles({ serviceStatus: 'degraded' })}></Button>
</Space>
</div>
</div>
</Card>
{serviceActionQueue.length > 0 ? (
<Card bordered title="车辆服务处置队列" style={{ marginBottom: 16 }}>
<div className="vp-action-grid">
{serviceActionQueue.map((item) => (
<div key={`${item.label}-${item.count}`} className="vp-action-item">
<div>
<Tag color="orange">{item.label} {item.count.toLocaleString()}</Tag>
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8 }}>{item.detail}</Typography.Text>
</div>
<Button size="small" theme="light" type="warning" onClick={() => onOpenVehicles(item.filters)}>
{item.label} {item.count.toLocaleString()}
</Button>
</div>
))}
</div>
</Card>
) : null}
<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>
);
}