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

2080 lines
100 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Button, Card, Form, Select, SideSheet, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui';
import { IconCopy, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { useEffect, useMemo, useState } from 'react';
import { api, type RawFrameQuery } from '../api/client';
import type { HistoryLocationRow, MapReverseGeocode, Page, RawFrameRow } from '../api/types';
import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap';
import { PageHeader } from '../components/PageHeader';
import { isAMapConfigured } from '../config/appConfig';
import { buildAppHash } from '../domain/appRoute';
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
type HistoryFilters = {
keyword?: string;
protocol?: string;
dateFrom?: string;
dateTo?: string;
fields?: string;
includeFields?: boolean | string;
};
type RawFieldRow = {
id: string;
rawId: string;
vin: string;
plate: string;
protocol: string;
frameType: string;
deviceTime: string;
serverTime: string;
fieldPath: string;
fieldValue: unknown;
};
const defaultFilters: HistoryFilters = {
includeFields: false
};
const defaultPage = { items: [], total: 0, limit: 10, offset: 0 };
function canOpenVehicle(vin?: string) {
const value = vin?.trim();
return Boolean(value && value !== 'unknown');
}
function dateOnly(value?: string) {
const match = String(value ?? '').match(/^(\d{4})-(\d{2})-(\d{2})/);
return match ? `${match[1]}-${match[2]}-${match[3]}` : '';
}
function nextDate(value: string) {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return '';
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + 1));
return date.toISOString().slice(0, 10);
}
function todayDateString() {
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function previousDateString(value: string) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
if (!match) return value;
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]) - 1);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function splitFields(value?: string) {
return (value ?? '')
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
}
type HistoryTabKey = 'location' | 'raw' | 'fields';
function normalizeHistoryTab(value?: string): HistoryTabKey {
if (value === 'fields') return 'fields';
return value === 'raw' ? 'raw' : 'location';
}
function isIncludeFieldsEnabled(value?: boolean | string) {
return value === true || value === 'true';
}
function hasRawFieldQueryScope(filters: HistoryFilters) {
return Boolean(
filters.keyword?.trim() ||
filters.dateFrom?.trim() ||
filters.dateTo?.trim() ||
splitFields(filters.fields).length > 0
);
}
function shouldBlockRawFieldQuery(filters: HistoryFilters) {
return isIncludeFieldsEnabled(filters.includeFields) && !hasRawFieldQueryScope(filters);
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function hasValidCoordinate(row: HistoryLocationRow) {
return isFiniteNumber(row.longitude) && isFiniteNumber(row.latitude) && row.longitude !== 0 && row.latitude !== 0;
}
function formatNumber(value?: number, suffix = '') {
if (!isFiniteNumber(value)) return '-';
return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}${suffix}`;
}
function formatPercent(value?: number) {
if (!isFiniteNumber(value)) return '-';
return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}%`;
}
function timeValue(row?: HistoryLocationRow) {
const value = row?.deviceTime || row?.serverTime || row?.lastSeen;
const ms = Date.parse(String(value ?? '').replace(' ', 'T'));
return Number.isFinite(ms) ? ms : undefined;
}
function playbackPointId(row: HistoryLocationRow, index: number) {
return `${row.vin || 'unknown'}-${row.deviceTime || row.serverTime || row.lastSeen || index}-${index}`;
}
function formatDurationMinutes(value?: number, suffix = '') {
if (!isFiniteNumber(value)) return '-';
if (value < 1) return `${Math.round(value * 60)}${suffix}`;
return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} 分钟${suffix}`;
}
type TrajectoryAnomalySummary = {
gapCount: number;
maxGapMinutes?: number;
mileageRollbackCount: number;
maxMileageRollbackKm?: number;
overspeedCount: number;
maxSpeedKmh?: number;
};
function analyzeTrajectoryAnomalies(rows: HistoryLocationRow[]): TrajectoryAnomalySummary {
const summary: TrajectoryAnomalySummary = {
gapCount: 0,
mileageRollbackCount: 0,
overspeedCount: 0
};
let previousTime: number | undefined;
let previousMileage: number | undefined;
for (const row of rows) {
const currentTime = timeValue(row);
if (currentTime != null && previousTime != null) {
const gapMinutes = Math.abs(currentTime - previousTime) / 60000;
if (gapMinutes > 30) {
summary.gapCount += 1;
summary.maxGapMinutes = summary.maxGapMinutes == null ? gapMinutes : Math.max(summary.maxGapMinutes, gapMinutes);
}
}
if (currentTime != null) {
previousTime = currentTime;
}
if (isFiniteNumber(row.totalMileageKm) && previousMileage != null && row.totalMileageKm < previousMileage) {
const rollback = previousMileage - row.totalMileageKm;
summary.mileageRollbackCount += 1;
summary.maxMileageRollbackKm = summary.maxMileageRollbackKm == null ? rollback : Math.max(summary.maxMileageRollbackKm, rollback);
}
if (isFiniteNumber(row.totalMileageKm)) {
previousMileage = row.totalMileageKm;
}
if (isFiniteNumber(row.speedKmh) && row.speedKmh > 120) {
summary.overspeedCount += 1;
summary.maxSpeedKmh = summary.maxSpeedKmh == null ? row.speedKmh : Math.max(summary.maxSpeedKmh, row.speedKmh);
}
}
return summary;
}
function mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record<string, string> = {}): HistoryFilters {
const hasExplicitScope = Boolean(
initialVin?.trim() ||
initialProtocol?.trim() ||
initialFilters.keyword?.trim() ||
initialFilters.protocol?.trim() ||
initialFilters.dateFrom?.trim() ||
initialFilters.dateTo?.trim() ||
initialFilters.fields?.trim()
);
const today = todayDateString();
const defaultDateRange = hasExplicitScope ? {} : {
dateFrom: previousDateString(today),
dateTo: today
};
return {
...defaultFilters,
...defaultDateRange,
keyword: initialVin || '',
protocol: initialProtocol,
...initialFilters,
includeFields: isIncludeFieldsEnabled(initialFilters.includeFields)
};
}
const locationExportColumns: CsvColumn<HistoryLocationRow>[] = [
{ title: 'VIN', value: (row) => row.vin },
{ title: '车牌', value: (row) => row.plate },
{ title: '数据通道', value: (row) => row.protocol },
{ title: '经度', value: (row) => row.longitude },
{ title: '纬度', value: (row) => row.latitude },
{ title: '速度km/h', value: (row) => row.speedKmh },
{ title: '总里程km', value: (row) => row.totalMileageKm },
{ title: '设备时间', value: (row) => row.deviceTime },
{ title: '入库时间', value: (row) => row.serverTime }
];
const rawExportColumns: CsvColumn<RawFrameRow>[] = [
{ title: 'ID', value: (row) => row.id },
{ title: 'VIN', value: (row) => row.vin },
{ title: '车牌', value: (row) => row.plate },
{ title: '数据通道', value: (row) => row.protocol },
{ title: '帧类型', value: (row) => row.frameType },
{ title: '大小B', value: (row) => row.rawSizeBytes },
{ title: '设备时间', value: (row) => row.deviceTime },
{ title: '入库时间', value: (row) => row.serverTime },
{ title: '字段明细', value: (row) => row.parsedFields ?? {} }
];
const rawFieldExportColumns: CsvColumn<RawFieldRow>[] = [
{ title: '历史明细ID', value: (row) => row.rawId },
{ title: 'VIN', value: (row) => row.vin },
{ title: '车牌', value: (row) => row.plate },
{ title: '数据通道', value: (row) => row.protocol },
{ title: '帧类型', value: (row) => row.frameType },
{ title: '字段', value: (row) => row.fieldPath },
{ title: '值', value: (row) => formatFieldValue(row.fieldValue) },
{ title: '设备时间', value: (row) => row.deviceTime },
{ title: '入库时间', value: (row) => row.serverTime }
];
function formatFieldValue(value: unknown) {
if (value == null) return '';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}
function flattenParsedFields(value: unknown, prefix = ''): Array<{ path: string; value: unknown }> {
if (value == null || typeof value !== 'object') {
return prefix ? [{ path: prefix, value }] : [];
}
if (Array.isArray(value)) {
return value.flatMap((item, index) => flattenParsedFields(item, prefix ? `${prefix}.${index}` : String(index)));
}
return Object.entries(value as Record<string, unknown>).flatMap(([key, item]) => {
const nextPath = prefix ? `${prefix}.${key}` : key;
if (item != null && typeof item === 'object') {
return flattenParsedFields(item, nextPath);
}
return [{ path: nextPath, value: item }];
});
}
function exportFileName(prefix: string, filters: HistoryFilters) {
const keyword = filters.keyword?.trim() || 'all';
const protocol = filters.protocol?.trim() || 'all-source';
return `${prefix}-${keyword}-${protocol}.csv`;
}
function trajectorySummaryText({
filters,
totalPoints,
validPointCount,
mileageDelta,
maxSpeed,
firstLocation,
lastLocation
}: {
filters: HistoryFilters;
totalPoints: number;
validPointCount: number;
mileageDelta?: number;
maxSpeed?: number;
firstLocation?: HistoryLocationRow;
lastLocation?: HistoryLocationRow;
}) {
const vehicle = filters.keyword?.trim() || '全部车辆';
const protocol = filters.protocol?.trim() || '全部数据通道';
const dateFrom = filters.dateFrom?.trim();
const dateTo = filters.dateTo?.trim();
const range = dateFrom || dateTo ? `${dateFrom || '-'}${dateTo || '-'}` : '全部时间';
const startTime = firstLocation?.deviceTime || firstLocation?.serverTime || '-';
const endTime = lastLocation?.deviceTime || lastLocation?.serverTime || '-';
return [
'【轨迹回放摘要】',
`车辆:${vehicle}`,
`数据通道:${protocol}`,
`查询范围:${range}`,
`轨迹点:${totalPoints.toLocaleString()},有效定位:${validPointCount.toLocaleString()}`,
`区间里程:${formatNumber(mileageDelta, ' km')}`,
`最高速度:${formatNumber(maxSpeed, ' km/h')}`,
`起点:${startTime}`,
`终点:${endTime}`,
`当前位置服务:${window.location.origin}${window.location.pathname}${window.location.hash}`
].join('\n');
}
function appURL(hash: string) {
return `${window.location.origin}${window.location.pathname}${hash}`;
}
function historyEvidencePackageText({
filters,
locationCount,
rawCount,
fieldCount,
validPointCount,
mileageDelta,
maxSpeed,
firstLocation,
lastLocation,
anomalySummary
}: {
filters: HistoryFilters;
locationCount: number;
rawCount: number;
fieldCount: number;
validPointCount: number;
mileageDelta?: number;
maxSpeed?: number;
firstLocation?: HistoryLocationRow;
lastLocation?: HistoryLocationRow;
anomalySummary: TrajectoryAnomalySummary;
}) {
const vehicle = filters.keyword?.trim() || '';
const protocol = filters.protocol?.trim() || '';
const evidenceFilters = {
...(filters.dateFrom?.trim() ? { dateFrom: filters.dateFrom.trim() } : {}),
...(filters.dateTo?.trim() ? { dateTo: filters.dateTo.trim() } : {})
};
return [
'【数据复核包】',
`车辆:${vehicle || '全部车辆'}`,
`数据通道:${protocol || '全部数据通道'}`,
`查询范围:${filters.dateFrom?.trim() || '-'}${filters.dateTo?.trim() || '-'}`,
`位置记录:${locationCount.toLocaleString()},有效定位:${validPointCount.toLocaleString()}`,
`历史明细:${rawCount.toLocaleString()},字段明细:${fieldCount.toLocaleString()}`,
`区间里程:${formatNumber(mileageDelta, ' km')},最高速度:${formatNumber(maxSpeed, ' km/h')}`,
`起点:${firstLocation?.deviceTime || firstLocation?.serverTime || '-'}`,
`终点:${lastLocation?.deviceTime || lastLocation?.serverTime || '-'}`,
`异常:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`,
`轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: vehicle, protocol, filters: evidenceFilters }))}`,
`历史位置:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'location' } }))}`,
`历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`,
`字段明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'fields', includeFields: 'true', ...(filters.fields?.trim() ? { fields: filters.fields.trim() } : {}) } }))}`,
`里程复核:${appURL(buildAppHash({ page: 'mileage', keyword: vehicle, protocol, filters: evidenceFilters }))}`,
`车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: vehicle, protocol }))}`
].join('\n');
}
function deliveryPackageText({
filters,
locationCount,
rawCount,
fieldCount,
validPointCount,
coverageRate,
mileageDelta,
anomalySummary
}: {
filters: HistoryFilters;
locationCount: number;
rawCount: number;
fieldCount: number;
validPointCount: number;
coverageRate?: number;
mileageDelta?: number;
anomalySummary: TrajectoryAnomalySummary;
}) {
const vehicle = filters.keyword?.trim() || '';
const protocol = filters.protocol?.trim() || '';
const evidenceFilters = {
...(filters.dateFrom?.trim() ? { dateFrom: filters.dateFrom.trim() } : {}),
...(filters.dateTo?.trim() ? { dateTo: filters.dateTo.trim() } : {})
};
const anomalyTotal = anomalySummary.gapCount + anomalySummary.mileageRollbackCount + anomalySummary.overspeedCount;
const deliveryState = locationCount > 0 || rawCount > 0
? anomalyTotal > 0 || (coverageRate ?? 100) < 80 ? '可交付,建议附带复核说明' : '可交付'
: '待查询数据';
return [
'【客户查询导出包】',
`交付状态:${deliveryState}`,
'交付物:位置历史 / 轨迹回放 / 里程复核 / 历史明细 / 字段明细 / 质量提示',
`车辆范围:${vehicle || '全部车辆'}`,
`数据通道:${protocol || '全部数据通道'}`,
`时间范围:${filters.dateFrom?.trim() || '-'}${filters.dateTo?.trim() || '-'}`,
`位置历史:${locationCount.toLocaleString()} 条,有效定位 ${validPointCount.toLocaleString()},覆盖率 ${formatPercent(coverageRate)}`,
`历史明细:${rawCount.toLocaleString()}`,
`字段明细:${fieldCount.toLocaleString()} 个字段`,
`区间里程:${formatNumber(mileageDelta, ' km')}`,
`质量提示:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`,
`位置导出:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'location' } }))}`,
`导出明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`,
`导出字段:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'fields', includeFields: 'true', ...(filters.fields?.trim() ? { fields: filters.fields.trim() } : {}) } }))}`,
`轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: vehicle, protocol, filters: evidenceFilters }))}`,
`里程复核:${appURL(buildAppHash({ page: 'mileage', keyword: vehicle, protocol, filters: evidenceFilters }))}`,
`车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: vehicle, protocol }))}`
].join('\n');
}
function trajectoryReviewPackageText({
filters,
locationCount,
rawCount,
fieldCount,
validPointCount,
coverageRate,
playbackSpanMinutes,
playbackIntervalMinutes,
mileageDelta,
maxSpeed,
firstLocation,
lastLocation,
currentPlayback,
currentPlaybackIndex,
playbackCount,
currentAddress,
anomalySummary
}: {
filters: HistoryFilters;
locationCount: number;
rawCount: number;
fieldCount: number;
validPointCount: number;
coverageRate?: number;
playbackSpanMinutes?: number;
playbackIntervalMinutes?: number;
mileageDelta?: number;
maxSpeed?: number;
firstLocation?: HistoryLocationRow;
lastLocation?: HistoryLocationRow;
currentPlayback?: HistoryLocationRow;
currentPlaybackIndex: number;
playbackCount: number;
currentAddress?: MapReverseGeocode;
anomalySummary: TrajectoryAnomalySummary;
}) {
const vehicle = filters.keyword?.trim() || '';
const protocol = filters.protocol?.trim() || '';
const evidenceFilters = {
...(filters.dateFrom?.trim() ? { dateFrom: filters.dateFrom.trim() } : {}),
...(filters.dateTo?.trim() ? { dateTo: filters.dateTo.trim() } : {})
};
const anomalyAction = anomalySummary.gapCount > 0 || anomalySummary.mileageRollbackCount > 0 || anomalySummary.overspeedCount > 0
? '建议核对历史明细、字段明细、当日里程统计,并确认平台是否存在断链或补发。'
: '当前页未发现明显断点、里程回退或速度异常,可作为轨迹复盘依据使用。';
return [
'【轨迹复盘交接包】',
`车辆:${vehicle || '全部车辆'}`,
`数据通道:${protocol || '全部数据通道'}`,
`查询范围:${filters.dateFrom?.trim() || '-'}${filters.dateTo?.trim() || '-'}`,
`轨迹规模:位置 ${locationCount.toLocaleString()} / 有效定位 ${validPointCount.toLocaleString()} / 历史明细 ${rawCount.toLocaleString()} / 字段明细 ${fieldCount.toLocaleString()}`,
`轨迹质量:定位覆盖率 ${formatPercent(coverageRate)} / 回放跨度 ${formatDurationMinutes(playbackSpanMinutes)} / 采样间隔 ${formatDurationMinutes(playbackIntervalMinutes, '/点')}`,
`里程速度:区间里程 ${formatNumber(mileageDelta, ' km')} / 最高速度 ${formatNumber(maxSpeed, ' km/h')}`,
`起点:${firstLocation?.deviceTime || firstLocation?.serverTime || '-'}`,
`终点:${lastLocation?.deviceTime || lastLocation?.serverTime || '-'}`,
`当前回放点:${currentPlayback ? `${currentPlaybackIndex + 1}/${playbackCount}${currentPlayback.deviceTime || currentPlayback.serverTime || '-'}${formatNumber(currentPlayback.speedKmh, ' km/h')}${formatNumber(currentPlayback.totalMileageKm, ' km')}` : '-'}`,
`当前点地址:${currentAddress?.formattedAddress || '-'}`,
`异常判读:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`,
`最大断点:${formatDurationMinutes(anomalySummary.maxGapMinutes)}`,
`最大里程回退:${formatNumber(anomalySummary.maxMileageRollbackKm, ' km')}`,
`最高异常速度:${formatNumber(anomalySummary.maxSpeedKmh, ' km/h')}`,
`下一步:${anomalyAction}`,
`轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: vehicle, protocol, filters: evidenceFilters }))}`,
`历史位置:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'location' } }))}`,
`历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`,
`字段明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'fields', includeFields: 'true', ...(filters.fields?.trim() ? { fields: filters.fields.trim() } : {}) } }))}`,
`里程复核:${appURL(buildAppHash({ page: 'mileage', keyword: vehicle, protocol, filters: evidenceFilters }))}`,
`车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: vehicle, protocol }))}`
].join('\n');
}
function trajectoryImpactPackageText({
filters,
locationCount,
rawCount,
fieldCount,
validPointCount,
coverageRate,
playbackSpanMinutes,
playbackIntervalMinutes,
mileageDelta,
maxSpeed,
anomalySummary,
amapConfigured
}: {
filters: HistoryFilters;
locationCount: number;
rawCount: number;
fieldCount: number;
validPointCount: number;
coverageRate?: number;
playbackSpanMinutes?: number;
playbackIntervalMinutes?: number;
mileageDelta?: number;
maxSpeed?: number;
anomalySummary: TrajectoryAnomalySummary;
amapConfigured: boolean;
}) {
const vehicle = filters.keyword?.trim() || '';
const protocol = filters.protocol?.trim() || '';
const evidenceFilters = {
...(filters.dateFrom?.trim() ? { dateFrom: filters.dateFrom.trim() } : {}),
...(filters.dateTo?.trim() ? { dateTo: filters.dateTo.trim() } : {})
};
const anomalyTotal = anomalySummary.gapCount + anomalySummary.mileageRollbackCount + anomalySummary.overspeedCount;
const operationState = anomalyTotal > 0 || (coverageRate ?? 100) < 80 ? '需要复核' : '可用于业务回放';
return [
'【轨迹运营影响】',
`车辆:${vehicle || '全部车辆'}`,
`数据通道:${protocol || '全部数据通道'}`,
`查询范围:${filters.dateFrom?.trim() || '-'}${filters.dateTo?.trim() || '-'}`,
`业务状态:${operationState}`,
`轨迹范围:位置 ${locationCount.toLocaleString()} / 有效定位 ${validPointCount.toLocaleString()} / 历史明细 ${rawCount.toLocaleString()} / 字段明细 ${fieldCount.toLocaleString()}`,
`定位覆盖率:${formatPercent(coverageRate)}`,
`回放跨度:${formatDurationMinutes(playbackSpanMinutes)},采样间隔:${formatDurationMinutes(playbackIntervalMinutes, '/点')}`,
`里程速度:${formatNumber(mileageDelta, ' km')} / ${formatNumber(maxSpeed, ' km/h')}`,
`异常影响:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`,
`地图能力:${amapConfigured ? '高德 JS API 已配置' : '高德 JS API 待配置'}`,
`轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: vehicle, protocol, filters: evidenceFilters }))}`,
`历史查询导出:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'location' } }))}`,
`历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`,
`车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: vehicle, protocol }))}`
].join('\n');
}
function trajectoryCustomerDecisionText({
filters,
locationCount,
rawCount,
fieldCount,
validPointCount,
coverageRate,
playbackSpanMinutes,
playbackIntervalMinutes,
mileageDelta,
maxSpeed,
firstLocation,
lastLocation,
anomalySummary,
amapConfigured,
deliveryState
}: {
filters: HistoryFilters;
locationCount: number;
rawCount: number;
fieldCount: number;
validPointCount: number;
coverageRate?: number;
playbackSpanMinutes?: number;
playbackIntervalMinutes?: number;
mileageDelta?: number;
maxSpeed?: number;
firstLocation?: HistoryLocationRow;
lastLocation?: HistoryLocationRow;
anomalySummary: TrajectoryAnomalySummary;
amapConfigured: boolean;
deliveryState: string;
}) {
const vehicle = filters.keyword?.trim() || '';
const protocol = filters.protocol?.trim() || '';
const evidenceFilters = {
...(filters.dateFrom?.trim() ? { dateFrom: filters.dateFrom.trim() } : {}),
...(filters.dateTo?.trim() ? { dateTo: filters.dateTo.trim() } : {})
};
const anomalyTotal = anomalySummary.gapCount + anomalySummary.mileageRollbackCount + anomalySummary.overspeedCount;
const decision = locationCount > 0 || rawCount > 0
? anomalyTotal > 0 || (coverageRate ?? 100) < 80 ? '可复盘,需附异常说明' : '可直接复盘'
: '待查询数据';
return [
'【客户轨迹决策说明】',
`决策结论:${decision}`,
`交付状态:${deliveryState}`,
`车辆范围:${vehicle || '全部车辆'}`,
`数据通道:${protocol || '全部数据通道'}`,
`时间范围:${filters.dateFrom?.trim() || '-'}${filters.dateTo?.trim() || '-'}`,
`轨迹规模:位置 ${locationCount.toLocaleString()} / 有效定位 ${validPointCount.toLocaleString()} / 历史明细 ${rawCount.toLocaleString()} / 字段明细 ${fieldCount.toLocaleString()}`,
`定位覆盖:${formatPercent(coverageRate)}`,
`回放跨度:${formatDurationMinutes(playbackSpanMinutes)},采样间隔:${formatDurationMinutes(playbackIntervalMinutes, '/点')}`,
`区间里程:${formatNumber(mileageDelta, ' km')},最高速度:${formatNumber(maxSpeed, ' km/h')}`,
`起点:${firstLocation?.deviceTime || firstLocation?.serverTime || '-'}`,
`终点:${lastLocation?.deviceTime || lastLocation?.serverTime || '-'}`,
`异常提示:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`,
`地图能力:${amapConfigured ? '高德地图可用' : '坐标预览'}`,
'',
'客户复盘路径:',
'1. 先看轨迹覆盖:确认是否有足够有效坐标。',
'2. 再看时间断点:断点会影响定位连续性和客户解释。',
'3. 再看里程速度:区间里程、速度和轨迹方向需要能互相解释。',
'4. 最后回到依据:异常点必须打开历史明细、字段明细和里程统计复核。',
`轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: vehicle, protocol, filters: evidenceFilters }))}`,
`历史查询导出:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'location' } }))}`,
`历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`,
`字段明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'fields', includeFields: 'true', ...(filters.fields?.trim() ? { fields: filters.fields.trim() } : {}) } }))}`,
`里程复核:${appURL(buildAppHash({ page: 'mileage', keyword: vehicle, protocol, filters: evidenceFilters }))}`,
`车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: vehicle, protocol }))}`
].join('\n');
}
function amapLocationName(row: HistoryLocationRow, fallback: string) {
return encodeURIComponent(row.plate || row.vin || fallback);
}
function amapTrajectoryURL(points: HistoryLocationRow[]) {
const validPoints = points.filter(hasValidCoordinate);
if (validPoints.length === 0) return '';
if (validPoints.length === 1) {
const point = validPoints[0];
return `https://uri.amap.com/marker?position=${point.longitude},${point.latitude}&name=${amapLocationName(point, '车辆位置')}&src=lingniu-vehicle-platform`;
}
const start = validPoints[0];
const end = validPoints[validPoints.length - 1];
return [
'https://uri.amap.com/navigation?',
`from=${start.longitude},${start.latitude},${amapLocationName(start, '轨迹起点')}`,
`&to=${end.longitude},${end.latitude},${amapLocationName(end, '轨迹终点')}`,
'&mode=car',
'&policy=1',
'&src=lingniu-vehicle-platform'
].join('');
}
async function copyText(value: string, label: string) {
const text = value.trim();
if (!text) {
Toast.warning(`${label}为空`);
return;
}
try {
await navigator.clipboard.writeText(text);
Toast.success(`已复制${label}`);
} catch {
Toast.error(`复制${label}失败`);
}
}
export function History({
mode = 'trajectory',
initialVin,
initialProtocol,
initialTab,
initialFilters = {},
onFiltersChange,
onOpenVehicle,
onOpenMileage,
onOpenRaw
}: {
mode?: 'trajectory' | 'query';
initialVin: string;
initialProtocol?: string;
initialTab?: string;
initialFilters?: Record<string, string>;
onFiltersChange?: (filters: HistoryFilters, tab?: HistoryTabKey) => void;
onOpenVehicle: (vin: string, protocol?: string) => void;
onOpenMileage?: (filters: Record<string, string>) => void;
onOpenRaw?: (filters: Record<string, string>) => void;
}) {
const initialHistoryFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters);
const [filters, setFilters] = useState<HistoryFilters>(initialHistoryFilters);
const [activeTab, setActiveTab] = useState<HistoryTabKey>(normalizeHistoryTab(initialTab));
const [locations, setLocations] = useState<Page<HistoryLocationRow>>(defaultPage);
const [rawFrames, setRawFrames] = useState<Page<RawFrameRow>>(defaultPage);
const [selectedRaw, setSelectedRaw] = useState<RawFrameRow | null>(null);
const [loadingLocations, setLoadingLocations] = useState(false);
const [loadingRaw, setLoadingRaw] = useState(false);
const [locationPagination, setLocationPagination] = useState({ currentPage: 1, pageSize: 10 });
const [rawPagination, setRawPagination] = useState({ currentPage: 1, pageSize: 10 });
const [playbackIndex, setPlaybackIndex] = useState(0);
const [playbackPlaying, setPlaybackPlaying] = useState(false);
const [playbackSpeedMs, setPlaybackSpeedMs] = useState(1200);
const [reverseGeocodeByPoint, setReverseGeocodeByPoint] = useState<Record<string, MapReverseGeocode>>({});
const [reverseGeocoding, setReverseGeocoding] = useState(false);
const buildParams = (nextFilters: HistoryFilters, limit: number, offset: number, raw: boolean) => {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (nextFilters.keyword?.trim()) params.set('keyword', nextFilters.keyword.trim());
if (nextFilters.protocol?.trim()) params.set('protocol', nextFilters.protocol.trim());
if (nextFilters.dateFrom?.trim()) params.set('dateFrom', nextFilters.dateFrom.trim());
if (nextFilters.dateTo?.trim()) params.set('dateTo', nextFilters.dateTo.trim());
if (raw && isIncludeFieldsEnabled(nextFilters.includeFields)) params.set('includeFields', 'true');
if (raw && nextFilters.fields?.trim()) params.set('fields', nextFilters.fields.trim());
return params;
};
const buildRawQuery = (nextFilters: HistoryFilters, limit: number, offset: number, forceIncludeFields = false): RawFrameQuery => {
const query: RawFrameQuery = { limit, offset };
if (nextFilters.keyword?.trim()) query.keyword = nextFilters.keyword.trim();
if (nextFilters.protocol?.trim()) query.protocol = nextFilters.protocol.trim();
if (nextFilters.dateFrom?.trim()) query.dateFrom = nextFilters.dateFrom.trim();
if (nextFilters.dateTo?.trim()) query.dateTo = nextFilters.dateTo.trim();
if (forceIncludeFields || isIncludeFieldsEnabled(nextFilters.includeFields)) query.includeFields = true;
const fields = splitFields(nextFilters.fields);
if (fields.length > 0) query.fields = fields;
return query;
};
const loadLocations = (nextFilters = filters, page = locationPagination.currentPage, pageSize = locationPagination.pageSize) => {
setLoadingLocations(true);
api.historyLocations(buildParams(nextFilters, pageSize, (page - 1) * pageSize, false))
.then((nextPage) => {
setLocations(nextPage);
setLocationPagination({ currentPage: page, pageSize });
setPlaybackIndex(0);
setPlaybackPlaying(false);
setReverseGeocodeByPoint({});
})
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoadingLocations(false));
};
const loadRawFrames = (nextFilters = filters, page = rawPagination.currentPage, pageSize = rawPagination.pageSize, forceIncludeFields = activeTab === 'fields') => {
if ((forceIncludeFields || isIncludeFieldsEnabled(nextFilters.includeFields)) && shouldBlockRawFieldQuery(nextFilters)) {
setRawFrames({ items: [], total: 0, limit: pageSize, offset: (page - 1) * pageSize });
setRawPagination({ currentPage: page, pageSize });
Toast.warning('字段明细查询需要车辆、时间范围或字段裁剪');
return;
}
setLoadingRaw(true);
api.rawFramesQuery(buildRawQuery(nextFilters, pageSize, (page - 1) * pageSize, forceIncludeFields))
.then((nextPage) => {
setRawFrames(nextPage);
setRawPagination({ currentPage: page, pageSize });
})
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoadingRaw(false));
};
const submit = (values: Record<string, unknown>) => {
const nextFilters: HistoryFilters = {
keyword: String(values.keyword ?? ''),
protocol: String(values.protocol ?? ''),
dateFrom: String(values.dateFrom ?? ''),
dateTo: String(values.dateTo ?? ''),
fields: String(values.fields ?? ''),
includeFields: Boolean(values.includeFields)
};
setFilters(nextFilters);
onFiltersChange?.(nextFilters, activeTab);
loadLocations(nextFilters, 1, locationPagination.pageSize);
loadRawFrames(nextFilters, 1, rawPagination.pageSize, activeTab === 'fields');
};
const applyFilters = (nextFilters: HistoryFilters) => {
setFilters(nextFilters);
onFiltersChange?.(nextFilters, activeTab);
loadLocations(nextFilters, 1, locationPagination.pageSize);
loadRawFrames(nextFilters, 1, rawPagination.pageSize, activeTab === 'fields');
};
const reset = () => {
const nextFilters = mergeInitialFilters(initialVin, initialProtocol, {});
applyFilters(nextFilters);
};
useEffect(() => {
const nextFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters);
const nextTab = normalizeHistoryTab(initialTab);
setFilters(nextFilters);
setActiveTab(nextTab);
loadLocations(nextFilters, 1, locationPagination.pageSize);
loadRawFrames(nextFilters, 1, rawPagination.pageSize, nextTab === 'fields');
}, [initialVin, initialProtocol, initialTab, JSON.stringify(initialFilters)]);
const changeTab = (key: string) => {
const nextTab = normalizeHistoryTab(String(key));
const nextFilters = nextTab === 'fields' ? { ...filters, includeFields: true } : filters;
setActiveTab(nextTab);
setFilters(nextFilters);
onFiltersChange?.(nextFilters, nextTab);
if (nextTab === 'fields') {
loadRawFrames(nextFilters, 1, rawPagination.pageSize, true);
}
};
const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]);
const rawItems = rawFrames.items ?? [];
const rawFieldRows = useMemo<RawFieldRow[]>(() => rawItems.flatMap((row) => {
const fields = flattenParsedFields(row.parsedFields ?? {});
return fields.map((field, index) => ({
id: `${row.id}-${field.path}-${index}`,
rawId: row.id,
vin: row.vin,
plate: row.plate,
protocol: row.protocol,
frameType: row.frameType,
deviceTime: row.deviceTime,
serverTime: row.serverTime,
fieldPath: field.path,
fieldValue: field.value
}));
}), [rawItems]);
const locationItems = locations.items ?? [];
const validLocations = locationItems.filter(hasValidCoordinate);
const mileageValues = locationItems.map((item) => item.totalMileageKm).filter(isFiniteNumber);
const mileageDelta = mileageValues.length > 1 ? Math.max(...mileageValues) - Math.min(...mileageValues) : undefined;
const maxSpeed = locationItems.map((item) => item.speedKmh).filter(isFiniteNumber).reduce<number | undefined>((max, item) => max == null ? item : Math.max(max, item), undefined);
const firstLocation = locationItems[0];
const lastLocation = locationItems[locationItems.length - 1];
const trajectoryCoverageRate = locationItems.length > 0 ? (validLocations.length / locationItems.length) * 100 : undefined;
const playbackStartMs = timeValue(firstLocation);
const playbackEndMs = timeValue(lastLocation);
const playbackSpanMinutes = playbackStartMs != null && playbackEndMs != null ? Math.abs(playbackEndMs - playbackStartMs) / 60000 : undefined;
const playbackIntervalMinutes = isFiniteNumber(playbackSpanMinutes) && locationItems.length > 1 ? playbackSpanMinutes / (locationItems.length - 1) : undefined;
const trajectoryAnomalies = useMemo(() => analyzeTrajectoryAnomalies(locationItems), [locationItems]);
const hasTrajectoryAnomaly = trajectoryAnomalies.gapCount > 0 || trajectoryAnomalies.mileageRollbackCount > 0 || trajectoryAnomalies.overspeedCount > 0;
const currentVehicleKeyword = filters.keyword?.trim() ?? '';
const currentProtocol = filters.protocol?.trim() ?? '';
const pageTitle = mode === 'query' ? '历史查询导出' : '轨迹回放';
const pageDescription = mode === 'query'
? '按车辆、时间和数据通道导出位置记录、历史明细和字段明细,支持分页、字段裁剪和 CSV 导出'
: '按车辆查询历史位置和轨迹回放,历史明细只作为复核依据,数据通道仅用于过滤';
const scopeDescription = mode === 'query'
? '位置记录、历史明细和字段明细按当前车辆与时间范围分页查询。'
: '历史位置和历史明细按当前车辆与时间范围查询。';
const amapConfigured = isAMapConfigured();
const selectedFieldCount = splitFields(filters.fields).length;
const playbackRows = validLocations.length > 0 ? validLocations : locationItems;
const currentPlaybackIndex = playbackRows.length === 0 ? -1 : Math.min(playbackIndex, playbackRows.length - 1);
const currentPlayback = currentPlaybackIndex >= 0 ? playbackRows[currentPlaybackIndex] : undefined;
const currentPlaybackPointId = currentPlayback ? playbackPointId(currentPlayback, currentPlaybackIndex) : undefined;
const currentPlaybackAddress = currentPlaybackPointId ? reverseGeocodeByPoint[currentPlaybackPointId] : undefined;
const playbackAtEnd = playbackRows.length > 0 && currentPlaybackIndex >= playbackRows.length - 1;
const playbackPoints: VehicleMapPoint[] = validLocations.map((row, index) => ({
id: playbackPointId(row, index),
label: row.plate || row.vin || `${index + 1}`,
longitude: row.longitude,
latitude: row.latitude,
online: true,
title: `${row.deviceTime || row.serverTime || '-'} ${row.speedKmh ?? '-'} km/h`
}));
const filterSummary = [
currentVehicleKeyword ? `车辆:${currentVehicleKeyword}` : '',
currentProtocol ? `数据通道:${currentProtocol}` : '',
filters.dateFrom?.trim() ? `开始时间:${filters.dateFrom.trim()}` : '',
filters.dateTo?.trim() ? `结束时间:${filters.dateTo.trim()}` : '',
isIncludeFieldsEnabled(filters.includeFields) ? '返回字段明细' : '',
selectedFieldCount > 0 ? `字段裁剪:${selectedFieldCount}` : ''
].filter(Boolean);
const anomalyTotal = trajectoryAnomalies.gapCount + trajectoryAnomalies.mileageRollbackCount + trajectoryAnomalies.overspeedCount;
const deliveryState = locations.total > 0 || (rawFrames.total ?? 0) > 0
? anomalyTotal > 0 || (trajectoryCoverageRate ?? 100) < 80 ? '可交付,需复核说明' : '可交付'
: '待查询数据';
const deliveryStateColor = deliveryState === '可交付' ? 'green' as const : deliveryState === '待查询数据' ? 'grey' as const : 'orange' as const;
const trajectoryDecisionState = locations.total > 0 || (rawFrames.total ?? 0) > 0
? anomalyTotal > 0 || (trajectoryCoverageRate ?? 100) < 80 ? '可复盘,需说明' : '可直接复盘'
: '待查询数据';
const trajectoryDecisionColor = trajectoryDecisionState === '可直接复盘' ? 'green' as const : trajectoryDecisionState === '待查询数据' ? 'grey' as const : 'orange' as const;
const deliveryScopeText = `${currentVehicleKeyword || '全部车辆'} / ${currentProtocol || '全部数据通道'}`;
const trajectoryDecisionItems = [
{
label: '复盘结论',
value: trajectoryDecisionState,
detail: trajectoryDecisionState === '可直接复盘' ? '轨迹连续性和明细覆盖可用于客户复盘。' : trajectoryDecisionState === '待查询数据' ? '先输入车辆和时间范围查询轨迹。' : '轨迹可用,但需要附带断点或异常说明。',
color: trajectoryDecisionColor,
action: '复制决策',
disabled: false,
onClick: () => copyTrajectoryDecision()
},
{
label: '定位覆盖',
value: formatPercent(trajectoryCoverageRate),
detail: `${validLocations.length.toLocaleString()} / ${locations.total.toLocaleString()} 个有效坐标。`,
color: (trajectoryCoverageRate ?? 0) >= 80 ? 'green' as const : locations.total > 0 ? 'orange' as const : 'grey' as const,
action: '播放轨迹',
disabled: validLocations.length === 0,
onClick: () => openQueryTab('location')
},
{
label: '时间断点',
value: `${trajectoryAnomalies.gapCount.toLocaleString()}`,
detail: trajectoryAnomalies.maxGapMinutes ? `最大断点 ${formatDurationMinutes(trajectoryAnomalies.maxGapMinutes)}` : '当前页未发现超过 30 分钟断点。',
color: trajectoryAnomalies.gapCount > 0 ? 'orange' as const : 'green' as const,
action: '复盘包',
disabled: false,
onClick: () => copyTrajectoryReviewPackage()
},
{
label: '里程速度',
value: formatNumber(mileageDelta, ' km'),
detail: `最高速度 ${formatNumber(maxSpeed, ' km/h')},用于和里程统计互相解释。`,
color: isFiniteNumber(mileageDelta) ? 'blue' as const : 'grey' as const,
action: '里程复核',
disabled: !onOpenMileage,
onClick: () => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) })
},
{
label: '明细复核',
value: `${(rawFrames.total ?? 0).toLocaleString()}`,
detail: rawFieldRows.length > 0 ? `${rawFieldRows.length.toLocaleString()} 个字段明细可导出。` : '异常点需要回到历史明细和字段明细。',
color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
action: '历史明细',
disabled: false,
onClick: () => openQueryTab('raw')
}
];
const deliveryChecklistItems = [
{
title: '位置历史',
value: `${locations.total.toLocaleString()}`,
detail: `${validLocations.length.toLocaleString()} 个有效坐标,交付车辆定位明细。`,
color: locations.total > 0 ? 'green' as const : 'grey' as const,
action: '导出位置',
disabled: locations.items.length === 0,
onClick: () => exportLocations()
},
{
title: '轨迹回放',
value: validLocations.length > 0 ? '可回放' : '待查询',
detail: '同一时间窗回放路线、速度、里程断点。',
color: validLocations.length > 0 ? 'blue' as const : 'orange' as const,
action: '查看轨迹',
disabled: validLocations.length === 0,
onClick: () => openQueryTab('location')
},
{
title: '里程复核',
value: formatNumber(mileageDelta, ' km'),
detail: '进入里程统计核对区间里程和日统计闭合。',
color: isFiniteNumber(mileageDelta) ? 'green' as const : 'grey' as const,
action: '里程复核',
disabled: !onOpenMileage,
onClick: () => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) })
},
{
title: '历史明细',
value: `${(rawFrames.total ?? 0).toLocaleString()}`,
detail: '保留接入明细,用于解释字段值来源。',
color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
action: '导出明细',
disabled: rawFrames.items.length === 0,
onClick: () => exportRawFrames()
},
{
title: '字段明细',
value: `${rawFieldRows.length.toLocaleString()}`,
detail: selectedFieldCount > 0 ? `${selectedFieldCount.toLocaleString()} 个字段裁剪。` : '可切换字段明细生成字段级交付。',
color: rawFieldRows.length > 0 ? 'green' as const : 'orange' as const,
action: '字段明细',
disabled: false,
onClick: () => openQueryTab('fields')
},
{
title: '质量提示',
value: anomalyTotal > 0 ? `${anomalyTotal.toLocaleString()}` : '无明显异常',
detail: `断点 ${trajectoryAnomalies.gapCount.toLocaleString()} / 里程回退 ${trajectoryAnomalies.mileageRollbackCount.toLocaleString()} / 超速 ${trajectoryAnomalies.overspeedCount.toLocaleString()}`,
color: anomalyTotal > 0 ? 'orange' as const : 'green' as const,
action: '复制清单',
disabled: false,
onClick: () => copyDeliveryPackage()
}
];
const clearRangeFilters = () => {
applyFilters(currentVehicleKeyword ? { keyword: currentVehicleKeyword } : {});
};
const exportLocations = () => {
if (locations.items.length === 0) {
Toast.warning('当前没有可导出的位置历史');
return;
}
downloadCsv(exportFileName('history-locations', filters), buildCsv(locationExportColumns, locations.items));
Toast.success(`已导出 ${locations.items.length} 条位置历史`);
};
const exportRawFrames = () => {
if (rawFrames.items.length === 0) {
Toast.warning('当前没有可导出的历史明细');
return;
}
downloadCsv(exportFileName('raw-frames', filters), buildCsv(rawExportColumns, rawFrames.items));
Toast.success(`已导出 ${rawFrames.items.length} 条历史明细`);
};
const exportRawFields = () => {
if (rawFieldRows.length === 0) {
Toast.warning('当前没有可导出的字段明细');
return;
}
downloadCsv(exportFileName('raw-fields', filters), buildCsv(rawFieldExportColumns, rawFieldRows));
Toast.success(`已导出 ${rawFieldRows.length} 条字段明细`);
};
const copyTrajectorySummary = () => {
copyText(
trajectorySummaryText({
filters,
totalPoints: locations.total,
validPointCount: validLocations.length,
mileageDelta,
maxSpeed,
firstLocation,
lastLocation
}),
'轨迹摘要'
);
};
const copyHistoryEvidencePackage = () => {
copyText(
historyEvidencePackageText({
filters,
locationCount: locations.total ?? 0,
rawCount: rawFrames.total ?? 0,
fieldCount: rawFieldRows.length,
validPointCount: validLocations.length,
mileageDelta,
maxSpeed,
firstLocation,
lastLocation,
anomalySummary: trajectoryAnomalies
}),
'数据复核包'
);
};
const copyDeliveryPackage = () => {
copyText(
deliveryPackageText({
filters,
locationCount: locations.total ?? 0,
rawCount: rawFrames.total ?? 0,
fieldCount: rawFieldRows.length,
validPointCount: validLocations.length,
coverageRate: trajectoryCoverageRate,
mileageDelta,
anomalySummary: trajectoryAnomalies
}),
'客户查询导出包'
);
};
const copyTrajectoryReviewPackage = () => {
copyText(
trajectoryReviewPackageText({
filters,
locationCount: locations.total ?? 0,
rawCount: rawFrames.total ?? 0,
fieldCount: rawFieldRows.length,
validPointCount: validLocations.length,
coverageRate: trajectoryCoverageRate,
playbackSpanMinutes,
playbackIntervalMinutes,
mileageDelta,
maxSpeed,
firstLocation,
lastLocation,
currentPlayback,
currentPlaybackIndex,
playbackCount: playbackRows.length,
currentAddress: currentPlaybackAddress,
anomalySummary: trajectoryAnomalies
}),
'轨迹复盘交接包'
);
};
const copyTrajectoryImpactPackage = () => {
copyText(
trajectoryImpactPackageText({
filters,
locationCount: locations.total ?? 0,
rawCount: rawFrames.total ?? 0,
fieldCount: rawFieldRows.length,
validPointCount: validLocations.length,
coverageRate: trajectoryCoverageRate,
playbackSpanMinutes,
playbackIntervalMinutes,
mileageDelta,
maxSpeed,
anomalySummary: trajectoryAnomalies,
amapConfigured
}),
'轨迹运营影响'
);
};
const copyTrajectoryDecision = () => {
copyText(
trajectoryCustomerDecisionText({
filters,
locationCount: locations.total ?? 0,
rawCount: rawFrames.total ?? 0,
fieldCount: rawFieldRows.length,
validPointCount: validLocations.length,
coverageRate: trajectoryCoverageRate,
playbackSpanMinutes,
playbackIntervalMinutes,
mileageDelta,
maxSpeed,
firstLocation,
lastLocation,
anomalySummary: trajectoryAnomalies,
amapConfigured,
deliveryState
}),
'客户轨迹决策说明'
);
};
const openAmapTrajectory = () => {
const url = amapTrajectoryURL(validLocations);
if (!url) {
Toast.warning('当前轨迹没有有效坐标');
return;
}
window.open(url, '_blank', 'noopener,noreferrer');
};
const resolveCurrentPlaybackAddress = () => {
if (!currentPlayback || !currentPlaybackPointId || !hasValidCoordinate(currentPlayback)) {
Toast.warning('当前回放点没有有效坐标');
return;
}
if (currentPlaybackAddress) {
Toast.info('当前回放点地址已解析');
return;
}
const params = new URLSearchParams({
longitude: String(currentPlayback.longitude),
latitude: String(currentPlayback.latitude)
});
setReverseGeocoding(true);
api.reverseGeocode(params)
.then((address) => {
setReverseGeocodeByPoint((current) => ({ ...current, [currentPlaybackPointId]: address }));
Toast.success('已解析当前点地址');
})
.catch((error: Error) => Toast.error(error.message))
.finally(() => setReverseGeocoding(false));
};
useEffect(() => {
if (!playbackPlaying || playbackRows.length <= 1) return undefined;
const timer = window.setInterval(() => {
setPlaybackIndex((current) => {
if (current >= playbackRows.length - 1) {
setPlaybackPlaying(false);
return current;
}
const next = current + 1;
if (next >= playbackRows.length - 1) setPlaybackPlaying(false);
return next;
});
}, playbackSpeedMs);
return () => window.clearInterval(timer);
}, [playbackPlaying, playbackRows.length, playbackSpeedMs]);
const togglePlayback = () => {
if (playbackPlaying) {
setPlaybackPlaying(false);
return;
}
if (playbackRows.length <= 1) return;
if (playbackAtEnd) setPlaybackIndex(0);
setPlaybackPlaying(true);
};
const movePlayback = (delta: number) => {
if (playbackRows.length === 0) return;
setPlaybackPlaying(false);
setPlaybackIndex((current) => Math.min(Math.max(current + delta, 0), playbackRows.length - 1));
};
const selectPlaybackPoint = (nextIndex: number) => {
if (playbackRows.length === 0) return;
setPlaybackPlaying(false);
setPlaybackIndex(Math.min(Math.max(nextIndex, 0), playbackRows.length - 1));
};
const selectPlaybackMapPoint = (point: VehicleMapPoint) => {
const index = playbackRows.findIndex((row, rowIndex) => playbackPointId(row, rowIndex) === point.id);
if (index >= 0) {
selectPlaybackPoint(index);
}
};
const openPlaybackVehicle = () => {
if (!currentPlayback || !canOpenVehicle(currentPlayback.vin)) return;
onOpenVehicle(currentPlayback.vin, currentPlayback.protocol);
};
const openLocationMileage = (row: HistoryLocationRow) => {
if (!canOpenVehicle(row.vin)) return;
const day = dateOnly(row.deviceTime || row.serverTime || row.lastSeen);
onOpenMileage?.({
keyword: row.vin,
protocol: row.protocol,
...(day ? { dateFrom: day, dateTo: nextDate(day) } : {})
});
};
const openLocationRaw = (row: HistoryLocationRow) => {
if (!canOpenVehicle(row.vin)) return;
const day = dateOnly(row.deviceTime || row.serverTime || row.lastSeen);
onOpenRaw?.({
keyword: row.vin,
protocol: row.protocol,
...(day ? { dateFrom: day, dateTo: nextDate(day) } : {}),
includeFields: 'true'
});
};
const openRawMileage = (row: RawFrameRow) => {
if (!canOpenVehicle(row.vin)) return;
const day = dateOnly(row.deviceTime || row.serverTime);
onOpenMileage?.({
keyword: row.vin,
protocol: row.protocol,
...(day ? { dateFrom: day, dateTo: nextDate(day) } : {})
});
};
const openQueryTab = (tab: HistoryTabKey) => {
const nextFilters = tab === 'fields' ? { ...filters, includeFields: true } : filters;
setActiveTab(tab);
setFilters(nextFilters);
onFiltersChange?.(nextFilters, tab);
if (tab === 'location') {
loadLocations(nextFilters, 1, locationPagination.pageSize);
return;
}
loadRawFrames(nextFilters, 1, rawPagination.pageSize, tab === 'fields');
};
return (
<div className="vp-page">
<PageHeader
title={pageTitle}
description={pageDescription}
actions={(
<Space>
<Button icon={<IconCopy />} onClick={copyHistoryEvidencePackage}></Button>
<Button disabled={!currentVehicleKeyword} onClick={() => onOpenVehicle(currentVehicleKeyword, currentProtocol)}>
</Button>
</Space>
)}
/>
<div className="vp-scope-bar">
<span className="vp-scope-label">{currentVehicleKeyword || '-'}</span>
<Tag color={currentProtocol ? 'blue' : 'green'}>{currentProtocol || '全部数据通道'}</Tag>
<Typography.Text type="tertiary">{scopeDescription}</Typography.Text>
</div>
{mode === 'query' ? (
<Card bordered className="vp-history-customer-export-board" bodyStyle={{ padding: 0 }}>
<div className="vp-history-customer-export-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
<Tag color={activeTab === 'location' ? 'green' : activeTab === 'raw' ? 'blue' : 'orange'}>
{activeTab === 'location' ? '位置历史' : activeTab === 'raw' ? '历史明细' : '字段明细'}
</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">
CSV
</Typography.Text>
<Space wrap>
<Button size="small" theme="solid" type="primary" onClick={() => openQueryTab('location')}></Button>
<Button size="small" theme="light" type="primary" onClick={() => openQueryTab('raw')}></Button>
<Button size="small" theme="light" type="primary" onClick={() => openQueryTab('fields')}></Button>
<Button size="small" theme="light" onClick={copyDeliveryPackage}></Button>
</Space>
</div>
<div className="vp-history-customer-export-steps">
{[
{
step: '01',
title: '选择范围',
value: currentVehicleKeyword || '全部车辆',
detail: `${currentProtocol || '全部数据通道'} / ${filters.dateFrom || '-'}${filters.dateTo || '-'}`,
action: '调整筛选',
color: currentVehicleKeyword ? 'green' as const : 'blue' as const,
disabled: false,
onClick: () => document.querySelector<HTMLInputElement>('input[name="keyword"]')?.focus()
},
{
step: '02',
title: '位置历史',
value: `${locations.total.toLocaleString()}`,
detail: `${validLocations.length.toLocaleString()} 个有效坐标,可用于定位复盘和轨迹回放。`,
action: '导出位置',
color: locations.total > 0 ? 'green' as const : 'grey' as const,
disabled: locations.items.length === 0,
onClick: exportLocations
},
{
step: '03',
title: '历史明细',
value: `${(rawFrames.total ?? 0).toLocaleString()}`,
detail: '保留接入明细,用于解释位置、里程和字段来源。',
action: '导出明细',
color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
disabled: rawFrames.items.length === 0,
onClick: exportRawFrames
},
{
step: '04',
title: '字段明细',
value: `${rawFieldRows.length.toLocaleString()}`,
detail: selectedFieldCount > 0 ? `已裁剪 ${selectedFieldCount.toLocaleString()} 个字段。` : '需要字段级交付时切换字段明细或填写字段裁剪。',
action: '字段明细',
color: rawFieldRows.length > 0 ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => openQueryTab('fields')
},
{
step: '05',
title: '交付说明',
value: deliveryState,
detail: anomalyTotal > 0 ? `存在 ${anomalyTotal.toLocaleString()} 项质量提示,交付时需说明。` : '当前范围未发现明显轨迹质量提示。',
action: '复制交付',
color: deliveryStateColor,
disabled: false,
onClick: copyDeliveryPackage
}
].map((item) => (
<button
key={item.step}
type="button"
className="vp-history-customer-export-step"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`客户数据交付中心 ${item.title} ${item.action}`}
>
<span>{item.step}</span>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<small>{item.detail}</small>
<em>{item.action}</em>
</button>
))}
</div>
</Card>
) : null}
<Card
bordered
title={<Space><span></span><Button size="small" icon={<IconCopy />} onClick={copyTrajectoryDecision}></Button></Space>}
style={{ marginTop: 16 }}
>
<div className="vp-trajectory-decision-board">
<div className="vp-trajectory-decision-summary">
<Space wrap>
<Tag color={trajectoryDecisionColor}>{trajectoryDecisionState}</Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? '高德地图可用' : '坐标预览'}</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
<Space wrap>
<Button size="small" theme="solid" type="primary" icon={<IconCopy />} onClick={copyTrajectoryDecision}></Button>
<Button size="small" disabled={validLocations.length === 0} onClick={() => openQueryTab('location')}></Button>
<Button size="small" disabled={(rawFrames.total ?? 0) === 0} onClick={() => openQueryTab('raw')}></Button>
<Button size="small" disabled={!onOpenMileage} onClick={() => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) })}></Button>
</Space>
</div>
<div className="vp-trajectory-decision-grid">
{trajectoryDecisionItems.map((item) => (
<button
key={item.label}
type="button"
className="vp-trajectory-decision-item"
disabled={item.disabled}
aria-label={`客户轨迹决策 ${item.label} ${item.action}`}
onClick={item.onClick}
>
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</div>
</Card>
{mode === 'query' ? (
<Card bordered title="历史查询与导出工作台" style={{ marginTop: 16 }}>
<div className="vp-history-query-workbench">
<div className="vp-history-query-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={activeTab === 'location' ? 'green' : 'blue'}>
{activeTab === 'location' ? '位置历史' : activeTab === 'raw' ? '历史明细' : '字段明细'}
</Tag>
</Space>
<Typography.Text strong>{currentVehicleKeyword || '全部车辆'}</Typography.Text>
<Typography.Text type="secondary">
BI
</Typography.Text>
<Typography.Text type="tertiary">
VIN
</Typography.Text>
<Space wrap>
<Button size="small" theme="solid" type="primary" onClick={() => openQueryTab('location')}></Button>
<Button size="small" onClick={() => openQueryTab('raw')}></Button>
<Button size="small" onClick={() => openQueryTab('fields')}></Button>
</Space>
</div>
<div className="vp-history-query-grid">
{[
{
label: '位置记录',
value: locations.total.toLocaleString(),
detail: `${validLocations.length.toLocaleString()} 个有效坐标,可用于轨迹和定位复盘。`,
action: '导出位置',
color: 'green' as const,
onClick: exportLocations
},
{
label: '历史明细',
value: `${(rawFrames.total ?? 0).toLocaleString()}`,
detail: '保存协议接入后的历史明细,可按车辆和时间追溯。',
action: '导出明细',
color: 'blue' as const,
onClick: exportRawFrames
},
{
label: '字段明细',
value: rawFieldRows.length.toLocaleString(),
detail: selectedFieldCount > 0 ? `已裁剪 ${selectedFieldCount.toLocaleString()} 个字段。` : '可勾选字段明细或填写字段裁剪后查询。',
action: '导出字段',
color: rawFieldRows.length > 0 ? 'green' as const : 'grey' as const,
onClick: exportRawFields
},
{
label: '服务联动',
value: currentVehicleKeyword ? '单车' : '批量',
detail: currentVehicleKeyword ? '可进入车辆服务、轨迹回放和里程核对。' : '输入 VIN、车牌或手机号后可进入单车服务。',
action: '车辆服务',
color: currentVehicleKeyword ? 'blue' as const : 'grey' as const,
onClick: () => currentVehicleKeyword && onOpenVehicle(currentVehicleKeyword, currentProtocol)
}
].map((item) => (
<div key={item.label} className="vp-history-query-item">
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
<Button size="small" disabled={item.label === '服务联动' && !currentVehicleKeyword} onClick={item.onClick}>{item.action}</Button>
</div>
))}
</div>
</div>
</Card>
) : null}
{mode === 'query' ? (
<Card bordered title="客户查询导出包" style={{ marginTop: 16 }}>
<div className="vp-history-package-board">
<div className="vp-history-package-summary">
<Space wrap>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
<Tag color={currentProtocol ? 'blue' : 'green'}>{currentProtocol || '全部数据通道'}</Tag>
</Space>
<Typography.Text strong>{deliveryScopeText}</Typography.Text>
<Typography.Text type="secondary">
</Typography.Text>
<Space wrap>
<Button size="small" theme="solid" type="primary" icon={<IconCopy />} onClick={copyDeliveryPackage}>
</Button>
<Button size="small" disabled={!currentVehicleKeyword} onClick={() => onOpenVehicle(currentVehicleKeyword, currentProtocol)}>
</Button>
<Button size="small" disabled={!onOpenMileage} onClick={() => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) })}>
</Button>
</Space>
</div>
<div className="vp-history-package-grid">
{[
{
label: '位置历史',
value: `${locations.total.toLocaleString()}`,
detail: `${validLocations.length.toLocaleString()} 个有效坐标,覆盖率 ${formatPercent(trajectoryCoverageRate)}`,
color: locations.total > 0 ? 'green' as const : 'grey' as const,
action: '导出位置',
disabled: locations.items.length === 0,
onClick: exportLocations
},
{
label: '历史明细',
value: `${(rawFrames.total ?? 0).toLocaleString()}`,
detail: '用于追溯数据来自接入协议解析后的真实明细。',
color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
action: '导出明细',
disabled: rawFrames.items.length === 0,
onClick: exportRawFrames
},
{
label: '字段明细',
value: `${rawFieldRows.length.toLocaleString()}`,
detail: selectedFieldCount > 0 ? `${selectedFieldCount.toLocaleString()} 个字段裁剪。` : '需要字段级交付时切换字段明细。',
color: rawFieldRows.length > 0 ? 'green' as const : 'orange' as const,
action: '字段明细',
disabled: false,
onClick: () => openQueryTab('fields')
},
{
label: '质量提示',
value: anomalyTotal > 0 ? `${anomalyTotal.toLocaleString()}` : '无明显异常',
detail: anomalyTotal > 0
? `断点 ${trajectoryAnomalies.gapCount.toLocaleString()} / 里程回退 ${trajectoryAnomalies.mileageRollbackCount.toLocaleString()} / 超速 ${trajectoryAnomalies.overspeedCount.toLocaleString()}`
: '当前页未发现断点、里程回退或超速。',
color: anomalyTotal > 0 ? 'orange' as const : 'green' as const,
action: '复制说明',
disabled: false,
onClick: copyDeliveryPackage
}
].map((item) => (
<button
key={item.label}
type="button"
className="vp-history-package-item"
disabled={item.disabled}
aria-label={`客户查询导出 ${item.label} ${item.action}`}
onClick={item.onClick}
>
<span>
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
</span>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
<em>{item.action}</em>
</button>
))}
</div>
</div>
</Card>
) : null}
{mode === 'query' ? (
<Card
bordered
title={<Space><span></span><Button size="small" icon={<IconCopy />} onClick={copyDeliveryPackage}></Button></Space>}
style={{ marginTop: 16 }}
>
<div className="vp-history-checklist-grid">
{deliveryChecklistItems.map((item) => (
<button
key={item.title}
type="button"
className="vp-history-checklist-item"
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>
) : null}
<Card bordered>
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => submit(values)}>
<Form.Input field="keyword" label="车辆关键词" placeholder="VIN / 车牌 / 手机号" style={{ width: 260 }} />
<Form.Select field="protocol" label="数据通道" placeholder="全部数据通道" style={{ width: 190 }}>
<Select.Option value="GB32960">GB32960</Select.Option>
<Select.Option value="JT808">JT808</Select.Option>
<Select.Option value="YUTONG_MQTT">YUTONG_MQTT</Select.Option>
</Form.Select>
<Form.Input field="dateFrom" label="开始时间" placeholder="2026-07-03 00:00:00" style={{ width: 210 }} />
<Form.Input field="dateTo" label="结束时间" placeholder="2026-07-03 23:59:59" style={{ width: 210 }} />
<Form.Checkbox field="includeFields" noLabel>
</Form.Checkbox>
<Form.TextArea
field="fields"
label="字段裁剪"
placeholder="可选,逗号分隔,如 jt808.header.phone,jt808.location.longitude"
autosize={{ minRows: 1, maxRows: 3 }}
style={{ width: 420 }}
/>
<Space align="start">
<Button icon={<IconSearch />} htmlType="submit" theme="solid" type="primary"></Button>
<Button icon={<IconRefresh />} onClick={reset}></Button>
</Space>
</Form>
</Card>
{filterSummary.length > 0 ? (
<Card bordered title="当前历史筛选" style={{ marginTop: 16 }}>
<Space wrap>
{filterSummary.map((item) => (
<Tag key={item} color="blue">{item}</Tag>
))}
<Button size="small" onClick={clearRangeFilters}></Button>
</Space>
</Card>
) : null}
<Card bordered title="历史查询导出任务板" style={{ marginTop: 16 }}>
<div className="vp-history-delivery-grid">
{[
{
title: '轨迹回放',
tag: validLocations.length > 0 ? '可回放' : '待查询',
tagColor: validLocations.length > 0 ? 'green' as const : 'orange' as const,
value: `${validLocations.length.toLocaleString()} 个有效点`,
detail: currentVehicleKeyword
? '围绕当前车辆生成可播放轨迹,用于定位复盘和客户问询。'
: '先输入 VIN、车牌或手机号再生成单车轨迹。',
primaryText: '播放轨迹',
secondaryText: '复制摘要',
disabled: validLocations.length === 0,
onPrimary: () => openQueryTab('location'),
onSecondary: copyTrajectorySummary
},
{
title: '位置导出',
tag: locations.total > 0 ? '可交付' : '无数据',
tagColor: locations.total > 0 ? 'green' as const : 'grey' as const,
value: `${locations.total.toLocaleString()} 条位置`,
detail: '导出当前筛选范围的位置历史支撑客户定位、BI 核对和离线复盘。',
primaryText: '导出位置',
secondaryText: '车辆服务',
disabled: locations.items.length === 0,
secondaryDisabled: !currentVehicleKeyword,
onPrimary: exportLocations,
onSecondary: () => currentVehicleKeyword && onOpenVehicle(currentVehicleKeyword, currentProtocol)
},
{
title: '历史明细',
tag: (rawFrames.total ?? 0) > 0 ? '有明细' : '待加载',
tagColor: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'orange' as const,
value: `${(rawFrames.total ?? 0).toLocaleString()}`,
detail: '保留接入侧历史明细,用于解释位置、里程和字段值的来源。',
primaryText: '看明细',
secondaryText: '导出明细',
disabled: false,
secondaryDisabled: rawFrames.items.length === 0,
onPrimary: () => openQueryTab('raw'),
onSecondary: exportRawFrames
},
{
title: '字段裁剪',
tag: selectedFieldCount > 0 ? `${selectedFieldCount} 个字段` : '全量字段',
tagColor: rawFieldRows.length > 0 ? 'green' as const : 'blue' as const,
value: `${rawFieldRows.length.toLocaleString()} 个字段`,
detail: selectedFieldCount > 0
? '按配置字段返回明细,减少接口体积并加快复核。'
: '需要字段级核对时,切到字段明细或填写字段裁剪。',
primaryText: '字段明细',
secondaryText: '导出字段',
disabled: false,
secondaryDisabled: rawFieldRows.length === 0,
onPrimary: () => openQueryTab('fields'),
onSecondary: exportRawFields
}
].map((task) => (
<div key={task.title} className="vp-history-delivery-item">
<div className="vp-history-delivery-head">
<Typography.Text strong>{task.title}</Typography.Text>
<Tag color={task.tagColor}>{task.tag}</Tag>
</div>
<strong>{task.value}</strong>
<Typography.Text type="secondary">{task.detail}</Typography.Text>
<Space wrap>
<Button size="small" theme="solid" type="primary" disabled={task.disabled} onClick={task.onPrimary}>
{task.primaryText}
</Button>
<Button size="small" disabled={task.secondaryDisabled} onClick={task.onSecondary}>
{task.secondaryText}
</Button>
</Space>
</div>
))}
</div>
</Card>
<Card bordered title={mode === 'query' ? '历史数据质量' : '轨迹运营影响'} style={{ marginTop: 16 }}>
<div className="vp-trajectory-impact-board">
<div className="vp-trajectory-impact-summary">
<Space wrap>
<Tag color={hasTrajectoryAnomaly ? 'orange' : 'green'}>
{hasTrajectoryAnomaly ? '需要复核' : '可用于业务回放'}
</Tag>
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? '地图已接入' : '地图待接入'}</Tag>
</Space>
<Typography.Text strong>{currentVehicleKeyword || '全部车辆'}</Typography.Text>
<Typography.Text type="secondary">
</Typography.Text>
<Space wrap>
<Button size="small" icon={<IconCopy />} onClick={copyTrajectoryImpactPackage}></Button>
<Button size="small" disabled={!currentVehicleKeyword} onClick={() => onOpenVehicle(currentVehicleKeyword, currentProtocol)}></Button>
<Button size="small" disabled={!onOpenMileage} onClick={() => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) })}></Button>
</Space>
</div>
<div className="vp-trajectory-impact-grid">
{[
{
label: '轨迹范围',
value: `${locations.total.toLocaleString()}`,
detail: `${validLocations.length.toLocaleString()} 个有效坐标,当前页覆盖可回放轨迹。`,
color: 'blue' as const
},
{
label: '定位覆盖',
value: formatPercent(trajectoryCoverageRate),
detail: `回放跨度 ${formatDurationMinutes(playbackSpanMinutes)},采样 ${formatDurationMinutes(playbackIntervalMinutes, '/点')}`,
color: (trajectoryCoverageRate ?? 0) >= 80 ? 'green' as const : 'orange' as const
},
{
label: '异常影响',
value: `${(trajectoryAnomalies.gapCount + trajectoryAnomalies.mileageRollbackCount + trajectoryAnomalies.overspeedCount).toLocaleString()}`,
detail: `断点 ${trajectoryAnomalies.gapCount.toLocaleString()},里程回退 ${trajectoryAnomalies.mileageRollbackCount.toLocaleString()},超速 ${trajectoryAnomalies.overspeedCount.toLocaleString()}`,
color: hasTrajectoryAnomaly ? 'orange' as const : 'green' as const
},
{
label: '历史明细',
value: `${(rawFrames.total ?? 0).toLocaleString()}`,
detail: rawFieldRows.length > 0 ? `${rawFieldRows.length.toLocaleString()} 个字段明细可直接导出。` : '可切换历史明细并请求字段明细。',
color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const
},
{
label: '地图能力',
value: amapConfigured ? '高德可用' : '待配置',
detail: amapConfigured ? '可打开高德线路并解析当前回放点地址。' : '配置高德 Web 服务和 JS API 后启用地图能力。',
color: amapConfigured ? 'green' as const : 'orange' as const
}
].map((item) => (
<div key={item.label} className="vp-trajectory-impact-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>{mode === 'query' ? '轨迹预览与复核' : '轨迹回放作业台'}</span>
<Button size="small" theme="light" icon={<IconCopy />} onClick={copyTrajectorySummary}></Button>
<Button size="small" theme="light" icon={<IconCopy />} onClick={copyHistoryEvidencePackage}></Button>
<Button size="small" theme="light" icon={<IconCopy />} onClick={copyTrajectoryReviewPackage}></Button>
<Button size="small" theme="light" disabled={validLocations.length === 0} onClick={openAmapTrajectory}>线</Button>
</Space>
)}
style={{ marginTop: 16 }}
>
<div className="vp-playback-layout">
<div className="vp-playback-map">
<div className="vp-monitor-map-header">
<Space wrap>
<Tag color="blue">{validLocations.length.toLocaleString()} </Tag>
<Tag color={currentProtocol ? 'blue' : 'green'}>{currentProtocol || '全部数据通道'}</Tag>
<Tag color="green">{formatNumber(mileageDelta, ' km')}</Tag>
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? '高德地图配置就绪' : '高德地图待配置'}</Tag>
</Space>
</div>
<VehicleMap
points={playbackPoints}
mode="track"
selectedId={currentPlaybackPointId}
onPointSelect={selectPlaybackMapPoint}
fallbackLabel="高德地图未配置,显示轨迹坐标预览"
/>
</div>
<div className="vp-playback-side">
{[
{ label: '轨迹点', value: locations.total.toLocaleString(), color: 'blue' as const },
{ label: '有效定位', value: validLocations.length.toLocaleString(), color: 'green' as const },
{ label: '区间里程', value: formatNumber(mileageDelta, ' km'), color: 'green' as const },
{ label: '最高速度', value: formatNumber(maxSpeed, ' km/h'), color: 'orange' as const }
].map((item) => (
<div key={item.label} className="vp-monitor-metric">
<Tag color={item.color}>{item.label}</Tag>
<div className="vp-monitor-metric-value">{item.value}</div>
</div>
))}
<div className="vp-playback-current">
<div className="vp-map-service-queue-title"></div>
{currentPlayback ? (
<>
<Space wrap>
<Tag color="blue"> {currentPlaybackIndex + 1} / {playbackRows.length}</Tag>
<Tag color={currentPlayback.protocol ? 'blue' : 'grey'}>{currentPlayback.protocol || '-'}</Tag>
</Space>
<Typography.Text strong>{currentPlayback.plate || currentPlayback.vin}</Typography.Text>
<Typography.Text type="tertiary" size="small">{currentPlayback.deviceTime || currentPlayback.serverTime || '-'}</Typography.Text>
<Space wrap>
<Tag color="green">{formatNumber(currentPlayback.speedKmh, ' km/h')}</Tag>
<Tag color="blue">{formatNumber(currentPlayback.totalMileageKm, ' km')}</Tag>
</Space>
<div className="vp-playback-address">
<Tag color={currentPlaybackAddress ? 'green' : 'grey'}>{currentPlaybackAddress?.provider || '地址未解析'}</Tag>
<Typography.Text type={currentPlaybackAddress ? 'secondary' : 'tertiary'} ellipsis={{ showTooltip: true }}>
{currentPlaybackAddress?.formattedAddress || `${currentPlayback.longitude}, ${currentPlayback.latitude}`}
</Typography.Text>
</div>
<Space wrap>
<Button
size="small"
aria-label={playbackPlaying ? '暂停轨迹' : '播放轨迹'}
disabled={playbackRows.length <= 1}
onClick={togglePlayback}
>
{playbackPlaying ? '暂停' : playbackAtEnd ? '重播' : '播放'}
</Button>
<Typography.Text type="secondary" size="small"></Typography.Text>
<Select
size="small"
value={String(playbackSpeedMs)}
style={{ width: 108 }}
onChange={(value) => setPlaybackSpeedMs(Number(value))}
>
<Select.Option value="1800"></Select.Option>
<Select.Option value="1200"></Select.Option>
<Select.Option value="600"></Select.Option>
</Select>
</Space>
<Space wrap>
<Button size="small" disabled={currentPlaybackIndex <= 0} onClick={() => movePlayback(-1)}></Button>
<Button size="small" disabled={currentPlaybackIndex >= playbackRows.length - 1} onClick={() => movePlayback(1)}></Button>
<Button size="small" loading={reverseGeocoding} disabled={!hasValidCoordinate(currentPlayback)} onClick={resolveCurrentPlaybackAddress}></Button>
<Button size="small" disabled={!canOpenVehicle(currentPlayback.vin)} onClick={openPlaybackVehicle}></Button>
</Space>
<input
className="vp-playback-range"
aria-label="轨迹回放进度"
type="range"
min={0}
max={Math.max(playbackRows.length - 1, 0)}
step={1}
value={currentPlaybackIndex}
onChange={(event) => selectPlaybackPoint(Number(event.currentTarget.value))}
/>
</>
) : (
<Typography.Text type="tertiary"></Typography.Text>
)}
</div>
</div>
</div>
<div className="vp-playback-timeline">
{(validLocations.length > 0 ? validLocations : locationItems).slice(0, 8).map((row, index) => (
<button
key={`${row.deviceTime}-${index}`}
type="button"
className={`vp-playback-step ${index === currentPlaybackIndex ? 'vp-playback-step-active' : ''}`}
aria-label={`选择轨迹点 ${index + 1}`}
onClick={() => selectPlaybackPoint(index)}
>
<Tag color={index === 0 ? 'green' : index === Math.min((validLocations.length || locationItems.length), 8) - 1 ? 'orange' : 'blue'}>
{index === 0 ? '起点' : index === Math.min((validLocations.length || locationItems.length), 8) - 1 ? '终点' : `${index + 1}`}
</Tag>
<div className="vp-evidence-value">{row.deviceTime || row.serverTime || '-'}</div>
<Typography.Text type="secondary">
{formatNumber(row.speedKmh, ' km/h')} / {formatNumber(row.totalMileageKm, ' km')}
</Typography.Text>
</button>
))}
{locationItems.length === 0 ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
</div>
<Card bordered title="轨迹复盘质量" style={{ marginTop: 12 }}>
<div className="vp-stat-insight-grid">
{[
{
label: '定位覆盖率',
value: formatPercent(trajectoryCoverageRate),
color: (trajectoryCoverageRate ?? 0) >= 80 ? 'green' as const : 'orange' as const,
detail: `${validLocations.length.toLocaleString()} / ${locationItems.length.toLocaleString()} 个点有有效坐标。`
},
{
label: '回放跨度',
value: formatDurationMinutes(playbackSpanMinutes),
color: isFiniteNumber(playbackSpanMinutes) ? 'blue' as const : 'grey' as const,
detail: '当前页首末轨迹点设备时间跨度。'
},
{
label: '采样间隔',
value: formatDurationMinutes(playbackIntervalMinutes, '/点'),
color: isFiniteNumber(playbackIntervalMinutes) && playbackIntervalMinutes <= 10 ? 'green' as const : 'orange' as const,
detail: '当前页估算的平均轨迹采样间隔。'
}
].map((item) => (
<Card key={item.label} bordered>
<Tag color={item.color}>{item.label}</Tag>
<div className="vp-monitor-metric-value">{item.value}</div>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
</Card>
))}
</div>
</Card>
<Card bordered title="轨迹异常判读" style={{ marginTop: 12 }}>
<div className="vp-trajectory-anomaly-grid">
{[
{
label: '数据断点',
value: trajectoryAnomalies.gapCount > 0 ? `${trajectoryAnomalies.gapCount.toLocaleString()}` : '无',
detail: trajectoryAnomalies.gapCount > 0 ? `最大断点 ${formatDurationMinutes(trajectoryAnomalies.maxGapMinutes)}` : '相邻轨迹点未发现超过 30 分钟断点。',
color: trajectoryAnomalies.gapCount > 0 ? 'orange' as const : 'green' as const
},
{
label: '里程回退',
value: trajectoryAnomalies.mileageRollbackCount > 0 ? formatNumber(trajectoryAnomalies.maxMileageRollbackKm, ' km') : '无',
detail: trajectoryAnomalies.mileageRollbackCount > 0 ? `${trajectoryAnomalies.mileageRollbackCount.toLocaleString()} 处总里程低于上一点。` : '当前页总里程单调不回退。',
color: trajectoryAnomalies.mileageRollbackCount > 0 ? 'red' as const : 'green' as const
},
{
label: '速度异常',
value: trajectoryAnomalies.overspeedCount > 0 ? formatNumber(trajectoryAnomalies.maxSpeedKmh, ' km/h') : '无',
detail: trajectoryAnomalies.overspeedCount > 0 ? `${trajectoryAnomalies.overspeedCount.toLocaleString()} 个点超过 120 km/h。` : '当前页未发现超过 120 km/h 的速度点。',
color: trajectoryAnomalies.overspeedCount > 0 ? 'orange' as const : 'green' as const
}
].map((item) => (
<div key={item.label} className="vp-trajectory-anomaly-item">
<Tag color={item.color}>{item.label}</Tag>
<div className="vp-monitor-metric-value">{item.value}</div>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
</div>
))}
</div>
<div className="vp-trajectory-anomaly-action">
<Tag color={hasTrajectoryAnomaly ? 'orange' : 'green'}>{hasTrajectoryAnomaly ? '建议核对历史明细和当日里程统计' : '轨迹质量可用'}</Tag>
<Typography.Text type="secondary">
{hasTrajectoryAnomaly ? '异常点会影响轨迹回放、定位复盘和区间里程判断,请结合字段明细与里程统计交叉确认。' : '当前页轨迹未发现明显断点、里程回退或速度异常。'}
</Typography.Text>
</div>
</Card>
<Space wrap style={{ marginTop: 12 }}>
<Tag color="grey">{firstLocation?.deviceTime || firstLocation?.serverTime || '-'}</Tag>
<Tag color="grey">{lastLocation?.deviceTime || lastLocation?.serverTime || '-'}</Tag>
<Tag color="grey"> JS API </Tag>
</Space>
</Card>
<Card bordered style={{ marginTop: 16 }}>
<Tabs activeKey={activeTab} onChange={(key) => changeTab(String(key))}>
<Tabs.TabPane tab="位置历史" itemKey="location">
<div className="vp-table-toolbar">
<Space wrap>
<Tag color="blue"> {locations.items.length.toLocaleString()} </Tag>
<Button size="small" onClick={exportLocations}> CSV</Button>
</Space>
</div>
<Table
rowKey="deviceTime"
dataSource={locations.items}
loading={loadingLocations}
pagination={{
currentPage: locationPagination.currentPage,
pageSize: locationPagination.pageSize,
total: locations.total,
showSizeChanger: true,
onPageChange: (page) => loadLocations(filters, page, locationPagination.pageSize),
onPageSizeChange: (pageSize) => loadLocations(filters, 1, pageSize)
}}
columns={[
{ title: 'VIN', dataIndex: 'vin', width: 190 },
{ title: '数据通道', dataIndex: 'protocol', width: 120 },
{ 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 },
{
title: '操作',
width: 280,
render: (_: unknown, row: HistoryLocationRow) => (
<Space wrap>
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => onOpenVehicle(row.vin, row.protocol)}></Button>
<Button disabled={!canOpenVehicle(row.vin) || !onOpenMileage} onClick={() => openLocationMileage(row)}></Button>
<Button disabled={!canOpenVehicle(row.vin) || !onOpenRaw} onClick={() => openLocationRaw(row)}></Button>
</Space>
)
}
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="历史明细" itemKey="raw">
<div className="vp-table-toolbar">
<Space wrap>
<Tag color="blue"> {rawFrames.items.length.toLocaleString()} </Tag>
<Tag color={isIncludeFieldsEnabled(filters.includeFields) || splitFields(filters.fields).length > 0 ? 'green' : 'orange'}>
{isIncludeFieldsEnabled(filters.includeFields) || splitFields(filters.fields).length > 0 ? '包含字段明细' : '未请求字段明细'}
</Tag>
<Button size="small" onClick={exportRawFrames}> CSV</Button>
</Space>
</div>
<Table
rowKey="id"
dataSource={rawFrames.items}
loading={loadingRaw}
pagination={{
currentPage: rawPagination.currentPage,
pageSize: rawPagination.pageSize,
total: rawFrames.total,
showSizeChanger: true,
onPageChange: (page) => loadRawFrames(filters, page, rawPagination.pageSize),
onPageSizeChange: (pageSize) => loadRawFrames(filters, 1, pageSize)
}}
columns={[
{ title: 'ID', dataIndex: 'id', width: 260 },
{ title: 'VIN', dataIndex: 'vin', width: 190 },
{ title: '车牌', dataIndex: 'plate', width: 120 },
{ title: '数据通道', dataIndex: 'protocol', width: 120 },
{ title: '帧类型', dataIndex: 'frameType', width: 190 },
{ title: '大小 B', dataIndex: 'rawSizeBytes', width: 100 },
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
{ title: '入库时间', dataIndex: 'serverTime', width: 190 },
{
title: '操作',
width: 280,
render: (_: unknown, row: RawFrameRow) => (
<Space wrap>
<Button onClick={() => setSelectedRaw(row)}></Button>
<Button disabled={!canOpenVehicle(row.vin) || !onOpenMileage} onClick={() => openRawMileage(row)}></Button>
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => onOpenVehicle(row.vin, row.protocol)}></Button>
</Space>
)
}
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="字段明细" itemKey="fields">
<div className="vp-table-toolbar">
<Space wrap>
<Tag color="green"> {rawFieldRows.length.toLocaleString()} </Tag>
<Tag color="blue">{rawFrames.items.length.toLocaleString()} </Tag>
{selectedFieldCount > 0 ? <Tag color="blue"> {selectedFieldCount.toLocaleString()} </Tag> : <Tag color="grey"></Tag>}
<Button size="small" onClick={exportRawFields}> CSV</Button>
</Space>
</div>
<Table
rowKey="id"
dataSource={rawFieldRows}
loading={loadingRaw}
pagination={false}
columns={[
{ title: '字段', dataIndex: 'fieldPath', width: 320 },
{
title: '值',
width: 260,
render: (_: unknown, row: RawFieldRow) => (
<Typography.Text ellipsis={{ showTooltip: true }}>{formatFieldValue(row.fieldValue) || '-'}</Typography.Text>
)
},
{ title: 'VIN', dataIndex: 'vin', width: 190 },
{ title: '车牌', dataIndex: 'plate', width: 120 },
{ title: '数据通道', dataIndex: 'protocol', width: 120 },
{ title: '帧类型', dataIndex: 'frameType', width: 190 },
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
{ title: '历史明细ID', dataIndex: 'rawId', width: 260 },
{
title: '操作',
width: 170,
render: (_: unknown, row: RawFieldRow) => (
<Space wrap>
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => onOpenVehicle(row.vin, row.protocol)}></Button>
</Space>
)
}
]}
/>
<Space wrap style={{ marginTop: 12 }}>
<Tag color="grey">沿</Tag>
<Tag color="grey"> parsedFields</Tag>
</Space>
</Tabs.TabPane>
</Tabs>
</Card>
<SideSheet title="历史明细字段明细" visible={Boolean(selectedRaw)} onCancel={() => setSelectedRaw(null)} width={720}>
<Space vertical align="start" spacing={12} style={{ width: '100%' }}>
<Typography.Text type="tertiary">
{selectedRaw?.protocol ?? '-'} / {selectedRaw?.plate || selectedRaw?.vin || '-'} / {rawFieldCount}
</Typography.Text>
{rawFieldCount === 0 ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
</Space>
<pre className="vp-json">{JSON.stringify(selectedRaw?.parsedFields ?? {}, null, 2)}</pre>
</SideSheet>
</div>
);
}