1327 lines
64 KiB
TypeScript
1327 lines
64 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, 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;
|
||
};
|
||
|
||
type FocusVehicleService = {
|
||
label: string;
|
||
lookupKey: string;
|
||
protocol?: string;
|
||
reason: string;
|
||
statusLabel: string;
|
||
statusColor: 'green' | 'orange' | 'red' | 'grey' | 'blue';
|
||
realtimeEvidence: string;
|
||
historyEvidence: string;
|
||
alertEvidence: string;
|
||
statisticEvidence: string;
|
||
issueType?: 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 focusVehicleFromIssue(row: QualityIssueRow): FocusVehicleService {
|
||
const lookup = qualityIssueVehicleLookup(row);
|
||
const filters = priorityIssueEvidenceFilters(row);
|
||
const label = priorityIssueVehicleLabel(row);
|
||
return {
|
||
label,
|
||
lookupKey: lookup.key,
|
||
protocol: row.protocol,
|
||
reason: `${row.severity === 'error' ? 'P0' : 'P1'} ${qualityIssueLabel(row.issueType)}`,
|
||
statusLabel: row.severity === 'error' ? '优先处置' : '持续跟踪',
|
||
statusColor: row.severity === 'error' ? 'red' : 'orange',
|
||
realtimeEvidence: `来源 ${qualityProtocolLabel(row.protocol)} / 最后 ${row.lastSeen || '-'}`,
|
||
historyEvidence: filters.dateFrom ? `${filters.dateFrom} 至 ${filters.dateTo}` : '待确认时间窗',
|
||
alertEvidence: row.detail || qualityIssueLabel(row.issueType),
|
||
statisticEvidence: '统计查询需回溯车辆口径',
|
||
issueType: row.issueType
|
||
};
|
||
}
|
||
|
||
function focusVehicleFromRealtime(row: VehicleRealtimeRow): FocusVehicleService {
|
||
const status = rowServiceStatus(row);
|
||
const label = [row.plate?.trim(), row.vin?.trim()].filter(Boolean).join(' / ') || row.phone || '-';
|
||
const location = hasValidCoordinate(row) ? `${row.longitude.toFixed(6)}, ${row.latitude.toFixed(6)}` : '无有效坐标';
|
||
return {
|
||
label,
|
||
lookupKey: row.vin || row.phone || row.plate,
|
||
protocol: row.primaryProtocol,
|
||
reason: row.online ? '最新在线车辆' : '最新车辆',
|
||
statusLabel: status.label,
|
||
statusColor: status.color,
|
||
realtimeEvidence: `${row.onlineSourceCount}/${row.sourceCount} 来源在线 / ${row.lastSeen || '-'}`,
|
||
historyEvidence: location,
|
||
alertEvidence: row.onlineSourceCount < row.sourceCount ? '来源不完整,建议检查缺失协议' : '暂无高优先级告警',
|
||
statisticEvidence: `速度 ${formatCount(row.speedKmh)} km/h / 里程 ${formatCount(row.totalMileageKm)} km`
|
||
};
|
||
}
|
||
|
||
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 focusVehicle = highPriorityIssue ? focusVehicleFromIssue(highPriorityIssue) : locations[0] ? focusVehicleFromRealtime(locations[0]) : 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 copyFocusVehicleService = () => {
|
||
if (!focusVehicle) {
|
||
Toast.warning('当前没有重点车辆服务可复制');
|
||
return;
|
||
}
|
||
const commonFilters = { keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol ?? '' };
|
||
const lines = [
|
||
'【重点车辆服务处置卡】',
|
||
`车辆:${focusVehicle.label}`,
|
||
`原因:${focusVehicle.reason}`,
|
||
`服务状态:${focusVehicle.statusLabel}`,
|
||
`实时证据:${focusVehicle.realtimeEvidence}`,
|
||
`轨迹证据:${focusVehicle.historyEvidence}`,
|
||
`告警证据:${focusVehicle.alertEvidence}`,
|
||
`统计证据:${focusVehicle.statisticEvidence}`,
|
||
`车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol }))}`,
|
||
`实时监控:${appURL(buildAppHash({ page: 'realtime', keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol }))}`,
|
||
`轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol }))}`,
|
||
`RAW证据:${appURL(buildAppHash({ page: 'history-query', keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol, filters: { ...commonFilters, tab: 'raw', includeFields: 'true' } }))}`,
|
||
`统计查询:${appURL(buildAppHash({ page: 'mileage', keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol }))}`,
|
||
`告警事件:${appURL(buildAppHash({ page: 'alert-events', keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol, filters: focusVehicle.issueType ? { issueType: focusVehicle.issueType } : {} }))}`
|
||
];
|
||
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>
|
||
{focusVehicle ? (
|
||
<Card
|
||
bordered
|
||
title={<Space><span>重点车辆服务</span><Tag color={focusVehicle.statusColor}>{focusVehicle.statusLabel}</Tag><Button size="small" onClick={copyFocusVehicleService}>复制处置卡</Button></Space>}
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
<div className="vp-focus-service">
|
||
<div className="vp-focus-service-main">
|
||
<Tag color="blue">{focusVehicle.reason}</Tag>
|
||
<Typography.Title heading={5} style={{ margin: '8px 0 4px' }}>{focusVehicle.label}</Typography.Title>
|
||
<Typography.Text type="secondary">把协议来源作为证据,把实时、轨迹、RAW、统计和告警集中到同一辆车处理。</Typography.Text>
|
||
</div>
|
||
<div className="vp-focus-service-evidence">
|
||
{[
|
||
{ label: '实时证据', value: focusVehicle.realtimeEvidence },
|
||
{ label: '轨迹证据', value: focusVehicle.historyEvidence },
|
||
{ label: '告警证据', value: focusVehicle.alertEvidence },
|
||
{ label: '统计证据', value: focusVehicle.statisticEvidence }
|
||
].map((item) => (
|
||
<div key={item.label} className="vp-focus-service-evidence-item">
|
||
<span>{item.label}</span>
|
||
<strong>{item.value}</strong>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<Space wrap>
|
||
<Button size="small" theme="solid" type="primary" aria-label="重点车辆 车辆服务" disabled={!focusVehicle.lookupKey} onClick={() => focusVehicle.lookupKey && onOpenVehicle(focusVehicle.lookupKey, focusVehicle.protocol)}>车辆服务</Button>
|
||
<Button size="small" theme="light" aria-label="重点车辆 实时监控" disabled={!focusVehicle.lookupKey} onClick={() => onOpenRealtime({ keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol ?? '' })}>实时监控</Button>
|
||
<Button size="small" theme="light" aria-label="重点车辆 轨迹回放" disabled={!focusVehicle.lookupKey} onClick={() => onOpenHistory({ keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol ?? '' })}>轨迹回放</Button>
|
||
<Button size="small" theme="light" aria-label="重点车辆 RAW证据" disabled={!focusVehicle.lookupKey} onClick={() => onOpenHistory({ keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol ?? '', tab: 'raw', includeFields: 'true' })}>RAW证据</Button>
|
||
<Button size="small" theme="light" aria-label="重点车辆 统计查询" disabled={!focusVehicle.lookupKey} onClick={() => onOpenMileage({ keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol ?? '' })}>统计查询</Button>
|
||
<Button size="small" theme="light" type="warning" aria-label="重点车辆 告警事件" onClick={() => onOpenQuality({ keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol ?? '', ...(focusVehicle.issueType ? { issueType: focusVehicle.issueType } : {}) })}>告警事件</Button>
|
||
</Space>
|
||
</div>
|
||
</Card>
|
||
) : null}
|
||
<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>
|
||
);
|
||
}
|