1929 lines
95 KiB
TypeScript
1929 lines
95 KiB
TypeScript
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>
|
||
);
|
||
}
|