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 = {}): 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[] = [ { 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[] = [ { 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[] = [ { 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).flatMap(([key, item]) => { const nextPath = prefix ? `${prefix}.${key}` : key; if (item != null && typeof item === 'object') { return flattenParsedFields(item, nextPath); } return [{ path: nextPath, value: item }]; }); } function exportFileName(prefix: string, filters: HistoryFilters) { const keyword = filters.keyword?.trim() || 'all'; const protocol = filters.protocol?.trim() || 'all-source'; return `${prefix}-${keyword}-${protocol}.csv`; } function trajectorySummaryText({ filters, totalPoints, validPointCount, mileageDelta, maxSpeed, firstLocation, lastLocation }: { filters: HistoryFilters; totalPoints: number; validPointCount: number; mileageDelta?: number; maxSpeed?: number; firstLocation?: HistoryLocationRow; lastLocation?: HistoryLocationRow; }) { const vehicle = filters.keyword?.trim() || '全部车辆'; const protocol = filters.protocol?.trim() || '全部数据通道'; const dateFrom = filters.dateFrom?.trim(); const dateTo = filters.dateTo?.trim(); const range = dateFrom || dateTo ? `${dateFrom || '-'} 至 ${dateTo || '-'}` : '全部时间'; const startTime = firstLocation?.deviceTime || firstLocation?.serverTime || '-'; const endTime = lastLocation?.deviceTime || lastLocation?.serverTime || '-'; return [ '【轨迹回放摘要】', `车辆:${vehicle}`, `数据通道:${protocol}`, `查询范围:${range}`, `轨迹点:${totalPoints.toLocaleString()},有效定位:${validPointCount.toLocaleString()}`, `区间里程:${formatNumber(mileageDelta, ' km')}`, `最高速度:${formatNumber(maxSpeed, ' km/h')}`, `起点:${startTime}`, `终点:${endTime}`, `当前位置服务:${window.location.origin}${window.location.pathname}${window.location.hash}` ].join('\n'); } function appURL(hash: string) { return `${window.location.origin}${window.location.pathname}${hash}`; } function historyEvidencePackageText({ filters, locationCount, rawCount, fieldCount, validPointCount, mileageDelta, maxSpeed, firstLocation, lastLocation, anomalySummary }: { filters: HistoryFilters; locationCount: number; rawCount: number; fieldCount: number; validPointCount: number; mileageDelta?: number; maxSpeed?: number; firstLocation?: HistoryLocationRow; lastLocation?: HistoryLocationRow; anomalySummary: TrajectoryAnomalySummary; }) { const vehicle = filters.keyword?.trim() || ''; const protocol = filters.protocol?.trim() || ''; const evidenceFilters = { ...(filters.dateFrom?.trim() ? { dateFrom: filters.dateFrom.trim() } : {}), ...(filters.dateTo?.trim() ? { dateTo: filters.dateTo.trim() } : {}) }; return [ '【数据复核包】', `车辆:${vehicle || '全部车辆'}`, `数据通道:${protocol || '全部数据通道'}`, `查询范围:${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}`, `位置记录:${locationCount.toLocaleString()},有效定位:${validPointCount.toLocaleString()}`, `历史明细:${rawCount.toLocaleString()},字段明细:${fieldCount.toLocaleString()}`, `区间里程:${formatNumber(mileageDelta, ' km')},最高速度:${formatNumber(maxSpeed, ' km/h')}`, `起点:${firstLocation?.deviceTime || firstLocation?.serverTime || '-'}`, `终点:${lastLocation?.deviceTime || lastLocation?.serverTime || '-'}`, `异常:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`, `轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: vehicle, protocol, filters: evidenceFilters }))}`, `历史位置:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'location' } }))}`, `历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`, `字段明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'fields', includeFields: 'true', ...(filters.fields?.trim() ? { fields: filters.fields.trim() } : {}) } }))}`, `里程复核:${appURL(buildAppHash({ page: 'mileage', keyword: vehicle, protocol, filters: evidenceFilters }))}`, `车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: vehicle, protocol }))}` ].join('\n'); } function deliveryPackageText({ filters, locationCount, rawCount, fieldCount, validPointCount, coverageRate, mileageDelta, anomalySummary }: { filters: HistoryFilters; locationCount: number; rawCount: number; fieldCount: number; validPointCount: number; coverageRate?: number; mileageDelta?: number; anomalySummary: TrajectoryAnomalySummary; }) { const vehicle = filters.keyword?.trim() || ''; const protocol = filters.protocol?.trim() || ''; const evidenceFilters = { ...(filters.dateFrom?.trim() ? { dateFrom: filters.dateFrom.trim() } : {}), ...(filters.dateTo?.trim() ? { dateTo: filters.dateTo.trim() } : {}) }; const anomalyTotal = anomalySummary.gapCount + anomalySummary.mileageRollbackCount + anomalySummary.overspeedCount; const deliveryState = locationCount > 0 || rawCount > 0 ? anomalyTotal > 0 || (coverageRate ?? 100) < 80 ? '可交付,建议附带复核说明' : '可交付' : '待查询数据'; return [ '【客户查询导出包】', `交付状态:${deliveryState}`, '交付物:位置历史 / 轨迹回放 / 里程复核 / 历史明细 / 字段明细 / 质量提示', `车辆范围:${vehicle || '全部车辆'}`, `数据通道:${protocol || '全部数据通道'}`, `时间范围:${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}`, `位置历史:${locationCount.toLocaleString()} 条,有效定位 ${validPointCount.toLocaleString()},覆盖率 ${formatPercent(coverageRate)}`, `历史明细:${rawCount.toLocaleString()} 帧`, `字段明细:${fieldCount.toLocaleString()} 个字段`, `区间里程:${formatNumber(mileageDelta, ' km')}`, `质量提示:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`, `位置导出:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'location' } }))}`, `导出明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`, `导出字段:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'fields', includeFields: 'true', ...(filters.fields?.trim() ? { fields: filters.fields.trim() } : {}) } }))}`, `轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: vehicle, protocol, filters: evidenceFilters }))}`, `里程复核:${appURL(buildAppHash({ page: 'mileage', keyword: vehicle, protocol, filters: evidenceFilters }))}`, `车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: vehicle, protocol }))}` ].join('\n'); } function trajectoryReviewPackageText({ filters, locationCount, rawCount, fieldCount, validPointCount, coverageRate, playbackSpanMinutes, playbackIntervalMinutes, mileageDelta, maxSpeed, firstLocation, lastLocation, currentPlayback, currentPlaybackIndex, playbackCount, currentAddress, anomalySummary }: { filters: HistoryFilters; locationCount: number; rawCount: number; fieldCount: number; validPointCount: number; coverageRate?: number; playbackSpanMinutes?: number; playbackIntervalMinutes?: number; mileageDelta?: number; maxSpeed?: number; firstLocation?: HistoryLocationRow; lastLocation?: HistoryLocationRow; currentPlayback?: HistoryLocationRow; currentPlaybackIndex: number; playbackCount: number; currentAddress?: MapReverseGeocode; anomalySummary: TrajectoryAnomalySummary; }) { const vehicle = filters.keyword?.trim() || ''; const protocol = filters.protocol?.trim() || ''; const evidenceFilters = { ...(filters.dateFrom?.trim() ? { dateFrom: filters.dateFrom.trim() } : {}), ...(filters.dateTo?.trim() ? { dateTo: filters.dateTo.trim() } : {}) }; const anomalyAction = anomalySummary.gapCount > 0 || anomalySummary.mileageRollbackCount > 0 || anomalySummary.overspeedCount > 0 ? '建议核对历史明细、字段明细、当日里程统计,并确认平台是否存在断链或补发。' : '当前页未发现明显断点、里程回退或速度异常,可作为轨迹复盘依据使用。'; return [ '【轨迹复盘交接包】', `车辆:${vehicle || '全部车辆'}`, `数据通道:${protocol || '全部数据通道'}`, `查询范围:${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}`, `轨迹规模:位置 ${locationCount.toLocaleString()} / 有效定位 ${validPointCount.toLocaleString()} / 历史明细 ${rawCount.toLocaleString()} / 字段明细 ${fieldCount.toLocaleString()}`, `轨迹质量:定位覆盖率 ${formatPercent(coverageRate)} / 回放跨度 ${formatDurationMinutes(playbackSpanMinutes)} / 采样间隔 ${formatDurationMinutes(playbackIntervalMinutes, '/点')}`, `里程速度:区间里程 ${formatNumber(mileageDelta, ' km')} / 最高速度 ${formatNumber(maxSpeed, ' km/h')}`, `起点:${firstLocation?.deviceTime || firstLocation?.serverTime || '-'}`, `终点:${lastLocation?.deviceTime || lastLocation?.serverTime || '-'}`, `当前回放点:${currentPlayback ? `点 ${currentPlaybackIndex + 1}/${playbackCount},${currentPlayback.deviceTime || currentPlayback.serverTime || '-'},${formatNumber(currentPlayback.speedKmh, ' km/h')},${formatNumber(currentPlayback.totalMileageKm, ' km')}` : '-'}`, `当前点地址:${currentAddress?.formattedAddress || '-'}`, `异常判读:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`, `最大断点:${formatDurationMinutes(anomalySummary.maxGapMinutes)}`, `最大里程回退:${formatNumber(anomalySummary.maxMileageRollbackKm, ' km')}`, `最高异常速度:${formatNumber(anomalySummary.maxSpeedKmh, ' km/h')}`, `下一步:${anomalyAction}`, `轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: vehicle, protocol, filters: evidenceFilters }))}`, `历史位置:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'location' } }))}`, `历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`, `字段明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'fields', includeFields: 'true', ...(filters.fields?.trim() ? { fields: filters.fields.trim() } : {}) } }))}`, `里程复核:${appURL(buildAppHash({ page: 'mileage', keyword: vehicle, protocol, filters: evidenceFilters }))}`, `车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: vehicle, protocol }))}` ].join('\n'); } function trajectoryImpactPackageText({ filters, locationCount, rawCount, fieldCount, validPointCount, coverageRate, playbackSpanMinutes, playbackIntervalMinutes, mileageDelta, maxSpeed, anomalySummary, amapConfigured }: { filters: HistoryFilters; locationCount: number; rawCount: number; fieldCount: number; validPointCount: number; coverageRate?: number; playbackSpanMinutes?: number; playbackIntervalMinutes?: number; mileageDelta?: number; maxSpeed?: number; anomalySummary: TrajectoryAnomalySummary; amapConfigured: boolean; }) { const vehicle = filters.keyword?.trim() || ''; const protocol = filters.protocol?.trim() || ''; const evidenceFilters = { ...(filters.dateFrom?.trim() ? { dateFrom: filters.dateFrom.trim() } : {}), ...(filters.dateTo?.trim() ? { dateTo: filters.dateTo.trim() } : {}) }; const anomalyTotal = anomalySummary.gapCount + anomalySummary.mileageRollbackCount + anomalySummary.overspeedCount; const operationState = anomalyTotal > 0 || (coverageRate ?? 100) < 80 ? '需要复核' : '可用于业务回放'; return [ '【轨迹运营影响】', `车辆:${vehicle || '全部车辆'}`, `数据通道:${protocol || '全部数据通道'}`, `查询范围:${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}`, `业务状态:${operationState}`, `轨迹范围:位置 ${locationCount.toLocaleString()} / 有效定位 ${validPointCount.toLocaleString()} / 历史明细 ${rawCount.toLocaleString()} / 字段明细 ${fieldCount.toLocaleString()}`, `定位覆盖率:${formatPercent(coverageRate)}`, `回放跨度:${formatDurationMinutes(playbackSpanMinutes)},采样间隔:${formatDurationMinutes(playbackIntervalMinutes, '/点')}`, `里程速度:${formatNumber(mileageDelta, ' km')} / ${formatNumber(maxSpeed, ' km/h')}`, `异常影响:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`, `地图能力:${amapConfigured ? '高德 JS API 已配置' : '高德 JS API 待配置'}`, `轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: vehicle, protocol, filters: evidenceFilters }))}`, `历史查询导出:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'location' } }))}`, `历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`, `车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: vehicle, protocol }))}` ].join('\n'); } function trajectoryCustomerDecisionText({ filters, locationCount, rawCount, fieldCount, validPointCount, coverageRate, playbackSpanMinutes, playbackIntervalMinutes, mileageDelta, maxSpeed, firstLocation, lastLocation, anomalySummary, amapConfigured, deliveryState }: { filters: HistoryFilters; locationCount: number; rawCount: number; fieldCount: number; validPointCount: number; coverageRate?: number; playbackSpanMinutes?: number; playbackIntervalMinutes?: number; mileageDelta?: number; maxSpeed?: number; firstLocation?: HistoryLocationRow; lastLocation?: HistoryLocationRow; anomalySummary: TrajectoryAnomalySummary; amapConfigured: boolean; deliveryState: string; }) { const vehicle = filters.keyword?.trim() || ''; const protocol = filters.protocol?.trim() || ''; const evidenceFilters = { ...(filters.dateFrom?.trim() ? { dateFrom: filters.dateFrom.trim() } : {}), ...(filters.dateTo?.trim() ? { dateTo: filters.dateTo.trim() } : {}) }; const anomalyTotal = anomalySummary.gapCount + anomalySummary.mileageRollbackCount + anomalySummary.overspeedCount; const decision = locationCount > 0 || rawCount > 0 ? anomalyTotal > 0 || (coverageRate ?? 100) < 80 ? '可复盘,需附异常说明' : '可直接复盘' : '待查询数据'; return [ '【客户轨迹决策说明】', `决策结论:${decision}`, `交付状态:${deliveryState}`, `车辆范围:${vehicle || '全部车辆'}`, `数据通道:${protocol || '全部数据通道'}`, `时间范围:${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}`, `轨迹规模:位置 ${locationCount.toLocaleString()} / 有效定位 ${validPointCount.toLocaleString()} / 历史明细 ${rawCount.toLocaleString()} / 字段明细 ${fieldCount.toLocaleString()}`, `定位覆盖:${formatPercent(coverageRate)}`, `回放跨度:${formatDurationMinutes(playbackSpanMinutes)},采样间隔:${formatDurationMinutes(playbackIntervalMinutes, '/点')}`, `区间里程:${formatNumber(mileageDelta, ' km')},最高速度:${formatNumber(maxSpeed, ' km/h')}`, `起点:${firstLocation?.deviceTime || firstLocation?.serverTime || '-'}`, `终点:${lastLocation?.deviceTime || lastLocation?.serverTime || '-'}`, `异常提示:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`, `地图能力:${amapConfigured ? '高德地图可用' : '坐标预览'}`, '', '客户复盘路径:', '1. 先看轨迹覆盖:确认是否有足够有效坐标。', '2. 再看时间断点:断点会影响定位连续性和客户解释。', '3. 再看里程速度:区间里程、速度和轨迹方向需要能互相解释。', '4. 最后回到依据:异常点必须打开历史明细、字段明细和里程统计复核。', `轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: vehicle, protocol, filters: evidenceFilters }))}`, `历史查询导出:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'location' } }))}`, `历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`, `字段明细:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'fields', includeFields: 'true', ...(filters.fields?.trim() ? { fields: filters.fields.trim() } : {}) } }))}`, `里程复核:${appURL(buildAppHash({ page: 'mileage', keyword: vehicle, protocol, filters: evidenceFilters }))}`, `车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: vehicle, protocol }))}` ].join('\n'); } function amapLocationName(row: HistoryLocationRow, fallback: string) { return encodeURIComponent(row.plate || row.vin || fallback); } function amapTrajectoryURL(points: HistoryLocationRow[]) { const validPoints = points.filter(hasValidCoordinate); if (validPoints.length === 0) return ''; if (validPoints.length === 1) { const point = validPoints[0]; return `https://uri.amap.com/marker?position=${point.longitude},${point.latitude}&name=${amapLocationName(point, '车辆位置')}&src=lingniu-vehicle-platform`; } const start = validPoints[0]; const end = validPoints[validPoints.length - 1]; return [ 'https://uri.amap.com/navigation?', `from=${start.longitude},${start.latitude},${amapLocationName(start, '轨迹起点')}`, `&to=${end.longitude},${end.latitude},${amapLocationName(end, '轨迹终点')}`, '&mode=car', '&policy=1', '&src=lingniu-vehicle-platform' ].join(''); } async function copyText(value: string, label: string) { const text = value.trim(); if (!text) { Toast.warning(`${label}为空`); return; } try { await navigator.clipboard.writeText(text); Toast.success(`已复制${label}`); } catch { Toast.error(`复制${label}失败`); } } export function History({ mode = 'trajectory', initialVin, initialProtocol, initialTab, initialFilters = {}, onFiltersChange, onOpenVehicle, onOpenMileage, onOpenRaw }: { mode?: 'trajectory' | 'query'; initialVin: string; initialProtocol?: string; initialTab?: string; initialFilters?: Record; onFiltersChange?: (filters: HistoryFilters, tab?: HistoryTabKey) => void; onOpenVehicle: (vin: string, protocol?: string) => void; onOpenMileage?: (filters: Record) => void; onOpenRaw?: (filters: Record) => void; }) { const initialHistoryFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters); const [filters, setFilters] = useState(initialHistoryFilters); const [activeTab, setActiveTab] = useState(normalizeHistoryTab(initialTab)); const [locations, setLocations] = useState>(defaultPage); const [rawFrames, setRawFrames] = useState>(defaultPage); const [selectedRaw, setSelectedRaw] = useState(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>({}); 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) => { 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(() => 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((max, item) => max == null ? item : Math.max(max, item), undefined); const firstLocation = locationItems[0]; const lastLocation = locationItems[locationItems.length - 1]; const trajectoryCoverageRate = locationItems.length > 0 ? (validLocations.length / locationItems.length) * 100 : undefined; const playbackStartMs = timeValue(firstLocation); const playbackEndMs = timeValue(lastLocation); const playbackSpanMinutes = playbackStartMs != null && playbackEndMs != null ? Math.abs(playbackEndMs - playbackStartMs) / 60000 : undefined; const playbackIntervalMinutes = isFiniteNumber(playbackSpanMinutes) && locationItems.length > 1 ? playbackSpanMinutes / (locationItems.length - 1) : undefined; const trajectoryAnomalies = useMemo(() => analyzeTrajectoryAnomalies(locationItems), [locationItems]); const hasTrajectoryAnomaly = trajectoryAnomalies.gapCount > 0 || trajectoryAnomalies.mileageRollbackCount > 0 || trajectoryAnomalies.overspeedCount > 0; const currentVehicleKeyword = filters.keyword?.trim() ?? ''; const currentProtocol = filters.protocol?.trim() ?? ''; const pageTitle = mode === 'query' ? '历史查询导出' : '轨迹回放'; const pageDescription = mode === 'query' ? '按车辆、时间和数据通道导出位置记录、历史明细和字段明细,支持分页、字段裁剪和 CSV 导出' : '按车辆查询历史位置和轨迹回放,历史明细只作为复核依据,数据通道仅用于过滤'; const scopeDescription = mode === 'query' ? '位置记录、历史明细和字段明细按当前车辆与时间范围分页查询。' : '历史位置和历史明细按当前车辆与时间范围查询。'; const amapConfigured = isAMapConfigured(); const selectedFieldCount = splitFields(filters.fields).length; const playbackRows = validLocations.length > 0 ? validLocations : locationItems; const currentPlaybackIndex = playbackRows.length === 0 ? -1 : Math.min(playbackIndex, playbackRows.length - 1); const currentPlayback = currentPlaybackIndex >= 0 ? playbackRows[currentPlaybackIndex] : undefined; const currentPlaybackPointId = currentPlayback ? playbackPointId(currentPlayback, currentPlaybackIndex) : undefined; const currentPlaybackAddress = currentPlaybackPointId ? reverseGeocodeByPoint[currentPlaybackPointId] : undefined; const playbackAtEnd = playbackRows.length > 0 && currentPlaybackIndex >= playbackRows.length - 1; const playbackPoints: VehicleMapPoint[] = validLocations.map((row, index) => ({ id: playbackPointId(row, index), label: row.plate || row.vin || `点 ${index + 1}`, longitude: row.longitude, latitude: row.latitude, online: true, title: `${row.deviceTime || row.serverTime || '-'} ${row.speedKmh ?? '-'} km/h` })); const filterSummary = [ currentVehicleKeyword ? `车辆:${currentVehicleKeyword}` : '', currentProtocol ? `数据通道:${currentProtocol}` : '', filters.dateFrom?.trim() ? `开始时间:${filters.dateFrom.trim()}` : '', filters.dateTo?.trim() ? `结束时间:${filters.dateTo.trim()}` : '', isIncludeFieldsEnabled(filters.includeFields) ? '返回字段明细' : '', selectedFieldCount > 0 ? `字段裁剪:${selectedFieldCount} 个` : '' ].filter(Boolean); const anomalyTotal = trajectoryAnomalies.gapCount + trajectoryAnomalies.mileageRollbackCount + trajectoryAnomalies.overspeedCount; const deliveryState = locations.total > 0 || (rawFrames.total ?? 0) > 0 ? anomalyTotal > 0 || (trajectoryCoverageRate ?? 100) < 80 ? '可交付,需复核说明' : '可交付' : '待查询数据'; const deliveryStateColor = deliveryState === '可交付' ? 'green' as const : deliveryState === '待查询数据' ? 'grey' as const : 'orange' as const; const trajectoryDecisionState = locations.total > 0 || (rawFrames.total ?? 0) > 0 ? anomalyTotal > 0 || (trajectoryCoverageRate ?? 100) < 80 ? '可复盘,需说明' : '可直接复盘' : '待查询数据'; const trajectoryDecisionColor = trajectoryDecisionState === '可直接复盘' ? 'green' as const : trajectoryDecisionState === '待查询数据' ? 'grey' as const : 'orange' as const; const deliveryScopeText = `${currentVehicleKeyword || '全部车辆'} / ${currentProtocol || '全部数据通道'}`; const trajectoryDecisionItems = [ { label: '复盘结论', value: trajectoryDecisionState, detail: trajectoryDecisionState === '可直接复盘' ? '轨迹连续性和明细覆盖可用于客户复盘。' : trajectoryDecisionState === '待查询数据' ? '先输入车辆和时间范围查询轨迹。' : '轨迹可用,但需要附带断点或异常说明。', color: trajectoryDecisionColor, action: '复制决策', disabled: false, onClick: () => copyTrajectoryDecision() }, { label: '定位覆盖', value: formatPercent(trajectoryCoverageRate), detail: `${validLocations.length.toLocaleString()} / ${locations.total.toLocaleString()} 个有效坐标。`, color: (trajectoryCoverageRate ?? 0) >= 80 ? 'green' as const : locations.total > 0 ? 'orange' as const : 'grey' as const, action: '播放轨迹', disabled: validLocations.length === 0, onClick: () => openQueryTab('location') }, { label: '时间断点', value: `${trajectoryAnomalies.gapCount.toLocaleString()} 项`, detail: trajectoryAnomalies.maxGapMinutes ? `最大断点 ${formatDurationMinutes(trajectoryAnomalies.maxGapMinutes)}。` : '当前页未发现超过 30 分钟断点。', color: trajectoryAnomalies.gapCount > 0 ? 'orange' as const : 'green' as const, action: '复盘包', disabled: false, onClick: () => copyTrajectoryReviewPackage() }, { label: '里程速度', value: formatNumber(mileageDelta, ' km'), detail: `最高速度 ${formatNumber(maxSpeed, ' km/h')},用于和里程统计互相解释。`, color: isFiniteNumber(mileageDelta) ? 'blue' as const : 'grey' as const, action: '里程复核', disabled: !onOpenMileage, onClick: () => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) }) }, { label: '明细复核', value: `${(rawFrames.total ?? 0).toLocaleString()} 帧`, detail: rawFieldRows.length > 0 ? `${rawFieldRows.length.toLocaleString()} 个字段明细可导出。` : '异常点需要回到历史明细和字段明细。', color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const, action: '历史明细', disabled: false, onClick: () => openQueryTab('raw') } ]; const deliveryChecklistItems = [ { title: '位置历史', value: `${locations.total.toLocaleString()} 条`, detail: `${validLocations.length.toLocaleString()} 个有效坐标,交付车辆定位明细。`, color: locations.total > 0 ? 'green' as const : 'grey' as const, action: '导出位置', disabled: locations.items.length === 0, onClick: () => exportLocations() }, { title: '轨迹回放', value: validLocations.length > 0 ? '可回放' : '待查询', detail: '同一时间窗回放路线、速度、里程断点。', color: validLocations.length > 0 ? 'blue' as const : 'orange' as const, action: '查看轨迹', disabled: validLocations.length === 0, onClick: () => openQueryTab('location') }, { title: '里程复核', value: formatNumber(mileageDelta, ' km'), detail: '进入里程统计核对区间里程和日统计闭合。', color: isFiniteNumber(mileageDelta) ? 'green' as const : 'grey' as const, action: '里程复核', disabled: !onOpenMileage, onClick: () => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) }) }, { title: '历史明细', value: `${(rawFrames.total ?? 0).toLocaleString()} 帧`, detail: '保留接入明细,用于解释字段值来源。', color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const, action: '导出明细', disabled: rawFrames.items.length === 0, onClick: () => exportRawFrames() }, { title: '字段明细', value: `${rawFieldRows.length.toLocaleString()} 个`, detail: selectedFieldCount > 0 ? `按 ${selectedFieldCount.toLocaleString()} 个字段裁剪。` : '可切换字段明细生成字段级交付。', color: rawFieldRows.length > 0 ? 'green' as const : 'orange' as const, action: '字段明细', disabled: false, onClick: () => openQueryTab('fields') }, { title: '质量提示', value: anomalyTotal > 0 ? `${anomalyTotal.toLocaleString()} 项` : '无明显异常', detail: `断点 ${trajectoryAnomalies.gapCount.toLocaleString()} / 里程回退 ${trajectoryAnomalies.mileageRollbackCount.toLocaleString()} / 超速 ${trajectoryAnomalies.overspeedCount.toLocaleString()}。`, color: anomalyTotal > 0 ? 'orange' as const : 'green' as const, action: '复制清单', disabled: false, onClick: () => copyDeliveryPackage() } ]; const clearRangeFilters = () => { applyFilters(currentVehicleKeyword ? { keyword: currentVehicleKeyword } : {}); }; const exportLocations = () => { if (locations.items.length === 0) { Toast.warning('当前没有可导出的位置历史'); return; } downloadCsv(exportFileName('history-locations', filters), buildCsv(locationExportColumns, locations.items)); Toast.success(`已导出 ${locations.items.length} 条位置历史`); }; const exportRawFrames = () => { if (rawFrames.items.length === 0) { Toast.warning('当前没有可导出的历史明细'); return; } downloadCsv(exportFileName('raw-frames', filters), buildCsv(rawExportColumns, rawFrames.items)); Toast.success(`已导出 ${rawFrames.items.length} 条历史明细`); }; const exportRawFields = () => { if (rawFieldRows.length === 0) { Toast.warning('当前没有可导出的字段明细'); return; } downloadCsv(exportFileName('raw-fields', filters), buildCsv(rawFieldExportColumns, rawFieldRows)); Toast.success(`已导出 ${rawFieldRows.length} 条字段明细`); }; const copyTrajectorySummary = () => { copyText( trajectorySummaryText({ filters, totalPoints: locations.total, validPointCount: validLocations.length, mileageDelta, maxSpeed, firstLocation, lastLocation }), '轨迹摘要' ); }; const copyHistoryEvidencePackage = () => { copyText( historyEvidencePackageText({ filters, locationCount: locations.total ?? 0, rawCount: rawFrames.total ?? 0, fieldCount: rawFieldRows.length, validPointCount: validLocations.length, mileageDelta, maxSpeed, firstLocation, lastLocation, anomalySummary: trajectoryAnomalies }), '数据复核包' ); }; const copyDeliveryPackage = () => { copyText( deliveryPackageText({ filters, locationCount: locations.total ?? 0, rawCount: rawFrames.total ?? 0, fieldCount: rawFieldRows.length, validPointCount: validLocations.length, coverageRate: trajectoryCoverageRate, mileageDelta, anomalySummary: trajectoryAnomalies }), '客户查询导出包' ); }; const copyTrajectoryReviewPackage = () => { copyText( trajectoryReviewPackageText({ filters, locationCount: locations.total ?? 0, rawCount: rawFrames.total ?? 0, fieldCount: rawFieldRows.length, validPointCount: validLocations.length, coverageRate: trajectoryCoverageRate, playbackSpanMinutes, playbackIntervalMinutes, mileageDelta, maxSpeed, firstLocation, lastLocation, currentPlayback, currentPlaybackIndex, playbackCount: playbackRows.length, currentAddress: currentPlaybackAddress, anomalySummary: trajectoryAnomalies }), '轨迹复盘交接包' ); }; const copyTrajectoryImpactPackage = () => { copyText( trajectoryImpactPackageText({ filters, locationCount: locations.total ?? 0, rawCount: rawFrames.total ?? 0, fieldCount: rawFieldRows.length, validPointCount: validLocations.length, coverageRate: trajectoryCoverageRate, playbackSpanMinutes, playbackIntervalMinutes, mileageDelta, maxSpeed, anomalySummary: trajectoryAnomalies, amapConfigured }), '轨迹运营影响' ); }; const copyTrajectoryDecision = () => { copyText( trajectoryCustomerDecisionText({ filters, locationCount: locations.total ?? 0, rawCount: rawFrames.total ?? 0, fieldCount: rawFieldRows.length, validPointCount: validLocations.length, coverageRate: trajectoryCoverageRate, playbackSpanMinutes, playbackIntervalMinutes, mileageDelta, maxSpeed, firstLocation, lastLocation, anomalySummary: trajectoryAnomalies, amapConfigured, deliveryState }), '客户轨迹决策说明' ); }; const openAmapTrajectory = () => { const url = amapTrajectoryURL(validLocations); if (!url) { Toast.warning('当前轨迹没有有效坐标'); return; } window.open(url, '_blank', 'noopener,noreferrer'); }; const resolveCurrentPlaybackAddress = () => { if (!currentPlayback || !currentPlaybackPointId || !hasValidCoordinate(currentPlayback)) { Toast.warning('当前回放点没有有效坐标'); return; } if (currentPlaybackAddress) { Toast.info('当前回放点地址已解析'); return; } const params = new URLSearchParams({ longitude: String(currentPlayback.longitude), latitude: String(currentPlayback.latitude) }); setReverseGeocoding(true); api.reverseGeocode(params) .then((address) => { setReverseGeocodeByPoint((current) => ({ ...current, [currentPlaybackPointId]: address })); Toast.success('已解析当前点地址'); }) .catch((error: Error) => Toast.error(error.message)) .finally(() => setReverseGeocoding(false)); }; useEffect(() => { if (!playbackPlaying || playbackRows.length <= 1) return undefined; const timer = window.setInterval(() => { setPlaybackIndex((current) => { if (current >= playbackRows.length - 1) { setPlaybackPlaying(false); return current; } const next = current + 1; if (next >= playbackRows.length - 1) setPlaybackPlaying(false); return next; }); }, playbackSpeedMs); return () => window.clearInterval(timer); }, [playbackPlaying, playbackRows.length, playbackSpeedMs]); const togglePlayback = () => { if (playbackPlaying) { setPlaybackPlaying(false); return; } if (playbackRows.length <= 1) return; if (playbackAtEnd) setPlaybackIndex(0); setPlaybackPlaying(true); }; const movePlayback = (delta: number) => { if (playbackRows.length === 0) return; setPlaybackPlaying(false); setPlaybackIndex((current) => Math.min(Math.max(current + delta, 0), playbackRows.length - 1)); }; const selectPlaybackPoint = (nextIndex: number) => { if (playbackRows.length === 0) return; setPlaybackPlaying(false); setPlaybackIndex(Math.min(Math.max(nextIndex, 0), playbackRows.length - 1)); }; const selectPlaybackMapPoint = (point: VehicleMapPoint) => { const index = playbackRows.findIndex((row, rowIndex) => playbackPointId(row, rowIndex) === point.id); if (index >= 0) { selectPlaybackPoint(index); } }; const openPlaybackVehicle = () => { if (!currentPlayback || !canOpenVehicle(currentPlayback.vin)) return; onOpenVehicle(currentPlayback.vin, currentPlayback.protocol); }; const openLocationMileage = (row: HistoryLocationRow) => { if (!canOpenVehicle(row.vin)) return; const day = dateOnly(row.deviceTime || row.serverTime || row.lastSeen); onOpenMileage?.({ keyword: row.vin, protocol: row.protocol, ...(day ? { dateFrom: day, dateTo: nextDate(day) } : {}) }); }; const openLocationRaw = (row: HistoryLocationRow) => { if (!canOpenVehicle(row.vin)) return; const day = dateOnly(row.deviceTime || row.serverTime || row.lastSeen); onOpenRaw?.({ keyword: row.vin, protocol: row.protocol, ...(day ? { dateFrom: day, dateTo: nextDate(day) } : {}), includeFields: 'true' }); }; const openRawMileage = (row: RawFrameRow) => { if (!canOpenVehicle(row.vin)) return; const day = dateOnly(row.deviceTime || row.serverTime); onOpenMileage?.({ keyword: row.vin, protocol: row.protocol, ...(day ? { dateFrom: day, dateTo: nextDate(day) } : {}) }); }; const openQueryTab = (tab: HistoryTabKey) => { const nextFilters = tab === 'fields' ? { ...filters, includeFields: true } : filters; setActiveTab(tab); setFilters(nextFilters); onFiltersChange?.(nextFilters, tab); if (tab === 'location') { loadLocations(nextFilters, 1, locationPagination.pageSize); return; } loadRawFrames(nextFilters, 1, rawPagination.pageSize, tab === 'fields'); }; return (
)} />
当前车辆:{currentVehicleKeyword || '-'} 数据通道:{currentProtocol || '全部数据通道'} {scopeDescription}
{mode === 'query' ? (
客户数据交付中心 {deliveryState} {activeTab === 'location' ? '位置历史' : activeTab === 'raw' ? '历史明细' : '字段明细'} 先锁定车辆和时间窗,再选择交付物并导出 面向客户的数据导出以车辆服务为中心:同一个筛选范围可以交付位置历史、历史明细、字段明细、CSV 文件、轨迹复盘和里程复核。
{[ { 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('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) => ( ))}
) : null} 客户轨迹决策台} style={{ marginTop: 16 }} >
{trajectoryDecisionState} {deliveryState} {amapConfigured ? '高德地图可用' : '坐标预览'} 先判断轨迹能否复盘,再进入明细、字段和里程复核 客户问某辆车某段时间发生了什么时,先看定位覆盖、时间断点、里程速度和历史明细,避免把内部排查过程当成客户主流程。
{trajectoryDecisionItems.map((item) => ( ))}
{mode === 'query' ? (
客户查询 {activeTab === 'location' ? '位置历史' : activeTab === 'raw' ? '历史明细' : '字段明细'} {currentVehicleKeyword || '全部车辆'} 面向车辆服务的历史数据检索入口。先按车辆和时间缩小范围,再导出位置、历史明细或字段明细,用于客户问询、BI 核对和问题追溯。 默认查询最近一天,不预置任何车辆;输入 VIN、车牌或手机号后进入单车查询导出。
{[ { 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) => (
{item.label} {item.value} {item.detail}
))}
) : null} {mode === 'query' ? (
{deliveryState} {currentProtocol || '全部数据通道'} {deliveryScopeText} 面向客户交付时,先确认车辆、时间、交付物和质量提示,再导出位置、历史明细或字段明细,避免把内部排查过程直接暴露给客户。
{[ { 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) => ( ))}
) : null} {mode === 'query' ? ( 客户交付物清单} style={{ marginTop: 16 }} >
{deliveryChecklistItems.map((item) => ( ))}
) : null}
submit(values)}> GB32960 JT808 YUTONG_MQTT 返回字段明细
{filterSummary.length > 0 ? ( {filterSummary.map((item) => ( {item} ))} ) : null}
{[ { 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) => (
{task.title} {task.tag}
{task.value} {task.detail}
))}
{hasTrajectoryAnomaly ? '需要复核' : '可用于业务回放'} {amapConfigured ? '地图已接入' : '地图待接入'} {currentVehicleKeyword || '全部车辆'} 轨迹、历史查询导出、历史明细和里程复核按同一车辆范围联动,用于调度复盘、客户问询和断链定位。
{[ { 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) => (
{item.label} {item.value} {item.detail}
))}
{mode === 'query' ? '轨迹预览与复核' : '轨迹回放作业台'} )} style={{ marginTop: 16 }} >
{validLocations.length.toLocaleString()} 个有效轨迹点 {currentProtocol || '全部数据通道'} {formatNumber(mileageDelta, ' km')} {amapConfigured ? '高德地图配置就绪' : '高德地图待配置'}
{[ { 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) => (
{item.label}
{item.value}
))}
当前回放点
{currentPlayback ? ( <> 点 {currentPlaybackIndex + 1} / {playbackRows.length} {currentPlayback.protocol || '-'} {currentPlayback.plate || currentPlayback.vin} {currentPlayback.deviceTime || currentPlayback.serverTime || '-'} {formatNumber(currentPlayback.speedKmh, ' km/h')} {formatNumber(currentPlayback.totalMileageKm, ' km')}
{currentPlaybackAddress?.provider || '地址未解析'} {currentPlaybackAddress?.formattedAddress || `${currentPlayback.longitude}, ${currentPlayback.latitude}`}
播放速度 selectPlaybackPoint(Number(event.currentTarget.value))} /> ) : ( 暂无可回放位置点 )}
{(validLocations.length > 0 ? validLocations : locationItems).slice(0, 8).map((row, index) => ( ))} {locationItems.length === 0 ? ( 当前筛选范围暂无轨迹点,请调整车辆、数据通道或时间范围。 ) : null}
{[ { 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) => ( {item.label}
{item.value}
{item.detail}
))}
{[ { 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) => (
{item.label}
{item.value}
{item.detail}
))}
{hasTrajectoryAnomaly ? '建议核对历史明细和当日里程统计' : '轨迹质量可用'} {hasTrajectoryAnomaly ? '异常点会影响轨迹回放、定位复盘和区间里程判断,请结合字段明细与里程统计交叉确认。' : '当前页轨迹未发现明显断点、里程回退或速度异常。'}
起点:{firstLocation?.deviceTime || firstLocation?.serverTime || '-'} 终点:{lastLocation?.deviceTime || lastLocation?.serverTime || '-'} 地图接入:高德 JS API 运行时配置
changeTab(String(key))}>
当前页 {locations.items.length.toLocaleString()} 条
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) => ( ) } ]} />
当前页 {rawFrames.items.length.toLocaleString()} 条 0 ? 'green' : 'orange'}> {isIncludeFieldsEnabled(filters.includeFields) || splitFields(filters.fields).length > 0 ? '包含字段明细' : '未请求字段明细'}
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) => ( ) } ]} />
当前页 {rawFieldRows.length.toLocaleString()} 个字段 {rawFrames.items.length.toLocaleString()} 条历史明细 {selectedFieldCount > 0 ? 字段裁剪 {selectedFieldCount.toLocaleString()} 个 : 全量字段明细}
( {formatFieldValue(row.fieldValue) || '-'} ) }, { 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) => ( ) } ]} /> 分页沿用历史明细分页 字段来自 parsedFields,保留协议字段映射后的路径 setSelectedRaw(null)} width={720}> {selectedRaw?.protocol ?? '-'} / {selectedRaw?.plate || selectedRaw?.vin || '-'} / {rawFieldCount} 个字段 {rawFieldCount === 0 ? ( 当前查询未返回字段明细,请勾选“返回字段明细”或配置字段裁剪后重新查询。 ) : null}
{JSON.stringify(selectedRaw?.parsedFields ?? {}, null, 2)}
); }