Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/History.tsx
2026-07-06 03:26:39 +08:00

3601 lines
173 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 scheduledHistoryReportPlanText({
filters,
locationCount,
rawCount,
fieldCount,
validPointCount,
anomalySummary
}: {
filters: HistoryFilters;
locationCount: number;
rawCount: number;
fieldCount: number;
validPointCount: 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() } : {})
};
return [
'【历史数据周期交付计划】',
`车辆范围:${vehicle || '全部车辆'}`,
`数据通道:${protocol || '全部数据通道'}`,
`时间范围:${filters.dateFrom?.trim() || '-'}${filters.dateTo?.trim() || '-'}`,
'推荐频率:日报用于运营复盘,周报用于客户对账,临时导出用于问题解释。',
'交付内容:轨迹报告 / 位置历史 CSV / 明细证据 CSV / 字段裁剪 CSV / 统计查询链接 / 质量提示。',
`当前数据量:位置 ${locationCount.toLocaleString()} 条,有效定位 ${validPointCount.toLocaleString()},明细证据 ${rawCount.toLocaleString()} 帧,字段裁剪 ${fieldCount.toLocaleString()} 个。`,
`质量提示:断点 ${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 }))}`
].join('\n');
}
function savedHistoryReportViewText({
filters,
fieldCount,
fieldPaths,
locationCount,
rawCount,
validPointCount,
anomalySummary
}: {
filters: HistoryFilters;
fieldCount: number;
fieldPaths: string[];
locationCount: number;
rawCount: number;
validPointCount: number;
anomalySummary: TrajectoryAnomalySummary;
}) {
const vehicle = filters.keyword?.trim() || '';
const protocol = filters.protocol?.trim() || '';
const fieldTemplate = fieldPaths.length > 0 ? fieldPaths.join(', ') : filters.fields?.trim() || '未配置字段裁剪';
const evidenceFilters = {
tab: 'fields',
...(filters.dateFrom?.trim() ? { dateFrom: filters.dateFrom.trim() } : {}),
...(filters.dateTo?.trim() ? { dateTo: filters.dateTo.trim() } : {}),
includeFields: 'true',
...(fieldPaths.length > 0 ? { fields: fieldPaths.join(',') } : filters.fields?.trim() ? { fields: filters.fields.trim() } : {})
};
return [
'【保存报表视图】',
`视图范围:${vehicle || '全部车辆'} / ${protocol || '全部数据通道'} / ${filters.dateFrom?.trim() || '-'}${filters.dateTo?.trim() || '-'}`,
`字段模板:${fieldTemplate}`,
'交付节奏:日报用于运营复盘,周报用于客户对账,临时导出用于问题解释。',
`当前证据:位置 ${locationCount.toLocaleString()} 条,有效定位 ${validPointCount.toLocaleString()},明细 ${rawCount.toLocaleString()} 帧,字段 ${fieldCount.toLocaleString()} 个。`,
`质量提示:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`,
`复用链接:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: evidenceFilters }))}`
].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 timeWindowMonitorItems = [
{
title: '轨迹复盘',
value: validLocations.length > 0 ? `${validLocations.length.toLocaleString()}` : '待查询',
detail: `${filters.dateFrom || '-'}${filters.dateTo || '-'},先确认位置覆盖和断点。`,
action: '位置历史',
color: validLocations.length > 0 ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => openQueryTab('location')
},
{
title: '统计核对',
value: formatNumber(mileageDelta, ' km'),
detail: '把同一时间窗带到统计查询,核对区间里程和日报闭合。',
action: '统计查询',
color: isFiniteNumber(mileageDelta) ? 'green' as const : 'grey' as const,
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: selectedFieldCount > 0 ? `包含 ${selectedFieldCount.toLocaleString()} 个字段裁剪。` : '保留原始明细和解析字段,解释位置、里程和字段来源。',
action: '明细证据',
color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
disabled: false,
onClick: () => openQueryTab('raw')
},
{
title: '告警复盘',
value: anomalyTotal > 0 ? `${anomalyTotal.toLocaleString()} 项提示` : '同步说明',
detail: '用同一车辆和时间窗打开告警事件,解释断链、字段缺失和质量影响。',
action: '告警说明',
color: anomalyTotal > 0 ? 'orange' as const : 'blue' as const,
disabled: false,
onClick: () => {
window.location.hash = buildAppHash({
page: 'alert-events',
keyword: currentVehicleKeyword,
protocol: currentProtocol,
filters: {
...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}),
...(filters.dateTo ? { dateTo: filters.dateTo } : {})
}
});
}
}
];
const historyReportTemplates = [
{
title: '轨迹报告',
value: validLocations.length > 0 ? `${validLocations.length.toLocaleString()}` : '待查询',
detail: '适合回答某辆车某段时间在哪里、怎么走、是否断点。',
color: validLocations.length > 0 ? 'green' as const : 'orange' as const,
action: '复制报告',
disabled: false,
onClick: () => copyTrajectoryReviewPackage()
},
{
title: '明细证据包',
value: `${(rawFrames.total ?? 0).toLocaleString()}`,
detail: '适合客户或BI追溯位置、里程、速度、SOC等字段来源。',
color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
action: '导出证据',
disabled: false,
onClick: () => openQueryTab('raw')
},
{
title: '字段裁剪包',
value: rawFieldRows.length > 0 ? `${rawFieldRows.length.toLocaleString()} 字段` : `${selectedFieldCount.toLocaleString()} 字段`,
detail: selectedFieldCount > 0 ? '按客户指定字段裁剪,减少导出体积。' : '先填写字段裁剪或切换字段裁剪页,再生成字段级交付。',
color: rawFieldRows.length > 0 || selectedFieldCount > 0 ? 'green' as const : 'orange' as const,
action: '字段配置',
disabled: false,
onClick: () => openQueryTab('fields')
},
{
title: '周期交付计划',
value: '日报/周报',
detail: '把同一查询范围沉淀为日报、周报或临时问题解释模板。',
color: 'blue' as const,
action: '复制计划',
disabled: false,
onClick: () => copyScheduledHistoryReportPlan()
}
];
const fieldEvidenceCount = rawFieldRows.length > 0 ? rawFieldRows.length : selectedFieldCount;
const customerExportCenterItems = [
{
title: '行程历史',
value: validLocations.length > 0 ? `${validLocations.length.toLocaleString()}` : '0 点',
detail: '交付轨迹路线、位置点、速度、里程断点和时间范围。',
action: '导出轨迹',
color: validLocations.length > 0 ? 'green' as const : 'orange' as const,
disabled: locations.items.length === 0,
onClick: () => exportLocations()
},
{
title: '里程对账',
value: formatNumber(mileageDelta, ' km'),
detail: '跳到同一车辆和时间窗的里程统计,核对区间差值和日报闭合。',
action: '统计查询',
color: isFiniteNumber(mileageDelta) ? 'green' as const : 'grey' as const,
disabled: !onOpenMileage,
onClick: () => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) })
},
{
title: '字段证据',
value: `${fieldEvidenceCount.toLocaleString()} 字段`,
detail: '按客户指定字段裁剪导出,避免把完整原始 JSON 暴露给客户。',
action: '导出字段',
color: fieldEvidenceCount > 0 ? 'green' as const : 'orange' as const,
disabled: rawFieldRows.length === 0,
onClick: () => exportRawFields()
},
{
title: '告警说明',
value: anomalyTotal > 0 ? `${anomalyTotal.toLocaleString()} 项异常` : '无明显异常',
detail: '复制交付说明,附带断点、里程回退、超速和证据链接。',
action: '复制说明',
color: anomalyTotal > 0 ? 'orange' as const : 'green' as const,
disabled: false,
onClick: () => copyDeliveryPackage()
}
];
const vehicleHistoryServiceItems = [
{
label: '路线复盘',
value: validLocations.length > 0 ? `${validLocations.length.toLocaleString()}` : '0 点',
detail: '先看同一时间窗内的路线、速度和断点。',
color: validLocations.length > 0 ? 'green' as const : 'orange' as const,
action: '查询位置',
disabled: false,
onClick: () => openQueryTab('location')
},
{
label: '里程核对',
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 } : {}) })
},
{
label: '明细证据',
value: `${(rawFrames.total ?? 0).toLocaleString()}`,
detail: '查看接入明细,解释位置、里程和字段来源。',
color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
action: '查看证据',
disabled: false,
onClick: () => openQueryTab('raw')
},
{
label: '字段裁剪',
value: rawFieldRows.length > 0 ? `${rawFieldRows.length.toLocaleString()} 字段` : `${selectedFieldCount.toLocaleString()} 字段`,
detail: '按客户需要裁剪字段,减少导出体积。',
color: rawFieldRows.length > 0 || selectedFieldCount > 0 ? 'green' as const : 'orange' as const,
action: '字段配置',
disabled: false,
onClick: () => openQueryTab('fields')
},
{
label: '客户导出',
value: deliveryState,
detail: '复制交付说明,或继续导出 CSV 证据。',
color: deliveryStateColor,
action: '复制交付',
disabled: false,
onClick: () => copyDeliveryPackage()
}
];
const customerEvidencePackageOverviewItems = [
{
label: '交付范围',
value: `${currentVehicleKeyword || '全部车辆'} / ${currentProtocol || '全部来源证据'}`,
detail: `${filters.dateFrom || '-'}${filters.dateTo || '-'},先确认客户要查的车辆和时间窗。`,
action: '查看范围',
color: currentVehicleKeyword ? 'green' as const : 'blue' as const,
disabled: false,
onClick: () => document.querySelector<HTMLInputElement>('input[name="keyword"]')?.focus()
},
{
label: '轨迹证据',
value: validLocations.length > 0 ? `${validLocations.length.toLocaleString()}` : '0 点',
detail: validLocations.length > 0 ? '可进入轨迹回放和位置历史导出。' : '当前范围没有有效轨迹点,需要补查位置历史。',
action: '查询轨迹',
color: validLocations.length > 0 ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => openQueryTab('location')
},
{
label: '明细字段',
value: fieldEvidenceCount > 0 ? `${fieldEvidenceCount.toLocaleString()} 字段` : `${(rawFrames.total ?? 0).toLocaleString()}`,
detail: fieldEvidenceCount > 0 ? '字段证据已裁剪,可按客户口径导出。' : '可查看历史明细并配置字段裁剪。',
action: fieldEvidenceCount > 0 ? '字段导出' : '明细证据',
color: fieldEvidenceCount > 0 ? 'green' as const : (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
disabled: false,
onClick: () => fieldEvidenceCount > 0 ? openQueryTab('fields') : openQueryTab('raw')
},
{
label: '交付说明',
value: deliveryState,
detail: anomalyTotal > 0 ? `存在 ${anomalyTotal.toLocaleString()} 项质量提示,交付说明需附带异常解释。` : '当前范围可生成客户可读的证据说明。',
action: '复制说明',
color: deliveryStateColor,
disabled: false,
onClick: () => copyDeliveryPackage()
}
];
const customerDeliveryReadinessItems = [
{
label: currentVehicleKeyword ? '范围已锁定' : '范围待选择',
value: currentVehicleKeyword || '全部车辆',
detail: `${currentProtocol || '全部来源证据'} / ${filters.dateFrom || '-'}${filters.dateTo || '-'}`,
action: '查看范围',
color: currentVehicleKeyword ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => document.querySelector<HTMLInputElement>('input[name="keyword"]')?.focus()
},
{
label: validLocations.length > 0 ? '轨迹可交付' : '轨迹待补',
value: validLocations.length > 0 ? `${validLocations.length.toLocaleString()}` : '0 点',
detail: validLocations.length > 0 ? '可以进入轨迹回放并导出客户路线。' : '当前时间窗没有轨迹点,先补查位置历史。',
action: '查询轨迹',
color: validLocations.length > 0 ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => openQueryTab('location')
},
{
label: fieldEvidenceCount > 0 ? '字段已裁剪' : '字段待配置',
value: fieldEvidenceCount > 0 ? `${fieldEvidenceCount.toLocaleString()} 字段` : '待配置',
detail: fieldEvidenceCount > 0 ? '只交付客户需要的字段,减少证据包体积。' : '先选择客户需要的字段,再导出 CSV。',
action: fieldEvidenceCount > 0 ? '字段导出' : '字段配置',
color: fieldEvidenceCount > 0 ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => openQueryTab('fields')
},
{
label: '说明可复制',
value: deliveryState,
detail: anomalyTotal > 0 ? `附带 ${anomalyTotal.toLocaleString()} 项质量提示。` : '可直接复制客户可读的交付说明。',
action: '复制说明',
color: deliveryStateColor,
disabled: false,
onClick: () => copyDeliveryPackage()
}
];
const customerReportCadenceItems = [
{
title: '每日运营复盘',
value: `${locations.total.toLocaleString()} 位置`,
detail: '每天交付位置历史、轨迹覆盖、里程统计和质量提示,便于运营早会复盘。',
action: '日报导出',
color: locations.total > 0 ? 'green' as const : 'orange' as const,
disabled: locations.items.length === 0,
onClick: () => exportLocations()
},
{
title: '每周客户对账',
value: '周报计划',
detail: '沉淀固定车辆范围、时间窗和交付物,按周给客户对账和复核。',
action: '周报计划',
color: 'blue' as const,
disabled: false,
onClick: () => copyScheduledHistoryReportPlan()
},
{
title: '临时问题解释',
value: deliveryState,
detail: '遇到定位、里程或断链质疑时,复制交付说明并附上证据链接。',
action: '复制说明',
color: deliveryStateColor,
disabled: false,
onClick: () => copyDeliveryPackage()
},
{
title: '字段模板复用',
value: selectedFieldCount > 0 ? `${selectedFieldCount.toLocaleString()} 字段` : '待配置',
detail: '复用字段裁剪模板,减少 CSV 体积并保证客户拿到稳定字段。',
action: '字段配置',
color: selectedFieldCount > 0 || rawFieldRows.length > 0 ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => openQueryTab('fields')
}
];
const customerExportTemplateLibraryItems = [
{
title: '日报运营包',
value: '运营早会',
detail: '每天交付车辆位置覆盖、轨迹断点、字段证据和质量提示,适合早会复盘。',
action: '导出日报',
color: locations.total > 0 ? 'green' as const : 'orange' as const,
disabled: locations.items.length === 0,
onClick: () => exportLocations()
},
{
title: '周报对账包',
value: '客户对账',
detail: '按固定车辆范围沉淀周报计划,交付轨迹、统计查询链接和异常说明。',
action: '复制周报计划',
color: 'blue' as const,
disabled: false,
onClick: () => copyScheduledHistoryReportPlan()
},
{
title: '问题解释包',
value: '客户问询',
detail: '客户质疑定位、里程或断链时,复制一份包含证据链接的说明。',
action: '复制说明',
color: deliveryStateColor,
disabled: false,
onClick: () => copyDeliveryPackage()
},
{
title: '字段稳定包',
value: '数据对接',
detail: '把客户固定字段保存成模板,后续导出保持字段口径和顺序稳定。',
action: '字段配置',
color: selectedFieldCount > 0 || rawFieldRows.length > 0 ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => openQueryTab('fields')
}
];
const customerReportPurposeItems = [
{
title: '日报复盘',
value: '运营早会',
detail: '每天快速交付车辆位置、轨迹覆盖、区间里程和质量提示。',
action: '生成日报',
color: locations.total > 0 ? 'green' as const : 'orange' as const,
disabled: locations.items.length === 0,
onClick: () => exportLocations()
},
{
title: '周报对账',
value: '客户对账',
detail: '把固定车辆范围和时间窗沉淀为周报计划,给客户对账复核。',
action: '生成周报',
color: 'blue' as const,
disabled: false,
onClick: () => copyScheduledHistoryReportPlan()
},
{
title: '临时解释',
value: '质量说明',
detail: '遇到定位、里程或断链质疑时,复制带证据链接的解释说明。',
action: '复制说明',
color: deliveryStateColor,
disabled: false,
onClick: () => copyDeliveryPackage()
},
{
title: '字段模板',
value: '稳定字段',
detail: '按客户固定字段裁剪导出,减少体积并保证每次字段一致。',
action: '字段配置',
color: selectedFieldCount > 0 || rawFieldRows.length > 0 ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => openQueryTab('fields')
}
];
const savedReportViewItems = [
{
title: '当前筛选',
value: currentVehicleKeyword || '全部车辆',
detail: `${currentProtocol || '全部来源证据'} / ${filters.dateFrom || '-'}${filters.dateTo || '-'}`,
action: '保存视图',
color: currentVehicleKeyword ? 'green' as const : 'blue' as const,
disabled: false,
onClick: () => copySavedReportView()
},
{
title: '字段模板',
value: rawFieldRows.length > 0 ? `${rawFieldRows.length.toLocaleString()} 字段` : `${selectedFieldCount.toLocaleString()} 字段`,
detail: filters.fields?.trim() || '未配置字段裁剪,默认使用当前字段明细。',
action: '复用字段',
color: selectedFieldCount > 0 || rawFieldRows.length > 0 ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => openQueryTab('fields')
},
{
title: '交付节奏',
value: '日报/周报',
detail: '日报用于运营复盘,周报用于客户对账,临时导出用于问题解释。',
action: '复制计划',
color: 'blue' as const,
disabled: false,
onClick: () => copyScheduledHistoryReportPlan()
},
{
title: '快速分享',
value: '可复制',
detail: '复制当前视图链接和字段模板,交给客户或内部运营复用。',
action: '复制链接',
color: 'blue' as const,
disabled: false,
onClick: () => copySavedReportView()
}
];
const customerHistoryQuestions = [
{
question: '这辆车这段时间在哪里?',
answer: validLocations.length > 0 ? `${validLocations.length.toLocaleString()} 个有效轨迹点` : '先查询位置历史',
evidence: `${filters.dateFrom || '-'}${filters.dateTo || '-'}`,
action: '轨迹回放',
color: validLocations.length > 0 ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => openQueryTab('location')
},
{
question: '这段时间跑了多少公里?',
answer: formatNumber(mileageDelta, ' km'),
evidence: `最高速度 ${formatNumber(maxSpeed, ' km/h')}`,
action: '统计查询',
color: isFiniteNumber(mileageDelta) ? 'green' as const : 'grey' as const,
disabled: !onOpenMileage,
onClick: () => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) })
},
{
question: '为什么定位或里程不连续?',
answer: anomalyTotal > 0 ? `${anomalyTotal.toLocaleString()} 项需复核` : '当前页无明显异常',
evidence: `断点 ${trajectoryAnomalies.gapCount.toLocaleString()} / 里程回退 ${trajectoryAnomalies.mileageRollbackCount.toLocaleString()} / 超速 ${trajectoryAnomalies.overspeedCount.toLocaleString()}`,
action: '复核证据',
color: anomalyTotal > 0 ? 'orange' as const : 'green' as const,
disabled: false,
onClick: () => copyTrajectoryDecision()
},
{
question: '明细能导出给客户吗?',
answer: deliveryState,
evidence: `位置 ${locations.total.toLocaleString()} / 明细 ${(rawFrames.total ?? 0).toLocaleString()} / 字段 ${rawFieldRows.length.toLocaleString()}`,
action: '导出交付包',
color: deliveryStateColor,
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 copyScheduledHistoryReportPlan = () => {
copyText(
scheduledHistoryReportPlanText({
filters,
locationCount: locations.total ?? 0,
rawCount: rawFrames.total ?? 0,
fieldCount: rawFieldRows.length,
validPointCount: validLocations.length,
anomalySummary: trajectoryAnomalies
}),
'历史数据周期交付计划'
);
};
const copySavedReportView = () => {
const fieldPaths = Array.from(new Set(rawFieldRows.map((row) => row.fieldPath).filter(Boolean)));
const fallbackDateFrom = dateOnly(rawFieldRows[0]?.deviceTime);
const fallbackDateTo = fallbackDateFrom ? nextDate(fallbackDateFrom) : '';
const viewFilters = {
...filters,
...(filters.dateFrom?.trim() ? {} : fallbackDateFrom ? { dateFrom: fallbackDateFrom } : {}),
...(filters.dateTo?.trim() ? {} : fallbackDateTo ? { dateTo: fallbackDateTo } : {}),
...(fieldPaths.length > 0 ? { fields: fieldPaths.join(',') } : {})
};
copyText(
savedHistoryReportViewText({
filters: viewFilters,
locationCount: locations.total ?? 0,
rawCount: rawFrames.total ?? 0,
fieldCount: rawFieldRows.length,
fieldPaths,
validPointCount: validLocations.length,
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');
};
const customerTimeWindowEvidenceItems = [
{
title: '轨迹复盘',
value: validLocations.length > 0 ? `${validLocations.length.toLocaleString()}` : '待查询',
detail: '先用车辆地图和轨迹回放解释这段时间车辆在哪里、怎么走。',
action: '查看轨迹',
color: validLocations.length > 0 ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => openQueryTab('location')
},
{
title: '里程核对',
value: formatNumber(mileageDelta, ' km'),
detail: '把同一车辆和时间窗带到统计页,核对区间差值与日报闭合。',
action: '统计查询',
color: isFiniteNumber(mileageDelta) ? 'green' as const : 'grey' as const,
disabled: !onOpenMileage || !currentVehicleKeyword,
onClick: () => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) })
},
{
title: '历史导出',
value: `${(rawFrames.total ?? 0).toLocaleString()}`,
detail: '下钻到历史明细和位置记录,保留客户问责时可复核的证据。',
action: '明细证据',
color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
disabled: false,
onClick: () => openQueryTab('raw')
},
{
title: '字段裁剪',
value: rawFieldRows.length > 0 ? `${rawFieldRows.length.toLocaleString()} 字段` : `${selectedFieldCount.toLocaleString()} 字段`,
detail: '按客户只需要的字段生成交付视图,避免暴露完整 RAW 结构。',
action: '字段导出',
color: rawFieldRows.length > 0 || selectedFieldCount > 0 ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => openQueryTab('fields')
},
{
title: '告警复盘',
value: anomalyTotal > 0 ? `${anomalyTotal.toLocaleString()}` : '可说明',
detail: '同一时间窗查看断链、离线、定位异常和通知闭环。',
action: '告警事件',
color: anomalyTotal > 0 ? 'orange' as const : 'blue' as const,
disabled: false,
onClick: () => {
window.location.hash = buildAppHash({
page: 'alert-events',
keyword: currentVehicleKeyword,
protocol: currentProtocol,
filters: {
...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}),
...(filters.dateTo ? { dateTo: filters.dateTo } : {})
}
});
}
}
];
const customerReplayNavigation = [
{
title: '选车和时间',
value: currentVehicleKeyword || '待选择车辆',
detail: `${currentProtocol || '全部来源证据'} / ${filters.dateFrom || '-'}${filters.dateTo || '-'}`,
action: '调整筛选',
color: currentVehicleKeyword ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => document.querySelector<HTMLInputElement>('input[name="keyword"]')?.focus()
},
{
title: '回放路线',
value: validLocations.length > 0 ? `${validLocations.length.toLocaleString()}` : '待查询',
detail: '按时间顺序播放轨迹,核对路线、停留和断点。',
action: '播放轨迹',
color: validLocations.length > 0 ? 'green' as const : 'grey' as const,
disabled: validLocations.length === 0,
onClick: () => openQueryTab('location')
},
{
title: '核对里程',
value: formatNumber(mileageDelta, ' km'),
detail: '跳到同一车辆和时间窗的里程统计,检查区间差值。',
action: '统计查询',
color: isFiniteNumber(mileageDelta) ? 'green' as const : 'grey' as const,
disabled: !onOpenMileage || !currentVehicleKeyword,
onClick: () => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) })
},
{
title: '导出证据',
value: deliveryState,
detail: '复制客户交付包包含轨迹、位置、RAW、字段和统计链接。',
action: '复制交付',
color: deliveryStateColor,
disabled: false,
onClick: copyDeliveryPackage
}
];
const customerDeliveryConclusionItems = [
{
label: '交付状态',
value: deliveryState,
detail: deliveryState === '可交付' ? '当前范围可直接生成客户证据包。' : deliveryState === '待查询数据' ? '先查询位置或明细数据后再交付。' : '可交付,但需要附带复核说明。',
color: deliveryStateColor,
onClick: () => copyDeliveryPackage()
},
{
label: '位置历史',
value: `${locations.total.toLocaleString()}`,
detail: `${validLocations.length.toLocaleString()} 个有效坐标,可用于轨迹复盘。`,
color: locations.total > 0 ? 'green' as const : 'grey' as const,
onClick: () => openQueryTab('location')
},
{
label: '明细证据',
value: `${(rawFrames.total ?? 0).toLocaleString()}`,
detail: '用于解释位置、里程、字段值和原始上报来源。',
color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
onClick: () => openQueryTab('raw')
},
{
label: '字段裁剪',
value: `${fieldEvidenceCount.toLocaleString()} 字段`,
detail: selectedFieldCount > 0 ? `已按 ${selectedFieldCount.toLocaleString()} 个字段裁剪。` : '按客户需要选择字段后再交付。',
color: fieldEvidenceCount > 0 ? 'green' as const : 'orange' as const,
onClick: () => openQueryTab('fields')
}
];
const customerDeliveryConclusionActions = [
{
label: '导出位置',
action: '位置CSV',
color: locations.items.length > 0 ? 'green' as const : 'grey' as const,
disabled: locations.items.length === 0,
onClick: exportLocations
},
{
label: '导出明细',
action: '明细CSV',
color: rawFrames.items.length > 0 ? 'blue' as const : 'grey' as const,
disabled: rawFrames.items.length === 0,
onClick: exportRawFrames
},
{
label: '字段裁剪',
action: '字段CSV',
color: rawFieldRows.length > 0 ? 'green' as const : 'orange' as const,
disabled: rawFieldRows.length === 0,
onClick: rawFieldRows.length > 0 ? exportRawFields : () => openQueryTab('fields')
},
{
label: '复制说明',
action: '交付包',
color: deliveryStateColor,
disabled: false,
onClick: copyDeliveryPackage
}
];
const nextExportPrimary = rawFieldRows.length > 0
? '字段证据'
: locations.items.length > 0
? '位置历史'
: rawFrames.items.length > 0
? '明细证据'
: '先查询';
const runPrimaryExport = () => {
if (rawFieldRows.length > 0) {
exportRawFields();
return;
}
if (locations.items.length > 0) {
exportLocations();
return;
}
if (rawFrames.items.length > 0) {
exportRawFrames();
return;
}
openQueryTab('raw');
};
const nextExportDecisionItems = [
{
label: '首要交付',
value: nextExportPrimary,
detail: rawFieldRows.length > 0 ? '优先交付客户选择的字段证据。' : locations.items.length > 0 ? '优先交付位置历史和轨迹复盘。' : rawFrames.items.length > 0 ? '先交付明细证据解释来源。' : '先查询车辆和时间窗数据。',
color: rawFieldRows.length > 0 || locations.items.length > 0 ? 'green' as const : rawFrames.items.length > 0 ? 'blue' as const : 'orange' as const,
onClick: runPrimaryExport
},
{
label: '轨迹状态',
value: validLocations.length > 0 ? `${validLocations.length.toLocaleString()}` : '待补轨迹',
detail: validLocations.length > 0 ? '可以进入轨迹回放和位置导出。' : '当前时间窗没有有效坐标,建议补查位置历史。',
color: validLocations.length > 0 ? 'green' as const : 'orange' as const,
onClick: () => openQueryTab('location')
},
{
label: '证据状态',
value: `${(rawFrames.total ?? 0).toLocaleString()} 帧 / ${fieldEvidenceCount.toLocaleString()} 字段`,
detail: fieldEvidenceCount > 0 ? '字段证据已准备,可缩小导出体积。' : '可从明细证据中选择客户需要的字段。',
color: fieldEvidenceCount > 0 ? 'green' as const : (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const,
onClick: () => openQueryTab(fieldEvidenceCount > 0 ? 'fields' : 'raw')
},
{
label: '说明状态',
value: deliveryState,
detail: anomalyTotal > 0 ? '复制时会带上质量提示和复核说明。' : '可复制客户可读的交付说明。',
color: deliveryStateColor,
onClick: copyDeliveryPackage
}
];
const nextExportActionItems = [
{
label: '导出优先项',
action: '执行导出',
color: nextExportPrimary === '字段证据' || nextExportPrimary === '位置历史' ? 'green' as const : nextExportPrimary === '明细证据' ? 'blue' as const : 'orange' as const,
disabled: nextExportPrimary === '先查询',
onClick: runPrimaryExport
},
{
label: '补查轨迹',
action: '位置历史',
color: validLocations.length > 0 ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => openQueryTab('location')
},
{
label: '复制说明',
action: '交付包',
color: deliveryStateColor,
disabled: false,
onClick: copyDeliveryPackage
},
{
label: '保存视图',
action: '复用',
color: 'blue' as const,
disabled: false,
onClick: copySavedReportView
}
];
const tripReviewSummaryItems = [
{
label: '起点',
value: firstLocation?.deviceTime || firstLocation?.serverTime || '-',
detail: firstLocation ? `${formatNumber(firstLocation.longitude)}, ${formatNumber(firstLocation.latitude)}` : '暂无起点',
color: firstLocation ? 'green' as const : 'grey' as const,
disabled: !firstLocation,
onClick: () => openQueryTab('location')
},
{
label: '终点',
value: lastLocation?.deviceTime || lastLocation?.serverTime || '-',
detail: lastLocation ? `${formatNumber(lastLocation.longitude)}, ${formatNumber(lastLocation.latitude)}` : '暂无终点',
color: lastLocation ? 'green' as const : 'grey' as const,
disabled: !lastLocation,
onClick: () => openQueryTab('location')
},
{
label: '行程里程',
value: formatNumber(mileageDelta, ' km'),
detail: `回放跨度 ${formatDurationMinutes(playbackSpanMinutes)},用于解释区间里程。`,
color: isFiniteNumber(mileageDelta) ? 'blue' as const : 'grey' as const,
disabled: !isFiniteNumber(mileageDelta),
onClick: () => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) })
},
{
label: '最高速度',
value: formatNumber(maxSpeed, ' km/h'),
detail: '用于复核超速、异常驾驶或位置跳点。',
color: isFiniteNumber(maxSpeed) ? 'blue' as const : 'grey' as const,
disabled: !isFiniteNumber(maxSpeed),
onClick: () => openQueryTab('location')
},
{
label: '异常提示',
value: anomalyTotal > 0 ? `${anomalyTotal.toLocaleString()}` : '无明显异常',
detail: `断点 ${trajectoryAnomalies.gapCount.toLocaleString()} / 回退 ${trajectoryAnomalies.mileageRollbackCount.toLocaleString()} / 超速 ${trajectoryAnomalies.overspeedCount.toLocaleString()}`,
color: anomalyTotal > 0 ? 'orange' as const : 'green' as const,
disabled: false,
onClick: copyTrajectoryReviewPackage
}
];
const tripReviewActionItems = [
{
label: '轨迹回放',
action: '播放轨迹',
detail: `${validLocations.length.toLocaleString()} 个有效定位点`,
color: validLocations.length > 0 ? 'green' as const : 'grey' as const,
disabled: validLocations.length === 0,
onClick: () => openQueryTab('location')
},
{
label: '统计核对',
action: '统计查询',
detail: `区间里程 ${formatNumber(mileageDelta, ' km')}`,
color: isFiniteNumber(mileageDelta) ? 'blue' as const : 'grey' as const,
disabled: !onOpenMileage || !currentVehicleKeyword,
onClick: () => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) })
},
{
label: '导出证据',
action: '复制交付',
detail: `位置 ${locations.total.toLocaleString()} / 明细 ${(rawFrames.total ?? 0).toLocaleString()}`,
color: deliveryStateColor,
disabled: false,
onClick: copyDeliveryPackage
},
{
label: '告警复盘',
action: '告警事件',
detail: anomalyTotal > 0 ? `${anomalyTotal.toLocaleString()} 项异常待说明` : '同一时间窗查看告警闭环',
color: anomalyTotal > 0 ? 'orange' as const : 'blue' as const,
disabled: false,
onClick: () => {
window.location.hash = buildAppHash({
page: 'alert-events',
keyword: currentVehicleKeyword,
protocol: currentProtocol,
filters: {
...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}),
...(filters.dateTo ? { dateTo: filters.dateTo } : {})
}
});
}
}
];
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' ? (
<section className="vp-history-delivery-conclusion-strip" aria-label="客户数据交付结论条">
<div className="vp-history-delivery-conclusion-copy">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
<Tag color={fieldEvidenceCount > 0 ? 'green' : 'orange'}>{fieldEvidenceCount.toLocaleString()} </Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
{deliveryScopeText}{filters.dateFrom || '-'} {filters.dateTo || '-'}
</Typography.Text>
</div>
<div className="vp-history-delivery-conclusion-metrics">
{customerDeliveryConclusionItems.map((item) => (
<button
key={item.label}
type="button"
className="vp-history-delivery-conclusion-metric"
onClick={item.onClick}
aria-label={`客户数据交付结论 ${item.label} ${item.value}`}
>
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
</button>
))}
</div>
<div className="vp-history-delivery-conclusion-actions">
{customerDeliveryConclusionActions.map((item) => (
<button
key={item.label}
type="button"
className="vp-history-delivery-conclusion-action"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`客户数据交付动作 ${item.label} ${item.action}`}
>
<Tag color={item.color}>{item.label}</Tag>
<span>{item.action}</span>
</button>
))}
</div>
</section>
) : null}
{mode === 'query' ? (
<section className="vp-history-next-export-strip" aria-label="导出下一步行动条">
<div className="vp-history-next-export-copy">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={nextExportPrimary === '字段证据' || nextExportPrimary === '位置历史' ? 'green' : nextExportPrimary === '明细证据' ? 'blue' : 'orange'}>
{nextExportPrimary}
</Tag>
<Tag color={validLocations.length > 0 ? 'green' : 'orange'}>
{validLocations.length > 0 ? '轨迹可用' : '待补轨迹'}
</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}>
CSV
</Typography.Title>
<Typography.Text type="secondary">
{nextExportPrimary}{(rawFrames.total ?? 0).toLocaleString()} / {fieldEvidenceCount.toLocaleString()} {deliveryScopeText}
</Typography.Text>
</div>
<div className="vp-history-next-export-grid">
{nextExportDecisionItems.map((item) => (
<button
key={item.label}
type="button"
className="vp-history-next-export-item"
onClick={item.onClick}
aria-label={`导出下一步行动 ${item.label} ${item.value}`}
>
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
</button>
))}
</div>
<div className="vp-history-next-export-actions">
{nextExportActionItems.map((item) => (
<button
key={item.label}
type="button"
className="vp-history-next-export-action"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`导出下一步动作 ${item.label} ${item.action}`}
>
<Tag color={item.color}>{item.label}</Tag>
<span>{item.action}</span>
</button>
))}
</div>
</section>
) : null}
<section className="vp-history-evidence-chain" aria-label="客户时间窗证据链">
<div className="vp-history-evidence-chain-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
<Tag color={currentVehicleKeyword ? 'green' : 'orange'}>{currentVehicleKeyword || '待选择车辆'}</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
GB32960JT808MQTT
</Typography.Text>
</div>
<div className="vp-history-evidence-chain-grid">
{customerTimeWindowEvidenceItems.map((item) => (
<button
key={item.title}
type="button"
className="vp-history-evidence-chain-item"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`客户时间窗证据链 ${item.title} ${item.action}`}
>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</section>
{mode === 'trajectory' ? (
<section className="vp-trip-review-summary" aria-label="行程复盘摘要">
<div className="vp-trip-review-summary-copy">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={trajectoryDecisionColor}>{trajectoryDecisionState}</Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}>
线
</Typography.Title>
<Typography.Text type="secondary">
Trip History
</Typography.Text>
</div>
<div className="vp-trip-review-summary-grid">
{tripReviewSummaryItems.map((item) => (
<button
key={item.label}
type="button"
className="vp-trip-review-summary-item"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`行程复盘摘要 ${item.label} ${item.value}`}
>
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
</button>
))}
</div>
<div className="vp-trip-review-action-grid">
{tripReviewActionItems.map((item) => (
<button
key={item.label}
type="button"
className="vp-trip-review-action"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`行程复盘动作 ${item.label} ${item.action}`}
>
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.action}</strong>
<span>{item.detail}</span>
</button>
))}
</div>
</section>
) : null}
{mode === 'query' ? (
<section className="vp-history-evidence-package-overview" aria-label="客户证据包交付总览">
<div className="vp-history-evidence-package-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
<Tag color={fieldEvidenceCount > 0 ? 'green' : 'orange'}>{fieldEvidenceCount.toLocaleString()} </Tag>
</Space>
<Typography.Text strong></Typography.Text>
</div>
<div className="vp-history-evidence-package-grid">
{customerEvidencePackageOverviewItems.map((item) => (
<button
key={item.label}
type="button"
className="vp-history-evidence-package-item"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`客户证据包交付总览 ${item.label} ${item.value} ${item.action}`}
>
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</section>
) : null}
{mode === 'query' ? (
<section className="vp-history-delivery-readiness" aria-label="客户交付准备度">
<div className="vp-history-delivery-readiness-copy">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
</Space>
<Typography.Text strong>
</Typography.Text>
</div>
<div className="vp-history-delivery-readiness-grid">
{customerDeliveryReadinessItems.map((item) => (
<button
key={item.label}
type="button"
className="vp-history-delivery-readiness-item"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`客户交付准备度 ${item.label} ${item.value} ${item.action}`}
>
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</section>
) : null}
{mode === 'query' ? (
<section className="vp-history-service-desk" aria-label="车辆历史服务台">
<div className="vp-history-service-desk-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
<Tag color={currentVehicleKeyword ? 'green' : 'orange'}>{currentVehicleKeyword || '全部车辆'}</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}>
线
</Typography.Title>
<div className="vp-history-service-desk-meta">
<span>{currentVehicleKeyword || '全部车辆'} / {currentProtocol || '全部来源证据'}</span>
<span>{filters.dateFrom || '-'} {filters.dateTo || '-'}</span>
<span></span>
</div>
</div>
<div className="vp-history-service-desk-grid">
{vehicleHistoryServiceItems.map((item) => (
<button
key={item.label}
type="button"
className="vp-history-service-desk-item"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`车辆历史服务台 ${item.label} ${item.value} ${item.action}`}
>
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</section>
) : null}
{mode === 'query' ? (
<section className="vp-history-export-center" aria-label="客户导出中心">
<div className="vp-history-export-center-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
<Tag color={currentVehicleKeyword ? 'green' : 'orange'}>{currentVehicleKeyword || '全部车辆'}</Tag>
</Space>
<strong></strong>
<span></span>
</div>
<div className="vp-history-export-center-grid">
{customerExportCenterItems.map((item) => (
<button
key={item.title}
type="button"
className="vp-history-export-center-item"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`客户导出中心 ${item.title} ${item.value} ${item.action}`}
>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</section>
) : null}
{mode === 'query' ? (
<Card bordered title="自定义时间窗监控台" style={{ marginTop: 16 }}>
<div className="vp-history-time-window-board">
<div className="vp-history-time-window-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
<Tag color={currentVehicleKeyword ? 'green' : 'orange'}>{currentVehicleKeyword || '全部车辆'}</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">
线穿
</Typography.Text>
</div>
<div className="vp-history-time-window-grid">
{timeWindowMonitorItems.map((item) => (
<button
key={item.title}
type="button"
className="vp-history-time-window-item"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`自定义时间窗监控 ${item.title} ${item.action}`}
>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</div>
</Card>
) : null}
{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}
{mode === 'query' ? (
<Card bordered title="报表交付模板" style={{ marginTop: 16 }}>
<div className="vp-history-report-templates">
<div className="vp-history-report-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
<Tag color={selectedFieldCount > 0 ? 'green' : 'grey'}>
{selectedFieldCount > 0 ? `${selectedFieldCount.toLocaleString()} 个字段裁剪` : '未裁剪字段'}
</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">
CSV
</Typography.Text>
</div>
<div className="vp-history-report-template-grid">
{historyReportTemplates.map((item) => (
<button
key={item.title}
type="button"
className="vp-history-report-template"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`报表交付模板 ${item.title} ${item.action}`}
>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</div>
</Card>
) : null}
{mode === 'query' ? (
<Card bordered title="客户报表交付节奏" style={{ marginTop: 16 }}>
<div className="vp-history-report-purpose" aria-label="客户报表用途导航">
<div className="vp-history-report-purpose-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
<Tag color={currentVehicleKeyword ? 'green' : 'grey'}>{currentVehicleKeyword ? '单车范围' : '全部车辆'}</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">
RAW
</Typography.Text>
</div>
<div className="vp-history-report-purpose-grid">
{customerReportPurposeItems.map((item) => (
<button
key={item.title}
type="button"
className="vp-history-report-purpose-item"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`客户报表用途导航 ${item.title} ${item.value} ${item.action}`}
>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</div>
<div className="vp-history-report-cadence">
<div className="vp-history-report-cadence-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
<Tag color={currentVehicleKeyword ? 'green' : 'grey'}>{currentVehicleKeyword ? '单车交付' : '批量交付'}</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}> FleetioSamsaraGeotab </Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</div>
<div className="vp-history-report-cadence-grid">
{customerReportCadenceItems.map((item) => (
<button
key={item.title}
type="button"
className="vp-history-report-cadence-item"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`客户报表交付节奏 ${item.title} ${item.action}`}
>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</div>
<div className="vp-history-export-template-library">
<div className="vp-history-export-template-library-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
<Tag color={selectedFieldCount > 0 || rawFieldRows.length > 0 ? 'green' : 'orange'}>
{fieldEvidenceCount.toLocaleString()}
</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}> CSV </Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</div>
<div className="vp-history-export-template-library-grid">
{customerExportTemplateLibraryItems.map((item) => (
<button
key={item.title}
type="button"
className="vp-history-export-template-library-item"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`客户导出包模板库 ${item.title} ${item.value} ${item.action}`}
>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</div>
</Card>
) : null}
{mode === 'query' ? (
<Card bordered title="保存报表视图" style={{ marginTop: 16 }}>
<div className="vp-history-saved-report">
<div className="vp-history-saved-report-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={currentVehicleKeyword ? 'green' : 'grey'}>{currentVehicleKeyword ? '单车范围' : '全部车辆'}</Tag>
<Tag color={selectedFieldCount > 0 || rawFieldRows.length > 0 ? 'green' : 'orange'}>
{selectedFieldCount > 0 ? `${selectedFieldCount.toLocaleString()} 个字段` : `${rawFieldRows.length.toLocaleString()} 个字段`}
</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</div>
<div className="vp-history-saved-report-grid">
{savedReportViewItems.map((item) => (
<button
key={item.title}
type="button"
className="vp-history-saved-report-item"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`保存报表视图 ${item.title} ${item.value} ${item.action}`}
>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</div>
</Card>
) : null}
{mode === 'query' ? (
<Card bordered title="客户历史问题" style={{ marginTop: 16 }}>
<div className="vp-history-question-board">
<div className="vp-history-question-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
<Tag color={currentVehicleKeyword ? 'green' : 'orange'}>{currentVehicleKeyword ? '单车范围' : '全部车辆'}</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}>线</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</div>
<div className="vp-history-question-grid">
{customerHistoryQuestions.map((item) => (
<button
key={item.question}
type="button"
className="vp-history-question-item"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`客户历史问题 ${item.question} ${item.action}`}
>
<Tag color={item.color}>{item.question}</Tag>
<strong>{item.answer}</strong>
<span>{item.evidence}</span>
<em>{item.action}</em>
</button>
))}
</div>
</div>
</Card>
) : null}
<Card bordered title="客户复盘导航" style={{ marginTop: 16 }}>
<div className="vp-history-replay-nav">
<div className="vp-history-replay-nav-summary">
<Space wrap>
<Tag color="blue">Trips History</Tag>
<Tag color={trajectoryDecisionColor}>{trajectoryDecisionState}</Tag>
<Tag color={deliveryStateColor}>{deliveryState}</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}> Trips History 线</Typography.Title>
<Typography.Text type="secondary">
RAW 线
</Typography.Text>
</div>
<div className="vp-history-replay-nav-grid">
{customerReplayNavigation.map((item) => (
<button
key={item.title}
type="button"
className="vp-history-replay-nav-item"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`客户复盘导航 ${item.title} ${item.action}`}
>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</div>
</Card>
<Card bordered className="vp-trip-workbench" bodyStyle={{ padding: 0 }}>
<div className="vp-trip-workbench-map">
<div className="vp-trip-workbench-copy">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={trajectoryDecisionColor}>{trajectoryDecisionState}</Tag>
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? '高德地图可用' : '坐标预览'}</Tag>
</Space>
<Typography.Title heading={3} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">
线
</Typography.Text>
</div>
<VehicleMap
points={playbackPoints}
mode="track"
selectedId={currentPlaybackPointId}
onPointSelect={selectPlaybackMapPoint}
heightClassName="vp-trip-workbench-map-canvas"
fallbackLabel="显示轨迹坐标预览"
/>
<div className="vp-trip-workbench-facts">
{[
{ label: '轨迹点', value: locations.total.toLocaleString(), detail: `${validLocations.length.toLocaleString()} 个有效坐标`, color: 'blue' as const },
{ label: '行程里程', value: formatNumber(mileageDelta, ' km'), detail: `最高速度 ${formatNumber(maxSpeed, ' km/h')}`, color: 'green' as const },
{ label: '回放跨度', value: formatDurationMinutes(playbackSpanMinutes), detail: `采样 ${formatDurationMinutes(playbackIntervalMinutes, '/点')}`, color: 'blue' as const },
{ label: '异常提示', value: anomalyTotal > 0 ? `${anomalyTotal.toLocaleString()}` : '无明显异常', detail: `断点 ${trajectoryAnomalies.gapCount.toLocaleString()} / 回退 ${trajectoryAnomalies.mileageRollbackCount.toLocaleString()} / 超速 ${trajectoryAnomalies.overspeedCount.toLocaleString()}`, color: anomalyTotal > 0 ? 'orange' as const : 'green' as const }
].map((item) => (
<button key={item.label} type="button" className="vp-trip-workbench-fact" onClick={item.label === '异常提示' ? copyTrajectoryReviewPackage : () => openQueryTab('location')} aria-label={`轨迹回放指标 ${item.label} ${item.value}`}>
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
</button>
))}
</div>
</div>
<div className="vp-trip-workbench-side">
<div className="vp-trip-current-card">
<Space wrap>
<Tag color={currentPlayback ? 'green' : 'grey'}>{currentPlayback ? `${currentPlaybackIndex + 1}/${playbackRows.length}` : '暂无回放点'}</Tag>
<Tag color={currentProtocol ? 'blue' : 'green'}>{currentProtocol ? `来源证据 ${currentProtocol}` : '全部来源证据'}</Tag>
</Space>
<Typography.Text strong>{currentPlayback?.plate || (currentPlayback ? '当前回放车辆' : currentVehicleKeyword ? '当前查询车辆' : '全部车辆')}</Typography.Text>
<Typography.Text type="secondary">{currentPlayback?.deviceTime || currentPlayback?.serverTime || '请选择车辆和时间窗查询轨迹'}</Typography.Text>
<div className="vp-trip-current-metrics">
<span> <strong>{formatNumber(currentPlayback?.speedKmh, ' km/h')}</strong></span>
<span> <strong>{formatNumber(currentPlayback?.totalMileageKm, ' km')}</strong></span>
</div>
<Typography.Text type="tertiary" ellipsis={{ showTooltip: true }}>
{currentPlaybackAddress?.formattedAddress || (currentPlayback ? `${currentPlayback.longitude}, ${currentPlayback.latitude}` : '暂无坐标')}
</Typography.Text>
<Space wrap>
<Button size="small" theme="solid" type="primary" disabled={playbackRows.length <= 1} onClick={togglePlayback}>{playbackPlaying ? '暂停' : playbackAtEnd ? '重播' : '播放'}</Button>
<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={!currentPlayback || !hasValidCoordinate(currentPlayback)} onClick={resolveCurrentPlaybackAddress}></Button>
</Space>
</div>
<div className="vp-trip-actions">
{[
{ title: '轨迹复盘', value: validLocations.length > 0 ? '可播放' : '待查询', detail: '回放路线、速度和里程断点。', action: '播放轨迹', color: validLocations.length > 0 ? 'green' as const : 'orange' as const, disabled: validLocations.length === 0, onClick: () => openQueryTab('location') },
{ title: '明细证据', value: `${(rawFrames.total ?? 0).toLocaleString()}`, detail: '查看轨迹背后的接入证据。', action: '查看证据', color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const, disabled: false, onClick: () => openQueryTab('raw') },
{ title: '统计查询', value: formatNumber(mileageDelta, ' km'), detail: '进入同一时间窗的统计查询。', action: '统计复核', color: isFiniteNumber(mileageDelta) ? 'green' as const : 'grey' as const, disabled: !onOpenMileage, onClick: () => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) }) },
{ title: '客户导出', value: deliveryState, detail: '复制交付说明或导出 CSV 证据。', action: '复制交付', color: deliveryStateColor, disabled: false, onClick: copyDeliveryPackage }
].map((item) => (
<button key={item.title} type="button" className="vp-trip-action" disabled={item.disabled} onClick={item.onClick} aria-label={`轨迹回放工作台 ${item.title} ${item.action}`}>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</div>
</Card>
<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>
);
}