Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx

1929 lines
95 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 { Banner, Button, Card, Descriptions, Form, Select, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui';
import { IconCopy, IconDownload, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api/client';
import type { QualityIssueRow, RealtimeLocationRow, VehicleDetail as VehicleDetailData, VehicleSourceStatus } 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 { buildAppHash } from '../domain/appRoute';
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
import { qualityIssueLabel } from '../domain/qualityIssue';
import { isLikelyVIN } from '../domain/vehicleLookup';
import { summarizeVehicleService } from '../domain/vehicleService';
type VehicleQuery = {
keyword: string;
protocol?: string;
};
const sourceCapabilities: Array<{ key: keyof Pick<VehicleSourceStatus, 'hasRealtime' | 'hasHistory' | 'hasRaw' | 'hasMileage'>; label: string }> = [
{ key: 'hasRealtime', label: '实时' },
{ key: 'hasHistory', label: '轨迹' },
{ key: 'hasRaw', label: '历史数据' },
{ key: 'hasMileage', label: '里程' }
];
const coverageStatusText: Record<string, string> = {
online: '全部在线',
partial: '部分在线',
offline: '全部离线',
no_data: '暂无来源'
};
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function formatCompactNumber(value?: number, suffix = '') {
if (!isFiniteNumber(value)) return '-';
return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}${suffix}`;
}
function formatLocation(row?: RealtimeLocationRow) {
if (!row || !isFiniteNumber(row.longitude) || !isFiniteNumber(row.latitude)) return '-';
return `${row.longitude}, ${row.latitude}`;
}
function parseTimeMs(value?: string) {
if (!value) return undefined;
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : undefined;
}
function formatSeconds(value?: number) {
if (!isFiniteNumber(value)) return '-';
return `${Math.round(value)}`;
}
function customerStatusTitle(title?: string) {
if (!title) return '';
return title === '来源不完整' ? '数据通道不完整' : title;
}
function customerScopeText(protocol?: string) {
return protocol ? `单一数据通道:${protocol}` : '全部数据通道聚合';
}
function sourceServiceRole(source: VehicleSourceStatus, primaryProtocol?: string) {
if (source.protocol === primaryProtocol) return { label: '主数据通道', color: 'blue' as const };
if (source.online) return { label: '在线数据通道', color: 'green' as const };
if (!source.lastSeen && !source.hasRealtime && !source.hasHistory && !source.hasRaw && !source.hasMileage) {
return { label: '暂无来源', color: 'grey' as const };
}
return { label: '离线证据', color: 'grey' as const };
}
function sourceFusionRow(source: VehicleSourceStatus, realtime: RealtimeLocationRow | undefined, primaryProtocol?: string): SourceFusionRow {
const hasLocation = !!realtime && isFiniteNumber(realtime.longitude) && isFiniteNumber(realtime.latitude) && realtime.longitude !== 0 && realtime.latitude !== 0;
const hasMileage = isFiniteNumber(realtime?.totalMileageKm) || source.hasMileage;
const contribution = [
source.protocol === primaryProtocol ? '主实时通道' : '',
source.online ? '在线心跳' : '',
source.hasRealtime || realtime ? '实时字段' : '',
hasLocation ? '定位数据' : '',
source.hasHistory ? '轨迹回放' : '',
source.hasRaw ? '历史数据' : '',
hasMileage ? '统计口径' : ''
].filter(Boolean);
return {
source,
realtime,
role: sourceServiceRole(source, primaryProtocol),
freshness: source.online ? `在线 / ${source.lastSeen || realtime?.lastSeen || '-'}` : source.lastSeen ? `离线 / ${source.lastSeen}` : '未接入',
realtimeText: source.hasRealtime || realtime ? `速度 ${formatCompactNumber(realtime?.speedKmh, ' km/h')} / SOC ${formatCompactNumber(realtime?.socPercent, '%')}` : '无实时字段',
locationText: hasLocation ? `${realtime?.longitude}, ${realtime?.latitude}` : '无有效坐标',
rawText: source.hasRaw ? '可查历史数据' : '无历史数据',
mileageText: hasMileage ? formatCompactNumber(realtime?.totalMileageKm, ' km') : '无里程数据',
contribution: contribution.length > 0 ? contribution : ['待补来源']
};
}
type VehicleServiceAction = {
key: string;
label: string;
description: string;
color: 'green' | 'orange' | 'red' | 'blue';
onClick: () => void;
};
type SourceFusionRow = {
source: VehicleSourceStatus;
realtime?: RealtimeLocationRow;
role: { label: string; color: 'green' | 'orange' | 'red' | 'blue' | 'grey' };
freshness: string;
realtimeText: string;
locationText: string;
rawText: string;
mileageText: string;
contribution: string[];
};
type VehicleReportRow = {
section: string;
item: string;
value: string;
};
const vehicleReportColumns: CsvColumn<VehicleReportRow>[] = [
{ title: '模块', value: (row) => row.section },
{ title: '项目', value: (row) => row.item },
{ title: '值', value: (row) => row.value }
];
function reportText(value: unknown) {
if (value == null || value === '') return '-';
if (Array.isArray(value)) return value.length > 0 ? value.join('|') : '-';
return String(value);
}
function vehicleReportFileName(vin: string, protocol?: string) {
return `vehicle-service-${vin || 'unknown'}-${protocol || 'all-source'}.csv`;
}
function qualityIssuePriority(row: QualityIssueRow) {
if (row.severity === 'error') return 0;
if (row.severity === 'warning') return 1;
return 2;
}
function issueEvidenceDate(value?: string) {
const match = value?.match(/\d{4}-\d{2}-\d{2}/);
return match?.[0];
}
function nextDate(value: string) {
const [year, month, day] = value.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day + 1));
return date.toISOString().slice(0, 10);
}
function qualityIssueVehicleLabel(row: QualityIssueRow, fallbackVIN: string) {
const vin = row.vin?.trim() || fallbackVIN;
return [row.plate?.trim(), vin].filter(Boolean).join(' / ') || row.phone || row.sourceEndpoint || '-';
}
async function copyText(value: string, label: string) {
try {
await navigator.clipboard.writeText(value);
Toast.success(`已复制${label}`);
} catch {
Toast.error(`复制${label}失败`);
}
}
export function VehicleDetail({
vin,
protocol,
platformRelease,
onOpenRealtime,
onOpenHistory,
onOpenRaw,
onOpenMileage,
onOpenVehicles,
onOpenQuality,
onOpenHistoryEvidence,
onOpenRawEvidence,
onQueryChange
}: {
vin: string;
protocol?: string;
platformRelease?: string;
onOpenRealtime: (vin: string, protocol?: string) => void;
onOpenHistory: (vin: string, protocol?: string) => void;
onOpenRaw: (vin: string, protocol?: string) => void;
onOpenMileage: (vin: string, protocol?: string) => void;
onOpenVehicles?: (filters?: Record<string, string>) => void;
onOpenQuality?: (filters?: Record<string, string>) => void;
onOpenHistoryEvidence?: (filters?: Record<string, string>) => void;
onOpenRawEvidence?: (filters?: Record<string, string>) => void;
onQueryChange?: (keyword: string, protocol?: string) => void;
}) {
const [query, setQuery] = useState<VehicleQuery>({ keyword: vin, protocol });
const [detail, setDetail] = useState<VehicleDetailData | null>(null);
const [loading, setLoading] = useState(false);
const requestSeq = useRef(0);
const load = (nextQuery = query) => {
const keyword = nextQuery.keyword.trim();
if (!keyword) {
Toast.warning('请输入 VIN / 车牌 / 手机号');
return;
}
setLoading(true);
const params = new URLSearchParams({ keyword });
if (nextQuery.protocol?.trim()) {
params.set('protocol', nextQuery.protocol.trim());
}
const seq = requestSeq.current + 1;
requestSeq.current = seq;
api.vehicleDetail(params)
.then((nextDetail) => {
if (requestSeq.current === seq) {
setDetail(nextDetail);
}
})
.catch((error: Error) => {
if (requestSeq.current === seq) {
Toast.error(error.message);
}
})
.finally(() => {
if (requestSeq.current === seq) {
setLoading(false);
}
});
};
useEffect(() => {
const nextQuery = { keyword: vin, protocol };
setQuery(nextQuery);
load(nextQuery);
}, [vin, protocol]);
const identity = detail?.identity;
const summary = detail?.realtimeSummary;
const latest = detail?.realtime?.[0];
const resolution = detail?.resolution;
const overview = detail?.serviceOverview;
const resolvedVIN = resolution?.vin || detail?.vin || summary?.vin || identity?.vin || latest?.vin || query.keyword;
const hasResolvedVIN = resolution?.resolved ?? detail?.lookupResolved ?? isLikelyVIN(resolvedVIN);
const displayVIN = hasResolvedVIN ? resolvedVIN : '-';
const displayLookupKey = resolution?.lookupKey || detail?.lookupKey || query.keyword;
const protocols = useMemo(() => resolution?.protocols?.length ? resolution.protocols : detail?.sources ?? [], [detail?.sources, resolution?.protocols]);
const latestRaw = detail?.raw?.items?.[0];
const qualityCount = detail?.quality?.total ?? 0;
const priorityQualityIssue = useMemo(() => {
const items = detail?.quality?.items ?? [];
return [...items].sort((a, b) => {
const severityDelta = qualityIssuePriority(a) - qualityIssuePriority(b);
if (severityDelta !== 0) return severityDelta;
return (Date.parse(b.lastSeen || '') || 0) - (Date.parse(a.lastSeen || '') || 0);
})[0];
}, [detail?.quality?.items]);
const online = resolution?.online || summary?.online || identity?.online || false;
const lastSeen = resolution?.lastSeen || summary?.lastSeen || latest?.lastSeen || '-';
const serviceStatus = detail?.serviceStatus;
const realtimeRows = detail?.realtime ?? [];
const locatedRealtimeRows = useMemo(() => realtimeRows.filter((row) => (
isFiniteNumber(row.longitude) &&
isFiniteNumber(row.latitude) &&
row.longitude !== 0 &&
row.latitude !== 0
)), [realtimeRows]);
const sourceOnlineByProtocol = useMemo(() => new Map((detail?.sourceStatus ?? []).map((source) => [source.protocol, source.online])), [detail?.sourceStatus]);
const vehicleMapPoints = useMemo<VehicleMapPoint[]>(() => locatedRealtimeRows.map((row) => ({
id: `${row.protocol || 'source'}-${row.lastSeen || row.vin}`,
label: row.protocol || row.vin,
longitude: row.longitude,
latitude: row.latitude,
online: sourceOnlineByProtocol.get(row.protocol) ?? true,
title: `${row.protocol || '来源'} / ${formatCompactNumber(row.speedKmh, ' km/h')}`
})), [locatedRealtimeRows, sourceOnlineByProtocol]);
const latestRealtimeByProtocol = useMemo(() => {
const next = new Map<string, RealtimeLocationRow>();
for (const row of realtimeRows) {
if (!row.protocol || next.has(row.protocol)) continue;
next.set(row.protocol, row);
}
return next;
}, [realtimeRows]);
const sourceFusionRows = useMemo(() => (
(detail?.sourceStatus ?? []).map((source) => sourceFusionRow(source, latestRealtimeByProtocol.get(source.protocol), summary?.primaryProtocol))
), [detail?.sourceStatus, latestRealtimeByProtocol, summary?.primaryProtocol]);
const realtimeSources = new Set(realtimeRows.map((row) => row.protocol).filter(Boolean));
const sourceStatusRows = detail?.sourceStatus ?? [];
const sourceCount = Math.max(sourceStatusRows.length, realtimeSources.size, summary?.sourceCount ?? 0, protocols.length);
const onlineSourceCount = sourceStatusRows.length
? sourceStatusRows.filter((source) => source.online).length
: summary?.onlineSourceCount ?? 0;
const locatedSourceCount = realtimeRows.filter((row) => isFiniteNumber(row.longitude) && isFiniteNumber(row.latitude)).length;
const mileageValues = realtimeRows.map((row) => row.totalMileageKm).filter(isFiniteNumber);
const mileageDelta = mileageValues.length > 1 ? Math.max(...mileageValues) - Math.min(...mileageValues) : undefined;
const timeValues = [
...realtimeRows.map((row) => parseTimeMs(row.lastSeen)),
...(detail?.sourceStatus ?? []).map((source) => parseTimeMs(source.lastSeen))
].filter(isFiniteNumber);
const sourceTimeDeltaSeconds = timeValues.length > 1 ? (Math.max(...timeValues) - Math.min(...timeValues)) / 1000 : undefined;
const consistency = detail?.sourceConsistency;
const consistencySourceCount = consistency?.sourceCount ?? sourceCount;
const consistencyOnlineSourceCount = consistency?.onlineSourceCount ?? onlineSourceCount;
const consistencyLocatedSourceCount = consistency?.locatedSourceCount ?? locatedSourceCount;
const consistencyMileageDelta = consistency?.mileageDeltaKm ?? mileageDelta;
const consistencyTimeDeltaSeconds = consistency?.sourceTimeDeltaSeconds ?? sourceTimeDeltaSeconds;
const consistencyTitle = consistency?.title ?? '-';
const consistencyDetail = consistency?.detail ?? '-';
const missingProtocols = consistency?.missingProtocols ?? [];
const missingProtocolText = missingProtocols.length > 0 ? (
<Space spacing={4}>
{missingProtocols.map((item) => (
<Button
key={item}
size="small"
theme="light"
type="warning"
onClick={() => onOpenVehicles?.({ serviceStatus: 'degraded', missingProtocol: item })}
>
{item}
</Button>
))}
</Space>
) : '-';
const evidenceSourceCount = overview?.sourceCount ?? consistencySourceCount;
const evidenceOnlineSourceCount = overview?.onlineSourceCount ?? consistencyOnlineSourceCount;
const evidenceLocatedSourceCount = consistencyLocatedSourceCount;
const evidenceMileageDelta = consistencyMileageDelta;
const evidenceTimeDeltaSeconds = consistencyTimeDeltaSeconds;
const activeProtocol = query.protocol?.trim() ?? '';
const scopeText = customerScopeText(activeProtocol);
const archivePlate = resolution?.plate || summary?.plate || identity?.plate || latest?.plate || '';
const archivePhone = resolution?.phone || summary?.phone || identity?.phone || '';
const archiveOEM = resolution?.oem || summary?.oem || identity?.oem || '';
const archiveFields = [hasResolvedVIN && displayVIN !== '-', archivePlate, archivePhone, archiveOEM];
const archiveCompleteness = `${archiveFields.filter(Boolean).length}/${archiveFields.length}`;
const archiveMissingLabels = [
{ value: hasResolvedVIN && displayVIN !== '-' ? displayVIN : '', label: '缺VIN' },
{ value: archivePlate, label: '缺车牌' },
{ value: archivePhone, label: '缺手机号' },
{ value: archiveOEM, label: '缺OEM' }
]
.filter((item) => !String(item.value ?? '').trim())
.map((item) => item.label);
const archiveSourceCount = Math.max(protocols.length, sourceCount);
const formKey = `${query.keyword}-${query.protocol ?? ''}`;
const switchSource = (nextProtocol = '') => {
const nextQuery = { keyword: resolvedVIN || query.keyword, protocol: nextProtocol };
setQuery(nextQuery);
onQueryChange?.(nextQuery.keyword, nextQuery.protocol);
load(nextQuery);
};
const serviceConclusion = useMemo(() => summarizeVehicleService({
resolved: hasResolvedVIN,
sourceCount,
onlineSourceCount,
qualityIssueCount: qualityCount,
missingProtocols,
mileageDeltaKm: consistencyMileageDelta
}), [consistencyMileageDelta, hasResolvedVIN, missingProtocols, onlineSourceCount, qualityCount, sourceCount]);
const qualityFiltersForCurrentVehicle = () => ({
keyword: resolvedVIN,
...(activeProtocol ? { protocol: activeProtocol } : {})
});
const qualityIssueEvidenceFilters = (row: QualityIssueRow, tab?: 'raw') => {
const dateFrom = issueEvidenceDate(row.lastSeen);
return {
keyword: row.vin?.trim() || resolvedVIN,
protocol: row.protocol || activeProtocol,
...(dateFrom ? { dateFrom, dateTo: nextDate(dateFrom) } : {}),
...(tab === 'raw' ? { tab: 'raw', includeFields: 'true' } : {})
};
};
const openQualityIssueHistoryEvidence = (row: QualityIssueRow) => {
const filters = qualityIssueEvidenceFilters(row);
if (onOpenHistoryEvidence) {
onOpenHistoryEvidence(filters);
return;
}
onOpenHistory(filters.keyword, filters.protocol);
};
const openQualityIssueRawEvidence = (row: QualityIssueRow) => {
const filters = qualityIssueEvidenceFilters(row, 'raw');
if (onOpenRawEvidence) {
onOpenRawEvidence(filters);
return;
}
onOpenRaw(filters.keyword, filters.protocol);
};
const vehicleServiceURL = (hash: string) => `${window.location.origin}${window.location.pathname}${hash}`;
const vehicleServiceHash = (page: 'detail' | 'realtime' | 'history' | 'history-query' | 'mileage', filters?: Record<string, string>) => buildAppHash({
page,
keyword: resolvedVIN,
protocol: activeProtocol,
filters
});
const copyVehicleServiceURL = () => copyText(vehicleServiceURL(window.location.hash), '服务链接');
const copyVehicleDiagnosticSummary = () => {
if (!detail) {
Toast.warning('当前没有可复制的诊断摘要');
return;
}
const vehicleName = [archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey;
const lines = [
'【车辆服务诊断摘要】',
...(platformRelease?.trim() ? [`运行版本:${platformRelease.trim()}`] : []),
`车辆:${vehicleName}`,
`查询关键词:${reportText(displayLookupKey)}`,
`当前范围:${scopeText}`,
`服务状态:${customerStatusTitle(serviceStatus?.title) || serviceConclusion.status}`,
`服务说明:${serviceStatus?.detail ?? '-'}`,
`服务判断:${serviceConclusion.status}`,
`主要风险:${serviceConclusion.risk}`,
`下一步:${serviceConclusion.nextStep}`,
`数据通道:${evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount} 在线` : '-'}`,
`定位数据:${evidenceLocatedSourceCount > 0 ? `${evidenceLocatedSourceCount} 个通道有位置` : '暂无位置'}`,
`里程差异:${formatCompactNumber(evidenceMileageDelta, ' km')}`,
`时间差异:${formatSeconds(evidenceTimeDeltaSeconds)}`,
`服务数据:实时 ${overview?.realtimeCount ?? detail.realtime.length} / 轨迹 ${overview?.historyCount ?? detail.history.total ?? 0} / 历史数据 ${overview?.rawCount ?? detail.raw.total ?? 0} / 里程 ${overview?.mileageCount ?? detail.mileage.total ?? 0} / 告警 ${overview?.qualityIssueCount ?? qualityCount}`,
`一致性:${customerStatusTitle(consistencyTitle) || consistencyTitle}`,
`一致性说明:${consistencyDetail}`,
`车辆服务:${vehicleServiceURL(vehicleServiceHash('detail'))}`,
`实时:${vehicleServiceURL(vehicleServiceHash('realtime'))}`,
`轨迹:${vehicleServiceURL(vehicleServiceHash('history'))}`,
`历史数据:${vehicleServiceURL(vehicleServiceHash('history-query', { tab: 'raw', includeFields: 'true' }))}`,
`统计查询:${vehicleServiceURL(vehicleServiceHash('mileage'))}`
];
copyText(lines.join('\n'), '诊断摘要');
};
const copySourceFusionReport = () => {
if (!detail || sourceFusionRows.length === 0) {
Toast.warning('当前没有可复制的数据通道归并矩阵');
return;
}
const vehicleName = [archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey;
const lines = [
'【单车数据通道归并矩阵】',
`车辆:${vehicleName}`,
`当前范围:${scopeText}`,
`主数据通道:${overview?.primaryProtocol || summary?.primaryProtocol || '-'}`,
`通道覆盖:${evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount} 在线` : '-'}`,
`一致性:${customerStatusTitle(consistencyTitle) || consistencyTitle}`,
'',
...sourceFusionRows.map((row, index) => [
`${index + 1}. ${row.source.protocol} / ${row.role.label}`,
` 新鲜度:${row.freshness}`,
` 贡献:${row.contribution.join('、')}`,
` 实时:${row.realtimeText}`,
` 定位:${row.locationText}`,
` 历史数据:${row.rawText}`,
` 里程:${row.mileageText}`
].join('\n')),
'',
`车辆服务:${vehicleServiceURL(vehicleServiceHash('detail'))}`,
`实时监控:${vehicleServiceURL(vehicleServiceHash('realtime'))}`,
`轨迹回放:${vehicleServiceURL(vehicleServiceHash('history'))}`,
`历史数据:${vehicleServiceURL(vehicleServiceHash('history-query', { tab: 'raw', includeFields: 'true' }))}`,
`统计查询:${vehicleServiceURL(vehicleServiceHash('mileage'))}`
];
copyText(lines.join('\n'), '数据通道归并矩阵');
};
const qualityIssueAction = (count: number) => {
if (count <= 0) {
return <Tag color="green"></Tag>;
}
return (
<Button
size="small"
theme="light"
type="warning"
onClick={() => onOpenQuality?.(qualityFiltersForCurrentVehicle())}
>
{count}
</Button>
);
};
const serviceActions = useMemo<VehicleServiceAction[]>(() => {
const actions: VehicleServiceAction[] = [];
if (!hasResolvedVIN) {
actions.push({
key: 'identity',
label: '维护身份绑定',
description: `关键词 ${displayLookupKey} 暂未解析到 VIN先补齐绑定后再看跨证据服务。`,
color: 'orange',
onClick: () => onOpenVehicles?.({ bindingStatus: 'unbound' })
});
}
for (const item of missingProtocols) {
actions.push({
key: `missing-${item}`,
label: `补齐 ${item} 证据`,
description: `${item} 当前没有形成有效数据通道,会影响这辆车的统一服务可信度。`,
color: 'orange',
onClick: () => onOpenVehicles?.({ serviceStatus: 'degraded', missingProtocol: item })
});
}
for (const source of detail?.sourceStatus ?? []) {
if (source.online) continue;
actions.push({
key: `offline-${source.protocol}`,
label: `排查 ${source.protocol} 离线`,
description: `${source.protocol} 最后上报时间:${source.lastSeen || '无上报'}`,
color: 'orange',
onClick: () => switchSource(source.protocol)
});
}
if (qualityCount > 0) {
actions.push({
key: 'quality',
label: `处理质量问题 ${qualityCount}`,
description: '质量问题会影响车辆服务的定位、里程和实时状态可信度。',
color: 'orange',
onClick: () => onOpenQuality?.(qualityFiltersForCurrentVehicle())
});
}
if (actions.length === 0 && hasResolvedVIN) {
actions.push({
key: 'healthy',
label: '查看实时服务',
description: '当前没有明确处置项,可直接查看该车统一实时状态。',
color: 'green',
onClick: () => onOpenRealtime(resolvedVIN, activeProtocol)
});
}
return actions;
}, [activeProtocol, detail?.sourceStatus, displayLookupKey, hasResolvedVIN, missingProtocols, onOpenQuality, onOpenRealtime, onOpenVehicles, qualityCount, resolvedVIN]);
const serviceAssetItems = [
{
label: '实时',
value: formatCompactNumber(overview?.realtimeCount ?? detail?.realtime?.length ?? 0),
detail: evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount} 在线` : '暂无来源',
color: evidenceOnlineSourceCount > 0 ? 'green' as const : 'orange' as const
},
{
label: '轨迹',
value: formatCompactNumber(overview?.historyCount ?? detail?.history?.total ?? 0),
detail: evidenceLocatedSourceCount > 0 ? `${evidenceLocatedSourceCount} 个位置证据` : '暂无位置',
color: (overview?.historyCount ?? detail?.history?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const
},
{
label: '历史数据',
value: formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0),
detail: latestRaw?.frameType || '解析字段',
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const
},
{
label: '里程',
value: formatCompactNumber(overview?.mileageCount ?? detail?.mileage?.total ?? 0),
detail: formatCompactNumber(evidenceMileageDelta, ' km 差异'),
color: isFiniteNumber(evidenceMileageDelta) && Math.abs(evidenceMileageDelta) > 1 ? 'orange' as const : 'green' as const
},
{
label: '告警',
value: qualityCount.toLocaleString(),
detail: priorityQualityIssue ? qualityIssueLabel(priorityQualityIssue.issueType) : '暂无告警',
color: qualityCount > 0 ? 'orange' as const : 'green' as const
}
];
const unifiedVehicleServiceItems = [
{
label: '车辆身份',
value: hasResolvedVIN ? displayVIN : displayLookupKey,
detail: archivePlate || archivePhone || archiveOEM || '待补身份',
color: hasResolvedVIN ? 'green' as const : 'orange' as const
},
{
label: '服务状态',
value: customerStatusTitle(serviceStatus?.title) || serviceConclusion.status,
detail: serviceStatus?.detail ?? serviceConclusion.risk,
color: serviceConclusion.color
},
{
label: '实时主数据通道',
value: overview?.primaryProtocol || summary?.primaryProtocol || '-',
detail: evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount} 在线` : '暂无来源',
color: evidenceOnlineSourceCount > 0 ? 'green' as const : 'orange' as const
},
{
label: '业务可用性',
value: online ? '在线可用' : hasResolvedVIN ? '离线待查' : '待绑定',
detail: lastSeen === '-' ? '无最后上报' : `最后上报 ${lastSeen}`,
color: online ? 'green' as const : 'orange' as const
}
];
const copyUnifiedVehicleService = () => {
if (!detail) {
Toast.warning('当前没有可复制的统一车辆服务摘要');
return;
}
const vehicleName = [archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey;
const lines = [
'【统一车辆服务摘要】',
`车辆:${vehicleName}`,
`当前范围:${scopeText}`,
`车辆身份:${hasResolvedVIN ? displayVIN : '待绑定'}`,
`服务状态:${customerStatusTitle(serviceStatus?.title) || serviceConclusion.status}`,
`服务说明:${serviceStatus?.detail ?? serviceConclusion.risk}`,
`实时主数据通道:${overview?.primaryProtocol || summary?.primaryProtocol || '-'}`,
`通道在线:${evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount}` : '-'}`,
`业务可用性:${online ? '在线可用' : hasResolvedVIN ? '离线待查' : '待绑定'}`,
`服务数据:实时 ${overview?.realtimeCount ?? detail.realtime.length} / 轨迹 ${overview?.historyCount ?? detail.history.total ?? 0} / 历史数据 ${overview?.rawCount ?? detail.raw.total ?? 0} / 里程 ${overview?.mileageCount ?? detail.mileage.total ?? 0} / 告警 ${overview?.qualityIssueCount ?? qualityCount}`,
`下一步:${serviceActions[0]?.label ?? serviceConclusion.nextStep}`,
`车辆服务:${vehicleServiceURL(vehicleServiceHash('detail'))}`,
`实时监控:${vehicleServiceURL(vehicleServiceHash('realtime'))}`,
`轨迹回放:${vehicleServiceURL(vehicleServiceHash('history'))}`,
`历史数据:${vehicleServiceURL(vehicleServiceHash('history-query', { tab: 'raw', includeFields: 'true' }))}`,
`统计查询:${vehicleServiceURL(vehicleServiceHash('mileage'))}`
];
copyText(lines.join('\n'), '统一车辆服务摘要');
};
const copyVehicleServiceDossier = () => {
if (!detail) {
Toast.warning('当前没有可复制的车辆服务档案');
return;
}
const vehicleName = [archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey;
const lines = [
'【车辆服务档案】',
`车辆:${vehicleName}`,
`查询关键词:${displayLookupKey}`,
`当前范围:${scopeText}`,
`档案完整度:${archiveCompleteness}${archiveMissingLabels.length > 0 ? `${archiveMissingLabels.join('、')}` : ''}`,
`主数据通道:${overview?.primaryProtocol || summary?.primaryProtocol || '-'}`,
`通道在线:${evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount}` : '-'}`,
`服务状态:${customerStatusTitle(serviceStatus?.title) || serviceConclusion.status}`,
`服务说明:${serviceStatus?.detail ?? serviceConclusion.risk}`,
`资产概览:${serviceAssetItems.map((item) => `${item.label} ${item.value}`).join(' / ')}`,
`一致性:${customerStatusTitle(consistencyTitle) || consistencyTitle}`,
`下一步:${serviceActions[0]?.label ?? serviceConclusion.nextStep}`,
`车辆服务:${vehicleServiceURL(vehicleServiceHash('detail'))}`,
`实时监控:${vehicleServiceURL(vehicleServiceHash('realtime'))}`,
`轨迹回放:${vehicleServiceURL(vehicleServiceHash('history'))}`,
`历史数据:${vehicleServiceURL(vehicleServiceHash('history-query', { tab: 'raw', includeFields: 'true' }))}`,
`统计查询:${vehicleServiceURL(vehicleServiceHash('mileage'))}`
];
copyText(lines.join('\n'), '车辆服务档案');
};
const copyVehicleOperationsSummary = () => {
if (!detail) {
Toast.warning('当前没有可复制的运营摘要');
return;
}
const vehicleName = [archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey;
const actionLines = serviceActions.map((item, index) => `${index + 1}. ${item.label}${item.description}`);
const lines = [
'【车辆服务运营摘要】',
`车辆:${vehicleName}`,
`当前范围:${scopeText}`,
`服务状态:${customerStatusTitle(serviceStatus?.title) || serviceConclusion.status}`,
`业务影响:${serviceConclusion.risk}`,
`建议动作:${serviceConclusion.nextStep}`,
`在线数据通道:${evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount}` : '-'}`,
`定位数据:${evidenceLocatedSourceCount > 0 ? `${evidenceLocatedSourceCount}` : '0'}`,
`质量问题:${qualityCount.toLocaleString()}`,
`服务数据:轨迹 ${overview?.historyCount ?? detail.history.total ?? 0} / 历史数据 ${overview?.rawCount ?? detail.raw.total ?? 0} / 里程 ${overview?.mileageCount ?? detail.mileage.total ?? 0}`,
`下一步清单:${actionLines.length > 0 ? '' : '暂无明确处置项,持续观察实时状态。'}`,
...actionLines,
`车辆服务:${vehicleServiceURL(vehicleServiceHash('detail'))}`,
`实时监控:${vehicleServiceURL(vehicleServiceHash('realtime'))}`,
`轨迹回放:${vehicleServiceURL(vehicleServiceHash('history'))}`,
`历史数据:${vehicleServiceURL(vehicleServiceHash('history-query', { tab: 'raw', includeFields: 'true' }))}`,
`统计查询:${vehicleServiceURL(vehicleServiceHash('mileage'))}`
];
copyText(lines.join('\n'), '运营摘要');
};
const detailWorkbench = [
{
title: '实时定位',
value: lastSeen,
meta: evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount} 在线` : '暂无来源',
color: online ? 'green' as const : 'orange' as const,
detail: '查看该车最新位置、速度、SOC、总里程和多通道实时字段。',
disabled: !hasResolvedVIN,
actions: [
{ label: '查看实时', onClick: () => onOpenRealtime(resolvedVIN, activeProtocol) }
]
},
{
title: '轨迹回放',
value: `${formatCompactNumber(overview?.historyCount ?? detail?.history?.total ?? 0)} 条历史`,
meta: evidenceLocatedSourceCount > 0 ? `${evidenceLocatedSourceCount} 个位置证据` : '暂无位置',
color: evidenceLocatedSourceCount > 0 ? 'blue' as const : 'grey' as const,
detail: '回放历史轨迹,复核定位断点、速度变化和里程连续性。',
disabled: !hasResolvedVIN,
actions: [
{ label: '打开轨迹', onClick: () => onOpenHistory(resolvedVIN, activeProtocol) }
]
},
{
title: '历史数据',
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`,
meta: latestRaw?.frameType || '字段明细',
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
detail: '查看历史明细和字段明细,用于复核定位、里程和告警依据。',
disabled: !hasResolvedVIN,
actions: [
{ label: '查看历史数据', onClick: () => onOpenRaw(resolvedVIN, activeProtocol) }
]
},
{
title: '告警处置',
value: `${qualityCount.toLocaleString()}`,
meta: priorityQualityIssue ? qualityIssueLabel(priorityQualityIssue.issueType) : '暂无告警',
color: qualityCount > 0 ? 'orange' as const : 'green' as const,
detail: '进入告警队列处理断链、VIN 缺失、字段缺失和来源不一致。',
disabled: !hasResolvedVIN && qualityCount <= 0,
actions: [
{ label: '查看告警', onClick: () => onOpenQuality?.(qualityFiltersForCurrentVehicle()) }
]
},
{
title: '统计复核',
value: `${formatCompactNumber(overview?.mileageCount ?? detail?.mileage?.total ?? 0)} 条统计`,
meta: formatCompactNumber(evidenceMileageDelta, ' km 差异'),
color: isFiniteNumber(evidenceMileageDelta) && Math.abs(evidenceMileageDelta) > 1 ? 'orange' as const : 'green' as const,
detail: '核对统计口径,确认区间总值、每日里程与数据通道是否闭合。',
disabled: !hasResolvedVIN,
actions: [
{ label: '查看统计', onClick: () => onOpenMileage(resolvedVIN, activeProtocol) }
]
}
];
const serviceRunbookSteps = [
{
title: '确认实时状态',
evidence: evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount} 在线,最后上报 ${lastSeen}` : '暂无在线数据',
acceptance: '车辆在线状态、最新时间、有效坐标均可解释。',
action: '打开实时',
disabled: !hasResolvedVIN,
onClick: () => onOpenRealtime(resolvedVIN, activeProtocol)
},
{
title: '回放轨迹断点',
evidence: evidenceLocatedSourceCount > 0 ? `${evidenceLocatedSourceCount} 个通道有位置,时间差 ${formatSeconds(evidenceTimeDeltaSeconds)}` : '暂无可回放位置',
acceptance: '轨迹点、速度和总里程变化连续,异常点可定位到时间段。',
action: '轨迹回放',
disabled: !hasResolvedVIN,
onClick: () => onOpenHistory(resolvedVIN, activeProtocol)
},
{
title: '查询历史数据',
evidence: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 条历史数据,最新类型 ${latestRaw?.frameType || '-'}`,
acceptance: '关键字段来自解析字段而不是页面二次推断。',
action: '历史数据',
disabled: !hasResolvedVIN,
onClick: () => onOpenRaw(resolvedVIN, activeProtocol)
},
{
title: '处理告警闭环',
evidence: priorityQualityIssue ? `${qualityIssueLabel(priorityQualityIssue.issueType)} / ${priorityQualityIssue.lastSeen || '-'}` : '暂无质量告警',
acceptance: '断链、VIN 缺失、字段缺失等问题进入告警队列并有处置结论。',
action: '告警队列',
disabled: !onOpenQuality || (!hasResolvedVIN && qualityCount <= 0),
onClick: () => onOpenQuality?.(qualityFiltersForCurrentVehicle())
},
{
title: '复核统计口径',
evidence: `${formatCompactNumber(overview?.mileageCount ?? detail?.mileage?.total ?? 0)} 条统计,证据里程差 ${formatCompactNumber(evidenceMileageDelta, ' km')}`,
acceptance: '区间里程、每日里程和轨迹总里程可以互相解释。',
action: '统计查询',
disabled: !hasResolvedVIN,
onClick: () => onOpenMileage(resolvedVIN, activeProtocol)
}
];
const copyVehicleRunbook = () => {
if (!detail) {
Toast.warning('当前没有可复制的单车处理清单');
return;
}
const vehicleName = [archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey;
const lines = [
'【单车服务处理清单】',
`车辆:${vehicleName}`,
`当前范围:${scopeText}`,
`服务状态:${customerStatusTitle(serviceStatus?.title) || serviceConclusion.status}`,
`一致性:${customerStatusTitle(consistencyTitle) || consistencyTitle}`,
...serviceRunbookSteps.map((item, index) => [
`${index + 1}. ${item.title}`,
` 证据:${item.evidence}`,
` 验收:${item.acceptance}`
].join('\n')),
`实时监控:${vehicleServiceURL(vehicleServiceHash('realtime'))}`,
`轨迹回放:${vehicleServiceURL(vehicleServiceHash('history'))}`,
`历史数据:${vehicleServiceURL(vehicleServiceHash('history-query', { tab: 'raw', includeFields: 'true' }))}`,
`告警事件:${vehicleServiceURL(buildAppHash({ page: 'alert-events', keyword: resolvedVIN, protocol: activeProtocol }))}`,
`统计查询:${vehicleServiceURL(vehicleServiceHash('mileage'))}`,
`车辆服务:${vehicleServiceURL(vehicleServiceHash('detail'))}`
];
copyText(lines.join('\n'), '单车处理清单');
};
const detailTaskBoard = [
{
title: '看实时位置',
value: lastSeen,
detail: evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount} 在线,先确认车辆当前是否仍在上报。` : '暂无在线数据,优先确认数据接入。',
color: online ? 'green' as const : 'orange' as const,
primaryAction: '实时监控',
secondaryAction: '复制摘要',
disabled: !hasResolvedVIN,
onPrimary: () => onOpenRealtime(resolvedVIN, activeProtocol),
onSecondary: () => copyUnifiedVehicleService()
},
{
title: '回放轨迹证据',
value: `${formatCompactNumber(overview?.historyCount ?? detail?.history?.total ?? 0)} 条历史`,
detail: evidenceLocatedSourceCount > 0 ? `${evidenceLocatedSourceCount} 个位置证据,可回放定位、速度和里程断点。` : '暂无有效位置证据,先检查实时坐标。',
color: evidenceLocatedSourceCount > 0 ? 'blue' as const : 'grey' as const,
primaryAction: '轨迹回放',
secondaryAction: '处理清单',
disabled: !hasResolvedVIN,
onPrimary: () => onOpenHistory(resolvedVIN, activeProtocol),
onSecondary: () => copyVehicleRunbook()
},
{
title: '查询历史数据',
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`,
detail: latestRaw?.frameType ? `最新历史数据类型 ${latestRaw.frameType},用于核对解析字段。` : '按车辆和数据通道查询历史明细与解析字段。',
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
primaryAction: '历史数据',
secondaryAction: '导出档案',
disabled: !hasResolvedVIN,
onPrimary: () => onOpenRaw(resolvedVIN, activeProtocol),
onSecondary: () => exportVehicleReport()
},
{
title: '复核统计查询',
value: `${formatCompactNumber(overview?.mileageCount ?? detail?.mileage?.total ?? 0)} 条统计`,
detail: `证据里程差 ${formatCompactNumber(evidenceMileageDelta, ' km')},确认区间和日统计能闭合。`,
color: isFiniteNumber(evidenceMileageDelta) && Math.abs(evidenceMileageDelta) > 1 ? 'orange' as const : 'green' as const,
primaryAction: '统计查询',
secondaryAction: '诊断摘要',
disabled: !hasResolvedVIN,
onPrimary: () => onOpenMileage(resolvedVIN, activeProtocol),
onSecondary: () => copyVehicleDiagnosticSummary()
},
{
title: '闭环告警事件',
value: `${qualityCount.toLocaleString()}`,
detail: priorityQualityIssue ? `${qualityIssueLabel(priorityQualityIssue.issueType)} / ${priorityQualityIssue.lastSeen || '-'}` : '暂无高优先级告警,保持观察。',
color: qualityCount > 0 ? 'orange' as const : 'green' as const,
primaryAction: '告警事件',
secondaryAction: '运营摘要',
disabled: !onOpenQuality || (!hasResolvedVIN && qualityCount <= 0),
onPrimary: () => onOpenQuality?.(qualityFiltersForCurrentVehicle()),
onSecondary: () => copyVehicleOperationsSummary()
}
];
const customerServicePackageItems = [
{
title: '实时监控',
value: online ? '在线' : hasResolvedVIN ? '离线' : '待绑定',
detail: lastSeen === '-' ? '暂无最后上报时间' : `最后上报 ${lastSeen}`,
color: online ? 'green' as const : 'orange' as const,
action: '打开监控',
disabled: !hasResolvedVIN,
onClick: () => onOpenRealtime(resolvedVIN, activeProtocol)
},
{
title: '轨迹回放',
value: `${formatCompactNumber(overview?.historyCount ?? detail?.history?.total ?? 0)}`,
detail: evidenceLocatedSourceCount > 0 ? `${evidenceLocatedSourceCount} 个通道可定位` : '暂无可用定位数据',
color: evidenceLocatedSourceCount > 0 ? 'blue' as const : 'grey' as const,
action: '回放轨迹',
disabled: !hasResolvedVIN,
onClick: () => onOpenHistory(resolvedVIN, activeProtocol)
},
{
title: '历史数据查询',
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`,
detail: latestRaw?.frameType ? `最新 ${latestRaw.frameType}` : 'RAW 与解析字段证据',
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
action: '查询历史',
disabled: !hasResolvedVIN,
onClick: () => onOpenRaw(resolvedVIN, activeProtocol)
},
{
title: '告警通知',
value: `${qualityCount.toLocaleString()}`,
detail: priorityQualityIssue ? `${qualityIssueLabel(priorityQualityIssue.issueType)} / ${priorityQualityIssue.lastSeen || '-'}` : '暂无待通知告警',
color: qualityCount > 0 ? 'orange' as const : 'green' as const,
action: '告警闭环',
disabled: !onOpenQuality || (!hasResolvedVIN && qualityCount <= 0),
onClick: () => onOpenQuality?.(qualityFiltersForCurrentVehicle())
},
{
title: '统计查询',
value: `${formatCompactNumber(overview?.mileageCount ?? detail?.mileage?.total ?? 0)}`,
detail: `里程差 ${formatCompactNumber(evidenceMileageDelta, ' km')}`,
color: isFiniteNumber(evidenceMileageDelta) && Math.abs(evidenceMileageDelta) > 1 ? 'orange' as const : 'green' as const,
action: '统计复核',
disabled: !hasResolvedVIN,
onClick: () => onOpenMileage(resolvedVIN, activeProtocol)
}
];
const liveViewActions = [
{
title: '实时位置',
value: formatLocation(latest),
detail: lastSeen === '-' ? '暂无最后上报时间' : `最后上报 ${lastSeen}`,
action: '查看实时',
color: online ? 'green' as const : 'orange' as const,
disabled: !hasResolvedVIN,
onClick: () => onOpenRealtime(resolvedVIN, activeProtocol)
},
{
title: '轨迹历史',
value: `${formatCompactNumber(overview?.historyCount ?? detail?.history?.total ?? 0)}`,
detail: evidenceLocatedSourceCount > 0 ? `${evidenceLocatedSourceCount} 个定位证据` : '暂无有效定位',
action: '轨迹回放',
color: evidenceLocatedSourceCount > 0 ? 'blue' as const : 'grey' as const,
disabled: !hasResolvedVIN,
onClick: () => onOpenHistory(resolvedVIN, activeProtocol)
},
{
title: '里程与能源',
value: `${formatCompactNumber(latest?.totalMileageKm, ' km')}`,
detail: `SOC ${formatCompactNumber(latest?.socPercent, '%')} / 速度 ${formatCompactNumber(latest?.speedKmh, ' km/h')}`,
action: '统计查询',
color: isFiniteNumber(latest?.totalMileageKm) ? 'green' as const : 'orange' as const,
disabled: !hasResolvedVIN,
onClick: () => onOpenMileage(resolvedVIN, activeProtocol)
},
{
title: '数据导出',
value: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)}`,
detail: latestRaw?.frameType ? `最新 ${latestRaw.frameType}` : '历史明细与字段',
action: '历史导出',
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
disabled: !hasResolvedVIN,
onClick: () => onOpenRaw(resolvedVIN, activeProtocol)
},
{
title: '告警通知',
value: `${qualityCount.toLocaleString()}`,
detail: priorityQualityIssue ? `${qualityIssueLabel(priorityQualityIssue.issueType)} / ${priorityQualityIssue.lastSeen || '-'}` : '暂无待通知告警',
action: '告警闭环',
color: qualityCount > 0 ? 'orange' as const : 'green' as const,
disabled: !onOpenQuality || (!hasResolvedVIN && qualityCount <= 0),
onClick: () => onOpenQuality?.(qualityFiltersForCurrentVehicle())
}
];
const customerQuestionActions = [
{
question: '这辆车现在在哪里?',
answer: formatLocation(latest),
action: '实时监控',
color: online ? 'green' as const : 'orange' as const,
disabled: !hasResolvedVIN,
onClick: () => onOpenRealtime(resolvedVIN, activeProtocol)
},
{
question: '最近是否还在线?',
answer: lastSeen === '-' ? '暂无最后上报' : `最后上报 ${lastSeen}`,
action: '查看实时',
color: online ? 'green' as const : 'orange' as const,
disabled: !hasResolvedVIN,
onClick: () => onOpenRealtime(resolvedVIN, activeProtocol)
},
{
question: '能不能回放轨迹?',
answer: `${formatCompactNumber(overview?.historyCount ?? detail?.history?.total ?? 0)} 条轨迹证据`,
action: '轨迹回放',
color: evidenceLocatedSourceCount > 0 ? 'blue' as const : 'grey' as const,
disabled: !hasResolvedVIN,
onClick: () => onOpenHistory(resolvedVIN, activeProtocol)
},
{
question: '这辆车跑了多少?',
answer: `${formatCompactNumber(latest?.totalMileageKm, ' km')} / ${formatCompactNumber(overview?.mileageCount ?? detail?.mileage?.total ?? 0)} 条统计`,
action: '统计查询',
color: isFiniteNumber(latest?.totalMileageKm) ? 'green' as const : 'orange' as const,
disabled: !hasResolvedVIN,
onClick: () => onOpenMileage(resolvedVIN, activeProtocol)
},
{
question: '数据能导出吗?',
answer: `${formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0)} 条历史数据`,
action: '历史导出',
color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
disabled: !hasResolvedVIN,
onClick: () => onOpenRaw(resolvedVIN, activeProtocol)
},
{
question: '是否需要通知处理?',
answer: priorityQualityIssue ? `${qualityIssueLabel(priorityQualityIssue.issueType)} / ${priorityQualityIssue.lastSeen || '-'}` : '暂无待通知告警',
action: '告警通知',
color: qualityCount > 0 ? 'orange' as const : 'green' as const,
disabled: !onOpenQuality || (!hasResolvedVIN && qualityCount <= 0),
onClick: () => onOpenQuality?.(qualityFiltersForCurrentVehicle())
}
];
const copyCustomerServicePackage = () => {
if (!detail) {
Toast.warning('当前没有可复制的客户服务交付包');
return;
}
const vehicleName = [archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey;
const lines = [
'【单车客户服务交付包】',
`车辆:${vehicleName}`,
`当前范围:${scopeText}`,
`平台能力:实时监控 / 轨迹回放 / 历史数据查询 / 告警通知 / 统计查询`,
`服务状态:${customerStatusTitle(serviceStatus?.title) || serviceConclusion.status}`,
`服务说明:${serviceStatus?.detail ?? serviceConclusion.risk}`,
`在线数据通道:${evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount}` : '-'}`,
`最新上报:${lastSeen}`,
`轨迹证据:${overview?.historyCount ?? detail.history.total ?? 0}`,
`历史数据:${overview?.rawCount ?? detail.raw.total ?? 0}`,
`统计查询:${overview?.mileageCount ?? detail.mileage.total ?? 0} 条,证据差异 ${formatCompactNumber(evidenceMileageDelta, ' km')}`,
`告警事件:${qualityCount.toLocaleString()}`,
`车辆服务:${vehicleServiceURL(vehicleServiceHash('detail'))}`,
`实时监控:${vehicleServiceURL(vehicleServiceHash('realtime'))}`,
`轨迹回放:${vehicleServiceURL(vehicleServiceHash('history'))}`,
`历史数据查询:${vehicleServiceURL(vehicleServiceHash('history-query', { tab: 'raw', includeFields: 'true' }))}`,
`告警通知:${vehicleServiceURL(buildAppHash({ page: 'alert-events', keyword: resolvedVIN, protocol: activeProtocol }))}`,
`统计查询:${vehicleServiceURL(vehicleServiceHash('mileage'))}`
];
copyText(lines.join('\n'), '客户服务交付包');
};
const qualityTable = (
<Table
loading={loading}
pagination={false}
rowKey={(row?: QualityIssueRow) => `${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.lastSeen ?? ''}`}
dataSource={detail?.quality?.items ?? []}
columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ title: '问题', dataIndex: 'issueType', width: 150 },
{ title: '级别', width: 110, render: (_: unknown, row: QualityIssueRow) => <Tag color={row.severity === 'error' ? 'red' : 'orange'}>{row.severity}</Tag> },
{ title: '最后时间', dataIndex: 'lastSeen', width: 190 },
{ title: '说明', dataIndex: 'detail' }
]}
/>
);
const exportVehicleReport = () => {
if (!detail) {
Toast.warning('当前没有可导出的车辆档案');
return;
}
const rows: VehicleReportRow[] = [
{ section: '身份档案', item: '车辆主键', value: reportText(displayVIN) },
{ section: '身份档案', item: '查询关键词', value: reportText(displayLookupKey) },
{ section: '身份档案', item: '车牌', value: reportText(archivePlate) },
{ section: '身份档案', item: '手机号', value: reportText(archivePhone) },
{ section: '身份档案', item: 'OEM', value: reportText(archiveOEM) },
{ section: '身份档案', item: '档案完整度', value: archiveCompleteness },
{ section: '身份档案', item: '档案缺项', value: reportText(archiveMissingLabels) },
{ section: '服务结论', item: '服务判断', value: serviceConclusion.status },
{ section: '服务结论', item: '主要风险', value: serviceConclusion.risk },
{ section: '服务结论', item: '下一步', value: serviceConclusion.nextStep },
{ section: '车辆服务', item: '数据通道', value: evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount} 在线` : '-' },
{ section: '车辆服务', item: '定位数据', value: evidenceLocatedSourceCount > 0 ? `${evidenceLocatedSourceCount} 个通道有位置` : '暂无位置' },
{ section: '车辆服务', item: '里程差异', value: formatCompactNumber(evidenceMileageDelta, ' km') },
{ section: '车辆服务', item: '时间差异', value: formatSeconds(evidenceTimeDeltaSeconds) },
{ section: '车辆服务', item: '一致性结论', value: customerStatusTitle(consistencyTitle) || consistencyTitle },
{ section: '车辆服务', item: '一致性说明', value: consistencyDetail },
{ section: '数据规模', item: '历史记录', value: String(overview?.historyCount ?? detail.history.total ?? 0) },
{ section: '数据规模', item: '历史数据', value: String(overview?.rawCount ?? detail.raw.total ?? 0) },
{ section: '数据规模', item: '里程记录', value: String(overview?.mileageCount ?? detail.mileage.total ?? 0) },
{ section: '数据规模', item: '质量问题', value: String(qualityCount) }
];
for (const source of detail.sourceStatus ?? []) {
rows.push(
{ section: `数据通道 ${source.protocol}`, item: '在线', value: source.online ? '在线' : '离线' },
{ section: `数据通道 ${source.protocol}`, item: '最后时间', value: reportText(source.lastSeen) },
{ section: `数据通道 ${source.protocol}`, item: '能力', value: sourceCapabilities.filter((item) => source[item.key]).map((item) => item.label).join('|') || '-' }
);
}
downloadCsv(vehicleReportFileName(displayVIN, activeProtocol), buildCsv(vehicleReportColumns, rows));
Toast.success('已导出车辆服务档案');
};
return (
<div className="vp-page">
<PageHeader title="车辆服务" description="以车辆为主对象查看实时位置、轨迹回放、统计查询、告警通知和历史数据" />
<Card bordered>
<Form key={formKey} initValues={query} layout="horizontal" onSubmit={(values) => {
const nextQuery = { keyword: String(values.keyword ?? ''), protocol: String(values.protocol ?? '') };
setQuery(nextQuery);
onQueryChange?.(nextQuery.keyword, nextQuery.protocol);
load(nextQuery);
}}>
<Form.Input field="keyword" label="车辆关键词" placeholder="VIN / 车牌 / 手机号" style={{ width: 280 }} />
<Form.Select field="protocol" label="数据通道" placeholder="全部数据通道" style={{ width: 180 }}>
<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>
<Space>
<Button icon={<IconSearch />} htmlType="submit" theme="solid" type="primary"></Button>
<Button icon={<IconRefresh />} onClick={() => load(query)} loading={loading}></Button>
<Button icon={<IconCopy />} onClick={copyVehicleServiceURL}></Button>
<Button disabled={!detail} icon={<IconCopy />} onClick={copyVehicleDiagnosticSummary}></Button>
<Button disabled={!detail} icon={<IconCopy />} onClick={copyVehicleOperationsSummary}></Button>
<Button disabled={!detail} icon={<IconDownload />} onClick={exportVehicleReport}> CSV</Button>
<Button disabled={!hasResolvedVIN} onClick={() => onOpenRealtime(resolvedVIN, activeProtocol)}></Button>
<Button disabled={!hasResolvedVIN} onClick={() => onOpenHistory(resolvedVIN, activeProtocol)}></Button>
<Button disabled={!hasResolvedVIN} onClick={() => onOpenRaw(resolvedVIN, activeProtocol)}></Button>
<Button disabled={!hasResolvedVIN} onClick={() => onOpenMileage(resolvedVIN, activeProtocol)}></Button>
</Space>
</Form>
</Card>
<div className="vp-scope-bar">
<span className="vp-scope-label"></span>
<Tag color={activeProtocol ? 'blue' : 'green'}>{scopeText}</Tag>
<Typography.Text type="tertiary">
{activeProtocol ? '下方实时、轨迹、历史数据和里程仅展示该数据通道返回的数据。' : '下方数据按车辆聚合展示全部可用数据通道。'}
</Typography.Text>
</div>
{serviceStatus ? (
<div className={`vp-service-status vp-service-status-${serviceStatus.severity}`}>
<span className="vp-scope-label"></span>
<Tag color={serviceStatus.severity === 'ok' ? 'green' : serviceStatus.severity === 'error' ? 'red' : 'orange'}>
{customerStatusTitle(serviceStatus.title)}
</Tag>
<Typography.Text type="tertiary">{serviceStatus.detail}</Typography.Text>
</div>
) : null}
<Card bordered className="vp-vehicle-live-view">
<div className="vp-vehicle-live-main">
<div className="vp-vehicle-live-copy">
<Space wrap>
<Tag color="blue"> Live View</Tag>
<Tag color={online ? 'green' : hasResolvedVIN ? 'orange' : 'grey'}>{online ? '在线' : hasResolvedVIN ? '离线' : '待绑定'}</Tag>
<Tag color={serviceConclusion.color}>{customerStatusTitle(serviceStatus?.title) || serviceConclusion.status}</Tag>
</Space>
<Typography.Title heading={3} style={{ margin: 0 }}>
{[archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey}
</Typography.Title>
<Typography.Text type="secondary">
SOC
</Typography.Text>
<div className="vp-vehicle-live-facts">
{[
{ label: '当前位置', value: formatLocation(latest) },
{ label: '最后上报', value: lastSeen },
{ label: '速度', value: formatCompactNumber(latest?.speedKmh, ' km/h') },
{ label: 'SOC', value: formatCompactNumber(latest?.socPercent, '%') },
{ label: '总里程', value: formatCompactNumber(latest?.totalMileageKm, ' km') }
].map((item) => (
<div key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
))}
</div>
<Space wrap>
<Button theme="solid" type="primary" disabled={!hasResolvedVIN} onClick={() => onOpenRealtime(resolvedVIN, activeProtocol)}></Button>
<Button theme="light" type="primary" disabled={!hasResolvedVIN} onClick={() => onOpenHistory(resolvedVIN, activeProtocol)}></Button>
<Button theme="light" type="primary" disabled={!hasResolvedVIN} onClick={() => onOpenRaw(resolvedVIN, activeProtocol)}></Button>
<Button theme="light" disabled={!detail} icon={<IconCopy />} onClick={copyCustomerServicePackage}></Button>
</Space>
</div>
<VehicleMap
points={vehicleMapPoints}
maxFallbackPoints={16}
heightClassName="vp-vehicle-live-map"
fallbackLabel="显示该车最新位置"
/>
</div>
<div className="vp-vehicle-live-actions">
{liveViewActions.map((item) => (
<button
key={item.title}
type="button"
className="vp-vehicle-live-action"
disabled={item.disabled}
aria-label={`单车实时视图 ${item.title} ${item.action}`}
onClick={item.onClick}
>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</Card>
<Card bordered title="客户常问" style={{ marginTop: 16 }}>
<div className="vp-vehicle-question-grid">
{customerQuestionActions.map((item) => (
<button
key={item.question}
type="button"
className="vp-vehicle-question-item"
disabled={item.disabled}
aria-label={`单车客户常问 ${item.question} ${item.action}`}
onClick={item.onClick}
>
<strong>{item.question}</strong>
<span>{item.answer}</span>
<Tag color={item.color}>{item.action}</Tag>
</button>
))}
</div>
</Card>
<Card
bordered
title={<Space><span></span><Button size="small" disabled={!detail} icon={<IconCopy />} onClick={copyUnifiedVehicleService}></Button></Space>}
style={{ marginTop: 16 }}
>
<div className="vp-unified-service-board">
<div className="vp-unified-service-main">
<Space wrap>
<Tag color={activeProtocol ? 'blue' : 'green'}>{scopeText}</Tag>
<Tag color={serviceConclusion.color}>{customerStatusTitle(serviceStatus?.title) || serviceConclusion.status}</Tag>
</Space>
<Typography.Title heading={4} style={{ margin: 0 }}>
{[archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey}
</Typography.Title>
<Typography.Text type="secondary">
{serviceStatus?.detail ?? serviceConclusion.risk}
</Typography.Text>
<div className="vp-unified-service-actions">
<Button size="small" theme="solid" type="primary" disabled={!hasResolvedVIN} onClick={() => onOpenRealtime(resolvedVIN, activeProtocol)}></Button>
<Button size="small" theme="light" disabled={!hasResolvedVIN} onClick={() => onOpenHistory(resolvedVIN, activeProtocol)}></Button>
<Button size="small" theme="light" disabled={!hasResolvedVIN} onClick={() => onOpenRaw(resolvedVIN, activeProtocol)}></Button>
<Button size="small" theme="light" disabled={!hasResolvedVIN} onClick={() => onOpenMileage(resolvedVIN, activeProtocol)}></Button>
</div>
</div>
<div className="vp-unified-service-grid">
{unifiedVehicleServiceItems.map((item) => (
<div key={item.label} className="vp-unified-service-item">
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
</div>
))}
</div>
</div>
</Card>
<Card
bordered
title={<Space><span></span><Button size="small" disabled={!detail} icon={<IconCopy />} onClick={copyVehicleServiceDossier}></Button></Space>}
style={{ marginTop: 16 }}
>
<div className="vp-service-dossier">
<div className="vp-service-dossier-main">
<Space wrap>
<Tag color={hasResolvedVIN ? 'green' : 'orange'}>{hasResolvedVIN ? '已解析 VIN' : '待维护身份'}</Tag>
<Tag color={activeProtocol ? 'blue' : 'green'}>{scopeText}</Tag>
<Tag color={serviceConclusion.color}>{customerStatusTitle(serviceStatus?.title) || serviceConclusion.status}</Tag>
</Space>
<Typography.Title heading={4} style={{ margin: 0 }}>
{[archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey}
</Typography.Title>
<div className="vp-service-dossier-meta">
{[
{ label: '手机号', value: archivePhone || '-' },
{ label: 'OEM', value: archiveOEM || '-' },
{ label: '主数据通道', value: overview?.primaryProtocol || summary?.primaryProtocol || '-' },
{ label: '最后上报', value: lastSeen },
{ label: '档案完整度', value: archiveCompleteness },
{ label: '下一步', value: serviceActions[0]?.label ?? serviceConclusion.nextStep }
].map((item) => (
<div key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
))}
</div>
</div>
<div className="vp-service-dossier-assets">
{serviceAssetItems.map((item) => (
<div key={item.label} className="vp-service-dossier-asset">
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
</div>
))}
</div>
</div>
{archiveMissingLabels.length > 0 ? (
<div className="vp-service-dossier-missing">
{archiveMissingLabels.map((label) => (
<Tag key={label} color="orange">{label}</Tag>
))}
</div>
) : null}
</Card>
<Card
bordered
title={<Space><span></span><Button size="small" disabled={!detail} icon={<IconCopy />} onClick={copyCustomerServicePackage}></Button></Space>}
style={{ marginTop: 16 }}
>
<div className="vp-customer-service-package">
<div className="vp-customer-service-summary">
<Space wrap>
<Tag color={serviceConclusion.color}>{customerStatusTitle(serviceStatus?.title) || serviceConclusion.status}</Tag>
<Tag color={activeProtocol ? 'blue' : 'green'}>{scopeText}</Tag>
<Tag color={hasResolvedVIN ? 'green' : 'orange'}>{hasResolvedVIN ? 'VIN 已解析' : '身份待绑定'}</Tag>
</Space>
<Typography.Title heading={4} style={{ margin: 0 }}>
{[archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey}
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
<div className="vp-customer-service-links">
<Button size="small" theme="solid" type="primary" disabled={!hasResolvedVIN} onClick={() => onOpenRealtime(resolvedVIN, activeProtocol)}></Button>
<Button size="small" theme="light" disabled={!hasResolvedVIN} onClick={() => onOpenHistory(resolvedVIN, activeProtocol)}></Button>
<Button size="small" theme="light" disabled={!hasResolvedVIN} onClick={() => onOpenRaw(resolvedVIN, activeProtocol)}></Button>
<Button size="small" theme="light" disabled={!onOpenQuality || (!hasResolvedVIN && qualityCount <= 0)} onClick={() => onOpenQuality?.(qualityFiltersForCurrentVehicle())}></Button>
<Button size="small" theme="light" disabled={!hasResolvedVIN} onClick={() => onOpenMileage(resolvedVIN, activeProtocol)}></Button>
</div>
</div>
<div className="vp-customer-service-grid">
{customerServicePackageItems.map((item) => (
<button
key={item.title}
className="vp-customer-service-item"
disabled={item.disabled}
aria-label={`客户服务交付 ${item.title} ${item.action}`}
onClick={item.onClick}
type="button"
>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</div>
</Card>
{overview ? (
<Card bordered title="车辆服务概览" style={{ marginTop: 16 }}>
<Descriptions
row
data={[
{ key: '服务车辆', value: [overview.plate, overview.vin].filter(Boolean).join(' / ') || '-' },
{ key: '在线数据通道', value: `${overview.onlineSourceCount}/${overview.sourceCount}` },
{ key: '覆盖状态', value: <Tag color={overview.coverageStatus === 'online' ? 'green' : overview.coverageStatus === 'partial' ? 'orange' : 'grey'}>{coverageStatusText[overview.coverageStatus] ?? overview.coverageStatus}</Tag> },
{ key: '主数据通道', value: overview.primaryProtocol || '-' },
{ key: '最后上报', value: overview.lastSeen || '-' },
{ key: '服务数据', value: `轨迹 ${overview.historyCount} / 历史数据 ${overview.rawCount} / 里程 ${overview.mileageCount}` },
{ key: '实时来源', value: overview.realtimeCount },
{ key: '质量问题', value: qualityIssueAction(overview.qualityIssueCount) }
]}
/>
</Card>
) : null}
<Card bordered title="车辆服务结论" style={{ marginTop: 16 }}>
<div className="vp-conclusion-grid">
{[
{ label: '服务判断', value: <Tag color={serviceConclusion.color}>{serviceConclusion.status}</Tag> },
{ label: '主要风险', value: serviceConclusion.risk },
{ label: '下一步', value: serviceConclusion.nextStep }
].map((item) => (
<div key={item.label} className="vp-conclusion-item">
<div className="vp-evidence-label">{item.label}</div>
<div className="vp-evidence-value">{item.value}</div>
</div>
))}
</div>
</Card>
<Card bordered title="今日单车服务任务板" style={{ marginTop: 16 }}>
<div className="vp-vehicle-task-grid">
{detailTaskBoard.map((item) => (
<div key={item.title} className="vp-vehicle-task-item">
<div className="vp-vehicle-task-head">
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
</div>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
<Space wrap>
<Button
size="small"
theme="solid"
type="primary"
disabled={item.disabled}
aria-label={`单车任务 ${item.title} ${item.primaryAction}`}
onClick={item.onPrimary}
>
{item.primaryAction}
</Button>
<Button
size="small"
theme="light"
type="primary"
disabled={item.disabled && item.title !== '闭环告警事件'}
aria-label={`单车任务 ${item.title} ${item.secondaryAction}`}
onClick={item.onSecondary}
>
{item.secondaryAction}
</Button>
</Space>
</div>
))}
</div>
</Card>
<Card bordered title="单车运营工作台" style={{ marginTop: 16 }}>
<div className="vp-workbench-grid">
{detailWorkbench.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"
disabled={item.disabled || (item.title === '告警处置' && !onOpenQuality)}
aria-label={`单车运营工作台 ${item.title} ${action.label}`}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</Space>
</div>
))}
</div>
</Card>
<Card
bordered
title={<Space><span></span><Button size="small" onClick={copyVehicleRunbook}></Button></Space>}
style={{ marginTop: 16 }}
>
<div className="vp-runbook-grid">
{serviceRunbookSteps.map((item, index) => (
<div key={item.title} className="vp-runbook-step">
<div className="vp-runbook-index">{index + 1}</div>
<div className="vp-runbook-body">
<Typography.Title heading={6} style={{ margin: 0 }}>{item.title}</Typography.Title>
<Typography.Text type="secondary">{item.evidence}</Typography.Text>
<div className="vp-runbook-acceptance">{item.acceptance}</div>
<Button
size="small"
theme="light"
type="primary"
disabled={item.disabled}
aria-label={`单车处理清单 ${item.title} ${item.action}`}
onClick={item.onClick}
>
{item.action}
</Button>
</div>
</div>
))}
</div>
</Card>
{overview || consistency ? (
<Card bordered title="车辆服务数据链" style={{ marginTop: 16 }}>
<div className="vp-evidence-grid">
{[
{ label: '数据通道', value: evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount} 在线` : '-' },
{ label: '定位数据', value: evidenceLocatedSourceCount > 0 ? `${evidenceLocatedSourceCount} 个通道有位置` : '暂无位置' },
{ label: '里程差异', value: formatCompactNumber(evidenceMileageDelta, ' km') },
{ label: '时间差异', value: formatSeconds(evidenceTimeDeltaSeconds) },
{ label: '主数据通道', value: overview?.primaryProtocol || summary?.primaryProtocol || '-' },
{ label: '服务结论', value: customerStatusTitle(consistencyTitle) || consistencyTitle }
].map((item) => (
<div key={item.label} className="vp-evidence-item">
<div className="vp-evidence-label">{item.label}</div>
<div className="vp-evidence-value">{item.value}</div>
</div>
))}
</div>
<Typography.Text type="secondary">{consistencyDetail}</Typography.Text>
</Card>
) : null}
{hasResolvedVIN && sourceFusionRows.length > 0 ? (
<Card
bordered
title={<Space><span></span><Button size="small" aria-label="复制归并矩阵" icon={<IconCopy />} onClick={copySourceFusionReport}></Button></Space>}
style={{ marginTop: 16 }}
>
<div className="vp-source-fusion-grid">
{sourceFusionRows.map((row) => (
<div key={row.source.protocol} className="vp-source-fusion-item">
<div className="vp-source-fusion-head">
<Space spacing={6} wrap>
<Tag color={row.role.color}>{row.source.protocol}</Tag>
<Tag color={row.source.online ? 'green' : row.source.lastSeen ? 'orange' : 'grey'}>{row.freshness}</Tag>
</Space>
<Tag color={row.role.color}>{row.role.label}</Tag>
</div>
<div className="vp-source-fusion-contribution">
{row.contribution.map((item) => (
<Tag key={item} color={item === '待补来源' ? 'orange' : 'blue'}>{item}</Tag>
))}
</div>
<div className="vp-source-fusion-facts">
{[
{ label: '实时', value: row.realtimeText },
{ label: '定位', value: row.locationText },
{ label: '历史数据', value: row.rawText },
{ label: '里程', value: row.mileageText }
].map((item) => (
<div key={item.label} className="vp-source-fusion-fact">
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
))}
</div>
<Space wrap>
<Button
size="small"
theme={activeProtocol === row.source.protocol ? 'solid' : 'light'}
type={activeProtocol === row.source.protocol ? 'primary' : 'tertiary'}
disabled={activeProtocol === row.source.protocol}
aria-label={`归并矩阵仅看 ${row.source.protocol}`}
onClick={() => switchSource(row.source.protocol)}
>
</Button>
<Button size="small" theme="light" disabled={!row.source.hasRealtime} onClick={() => onOpenRealtime(resolvedVIN, row.source.protocol)}></Button>
<Button size="small" theme="light" disabled={!row.source.hasHistory} onClick={() => onOpenHistory(resolvedVIN, row.source.protocol)}></Button>
<Button size="small" theme="light" disabled={!row.source.hasRaw} onClick={() => onOpenRaw(resolvedVIN, row.source.protocol)}></Button>
<Button size="small" theme="light" disabled={!row.source.hasMileage} onClick={() => onOpenMileage(resolvedVIN, row.source.protocol)}></Button>
</Space>
</div>
))}
</div>
</Card>
) : null}
{hasResolvedVIN && vehicleMapPoints.length > 0 ? (
<Card bordered title="车辆位置态势" style={{ marginTop: 16 }}>
<div className="vp-vehicle-map-layout">
<VehicleMap
points={vehicleMapPoints}
heightClassName="vp-vehicle-detail-map"
maxFallbackPoints={12}
fallbackLabel="高德地图未配置,显示车辆服务坐标预览"
/>
<div className="vp-vehicle-map-side">
<Space wrap>
<Tag color="blue">{locatedRealtimeRows.length.toLocaleString()} </Tag>
<Tag color={activeProtocol ? 'blue' : 'green'}>{scopeText}</Tag>
</Space>
{locatedRealtimeRows.map((row) => (
<div key={`${row.protocol}-${row.lastSeen}-${row.longitude}-${row.latitude}`} className="vp-vehicle-map-source">
<div className="vp-vehicle-map-source-main">
<Typography.Text strong>{row.protocol}</Typography.Text>
<Tag color={sourceOnlineByProtocol.get(row.protocol) === false ? 'orange' : 'green'}>
{row.protocol} / {formatCompactNumber(row.speedKmh, ' km/h')}
</Tag>
</div>
<Typography.Text type="secondary" size="small">
{row.longitude}, {row.latitude}
</Typography.Text>
<Typography.Text type="tertiary" size="small">
{formatCompactNumber(row.totalMileageKm, ' km')} / {row.lastSeen || '-'}
</Typography.Text>
</div>
))}
</div>
</div>
</Card>
) : null}
<Card bordered title="服务处置建议" style={{ marginTop: 16 }}>
<div className="vp-action-grid">
{serviceActions.map((item) => (
<div key={item.key} className="vp-action-item">
<div>
<Tag color={item.color}>{item.label}</Tag>
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8 }}>{item.description}</Typography.Text>
</div>
<Button size="small" theme="light" type={item.color === 'red' ? 'danger' : item.color === 'green' ? 'primary' : 'warning'} onClick={item.onClick}>
{item.label}
</Button>
</div>
))}
</div>
</Card>
{priorityQualityIssue ? (
<Card bordered title="单车处置建议" style={{ marginTop: 16 }}>
<div className="vp-action-item">
<div>
<Space wrap>
<Tag color={priorityQualityIssue.severity === 'error' ? 'red' : 'orange'}>
{qualityIssueLabel(priorityQualityIssue.issueType)}
</Tag>
<Tag color="blue">{priorityQualityIssue.protocol || activeProtocol || '全部来源'}</Tag>
<Tag color="grey">{priorityQualityIssue.lastSeen || '无时间'}</Tag>
</Space>
<Typography.Text strong style={{ display: 'block', marginTop: 8 }}>
{qualityIssueVehicleLabel(priorityQualityIssue, resolvedVIN)}
</Typography.Text>
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 6 }}>
{priorityQualityIssue.detail || '该车存在告警事件,需要结合轨迹和历史数据确认影响范围。'}
</Typography.Text>
</div>
<Space wrap>
<Button size="small" theme="light" type="warning" onClick={() => onOpenQuality?.({
keyword: priorityQualityIssue.vin || resolvedVIN,
protocol: priorityQualityIssue.protocol || activeProtocol,
issueType: priorityQualityIssue.issueType
})}>
</Button>
<Button
aria-label="单车告警轨迹证据"
size="small"
theme="light"
type="primary"
onClick={() => openQualityIssueHistoryEvidence(priorityQualityIssue)}
>
</Button>
<Button
aria-label="单车告警历史数据"
size="small"
theme="light"
type="tertiary"
onClick={() => openQualityIssueRawEvidence(priorityQualityIssue)}
>
</Button>
</Space>
</div>
</Card>
) : null}
<Card bordered title="车辆档案" style={{ marginTop: 16 }}>
<div className="vp-vehicle-summary">
<Descriptions
row
data={[
{ key: '车辆主键', value: displayVIN },
{ key: '查询关键词', value: displayLookupKey },
{ key: 'VIN', value: displayVIN },
{ key: '解析状态', value: <Tag color={hasResolvedVIN ? 'green' : 'orange'}>{hasResolvedVIN ? '已解析 VIN' : '未解析到 VIN'}</Tag> },
{ key: '车牌', value: archivePlate || '-' },
{ key: '手机号', value: archivePhone || '-' },
{ key: 'OEM', value: archiveOEM || '-' },
{
key: '档案完整度',
value: (
<Space spacing={4} wrap>
<Tag color={archiveCompleteness === '4/4' ? 'green' : 'orange'}>{archiveCompleteness}</Tag>
{archiveMissingLabels.map((label) => (
<Tag key={label} color="orange">{label}</Tag>
))}
</Space>
)
},
{ key: '归并来源数', value: archiveSourceCount },
{ key: '在线', value: <StatusTag status={online ? 'ok' : 'offline'} /> },
{
key: '数据通道',
value: protocols.length > 0 ? (
<SourceStatusTags sourceStatus={detail?.sourceStatus} protocols={protocols} lastSeen={lastSeen === '-' ? '' : lastSeen} />
) : '-'
},
{ key: '在线数据通道', value: summary ? `${summary.onlineSourceCount}/${summary.sourceCount}` : '-' },
{ key: '最后位置时间', value: lastSeen },
{ key: '最新历史数据时间', value: latestRaw?.serverTime ?? '-' },
{ key: '质量问题', value: <Tag color={qualityCount > 0 ? 'orange' : 'green'}>{qualityCount > 0 ? `${qualityCount}` : '无'}</Tag> }
]}
/>
</div>
{!hasResolvedVIN ? (
<Banner
type="warning"
bordered
title="身份绑定待处理"
description={`车辆关键词 ${displayLookupKey} 暂未解析到 VIN请维护车辆身份绑定后再查看历史、里程和跨证据数据。`}
style={{ marginTop: 12 }}
/>
) : null}
</Card>
{hasResolvedVIN ? (
<>
<Card bordered title="数据通道覆盖" style={{ marginTop: 16 }}>
{detail?.sourceStatus?.length ? (
<>
<div className="vp-source-toolbar">
<Space>
<Tag color={activeProtocol ? 'grey' : 'blue'}>{activeProtocol ? `当前 ${activeProtocol}` : '当前 全部数据通道'}</Tag>
<SourceStatusTags sourceStatus={detail.sourceStatus} protocols={protocols} lastSeen={lastSeen === '-' ? '' : lastSeen} />
<Button size="small" disabled={!activeProtocol} onClick={() => switchSource('')}></Button>
</Space>
</div>
<Table
loading={loading}
pagination={false}
rowKey="protocol"
dataSource={detail.sourceStatus}
columns={[
{
title: '数据通道',
dataIndex: 'protocol',
width: 140,
render: (_: unknown, row: VehicleSourceStatus) => (
<Tag color={row.protocol === summary?.primaryProtocol ? 'blue' : 'grey'}>{row.protocol}</Tag>
)
},
{
title: '车辆服务角色',
width: 130,
render: (_: unknown, row: VehicleSourceStatus) => {
const role = sourceServiceRole(row, summary?.primaryProtocol);
return <Tag color={role.color}>{role.label}</Tag>;
}
},
{ title: '在线', width: 100, render: (_: unknown, row: VehicleSourceStatus) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
{ title: '最后时间', dataIndex: 'lastSeen', width: 190, render: (value?: string) => value || '无上报' },
{
title: '最新位置',
width: 170,
render: (_: unknown, row: VehicleSourceStatus) => formatLocation(latestRealtimeByProtocol.get(row.protocol))
},
{
title: '里程',
width: 110,
render: (_: unknown, row: VehicleSourceStatus) => formatCompactNumber(latestRealtimeByProtocol.get(row.protocol)?.totalMileageKm, ' km')
},
{
title: '速度',
width: 110,
render: (_: unknown, row: VehicleSourceStatus) => formatCompactNumber(latestRealtimeByProtocol.get(row.protocol)?.speedKmh, ' km/h')
},
{
title: 'SOC',
width: 90,
render: (_: unknown, row: VehicleSourceStatus) => formatCompactNumber(latestRealtimeByProtocol.get(row.protocol)?.socPercent, '%')
},
...sourceCapabilities.map((item) => ({
title: item.label,
width: 90,
render: (_: unknown, row: VehicleSourceStatus) => <Tag color={row[item.key] ? 'green' : 'grey'}>{row[item.key] ? '有' : '无'}</Tag>
})),
{
title: '操作',
width: 330,
render: (_: unknown, row: VehicleSourceStatus) => (
<Space spacing={6} wrap>
<Button
size="small"
theme={activeProtocol === row.protocol ? 'solid' : 'light'}
type={activeProtocol === row.protocol ? 'primary' : 'tertiary'}
disabled={activeProtocol === row.protocol}
onClick={() => switchSource(row.protocol)}
>
{row.protocol}
</Button>
<Button
size="small"
theme="light"
type="tertiary"
disabled={!row.hasHistory}
onClick={() => onOpenHistory(resolvedVIN, row.protocol)}
>
{row.protocol}
</Button>
<Button
size="small"
theme="light"
type="tertiary"
disabled={!row.hasRaw}
onClick={() => onOpenRaw(resolvedVIN, row.protocol)}
>
{row.protocol}
</Button>
<Button
size="small"
theme="light"
type="tertiary"
disabled={!row.hasMileage}
onClick={() => onOpenMileage(resolvedVIN, row.protocol)}
>
{row.protocol}
</Button>
</Space>
)
}
]}
/>
</>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Card>
<Card bordered title="跨证据一致性" style={{ marginTop: 16 }}>
<Descriptions
row
data={[
{ key: '一致性结论', value: customerStatusTitle(consistencyTitle) || consistencyTitle },
{ key: '通道覆盖', value: consistencySourceCount > 0 ? `${consistencySourceCount} 个通道` : '-' },
{ key: '在线数据通道', value: consistencySourceCount > 0 ? `${consistencyOnlineSourceCount}/${consistencySourceCount} 在线` : '-' },
{ key: '缺失通道', value: missingProtocolText },
{ key: '位置覆盖', value: consistencyLocatedSourceCount > 0 ? `${consistencyLocatedSourceCount} 个通道有位置` : '暂无位置' },
{ key: '里程差异', value: formatCompactNumber(consistencyMileageDelta, ' km') },
{ key: '通道时间差', value: formatSeconds(consistencyTimeDeltaSeconds) },
{ key: '诊断说明', value: consistencyDetail },
{ key: '车辆服务视角', value: activeProtocol ? `当前只看 ${activeProtocol}` : '三类来源合并为同一车辆服务' }
]}
/>
</Card>
</>
) : null}
{!hasResolvedVIN ? (
<Card bordered style={{ marginTop: 16 }}>
<Tabs>
<Tabs.TabPane tab="质量问题" itemKey="quality">
{qualityTable}
</Tabs.TabPane>
</Tabs>
</Card>
) : (
<Card bordered style={{ marginTop: 16 }}>
<Tabs>
<Tabs.TabPane tab="实时状态" itemKey="realtime">
<div className="vp-realtime-strip">
{[
{ label: '最新来源', value: summary?.primaryProtocol || '-' },
{ label: '速度 km/h', value: summary ? summary.speedKmh : '-' },
{ label: 'SOC %', value: summary ? summary.socPercent : '-' },
{ label: '总里程 km', value: summary ? summary.totalMileageKm : '-' },
{ label: '经纬度', value: summary ? `${summary.longitude}, ${summary.latitude}` : '-' },
{ label: '最后时间', value: lastSeen }
].map((item) => (
<div key={item.label} className="vp-realtime-strip-item">
<div className="vp-realtime-strip-label">{item.label}</div>
<div className="vp-realtime-strip-value">{item.value}</div>
</div>
))}
</div>
<Table
loading={loading}
pagination={false}
rowKey="protocol"
dataSource={detail?.realtime ?? []}
columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ title: '经度', dataIndex: 'longitude', width: 120 },
{ title: '纬度', dataIndex: 'latitude', width: 120 },
{ title: '速度 km/h', dataIndex: 'speedKmh', width: 120 },
{ title: 'SOC %', dataIndex: 'socPercent', width: 120 },
{ title: '总里程 km', dataIndex: 'totalMileageKm', width: 130 },
{ title: '最后时间', dataIndex: 'lastSeen', width: 190 }
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="历史位置" itemKey="history">
<Table
loading={loading}
pagination={false}
rowKey="deviceTime"
dataSource={detail?.history?.items ?? []}
columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ title: '经度', dataIndex: 'longitude', width: 120 },
{ title: '纬度', dataIndex: 'latitude', width: 120 },
{ title: '速度 km/h', dataIndex: 'speedKmh', width: 120 },
{ title: '总里程 km', dataIndex: 'totalMileageKm', width: 130 },
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
{ title: '入库时间', dataIndex: 'serverTime', width: 190 }
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="原始字段" itemKey="raw">
<Table
loading={loading}
pagination={false}
rowKey="id"
dataSource={detail?.raw?.items ?? []}
columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ title: '帧类型', dataIndex: 'frameType', width: 190 },
{ title: '大小 B', dataIndex: 'rawSizeBytes', width: 100 },
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
{ title: '入库时间', dataIndex: 'serverTime', width: 190 }
]}
/>
<Typography.Text type="tertiary"></Typography.Text>
<pre className="vp-json">{JSON.stringify(latestRaw?.parsedFields ?? {}, null, 2)}</pre>
</Tabs.TabPane>
<Tabs.TabPane tab="里程" itemKey="mileage">
<Table
loading={loading}
pagination={false}
rowKey="date"
dataSource={detail?.mileage?.items ?? []}
columns={[
{ title: '日期', dataIndex: 'date', width: 130 },
{ title: '来源', dataIndex: 'source', width: 130 },
{ title: '起始里程', dataIndex: 'startMileageKm', width: 130 },
{ title: '结束里程', dataIndex: 'endMileageKm', width: 130 },
{ title: '日里程', dataIndex: 'dailyMileageKm', width: 130 }
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="质量问题" itemKey="quality">
{qualityTable}
</Tabs.TabPane>
</Tabs>
</Card>
)}
</div>
);
}