2080 lines
100 KiB
TypeScript
2080 lines
100 KiB
TypeScript
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>
|
||
);
|
||
}
|