Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/History.tsx
2026-07-04 20:57:28 +08:00

1203 lines
55 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, 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 = {
keyword: 'LB9A32A24R0LS1426',
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 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 hasExplicitFilters = Object.keys(initialFilters).length > 0;
return {
...defaultFilters,
keyword: initialVin || (hasExplicitFilters ? '' : defaultFilters.keyword),
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: 'RAW 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()}`,
`RAW帧${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' } }))}`,
`RAW证据${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 trajectoryReviewPackageText({
filters,
locationCount,
rawCount,
fieldCount,
validPointCount,
coverageRate,
playbackSpanMinutes,
playbackIntervalMinutes,
mileageDelta,
maxSpeed,
firstLocation,
lastLocation,
currentPlayback,
currentPlaybackIndex,
playbackCount,
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;
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
? '建议核对 RAW 帧、解析字段、当日里程统计,并确认平台是否存在断链或补发。'
: '当前页未发现明显断点、里程回退或速度异常,可作为轨迹证据使用。';
return [
'【轨迹复盘交接包】',
`车辆:${vehicle || '全部车辆'}`,
`数据来源:${protocol || '全部来源'}`,
`查询范围:${filters.dateFrom?.trim() || '-'}${filters.dateTo?.trim() || '-'}`,
`轨迹规模:位置 ${locationCount.toLocaleString()} / 有效定位 ${validPointCount.toLocaleString()} / RAW ${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')}` : '-'}`,
`异常判读:断点 ${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' } }))}`,
`RAW证据${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 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);
})
.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('RAW 解析字段查询需要车辆、时间范围或字段裁剪');
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'
? '按车辆查询历史位置、RAW 帧和扁平解析字段,支持分页、字段裁剪和证据导出'
: '按车辆查询历史位置、轨迹回放和 RAW 帧证据,数据来源只作为过滤和诊断维度';
const scopeDescription = mode === 'query'
? '历史位置、RAW 帧和解析字段按当前车辆与来源范围分页查询。'
: '历史位置和 RAW 帧按当前车辆与来源范围查询。';
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 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 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('当前没有可导出的 RAW 帧');
return;
}
downloadCsv(exportFileName('raw-frames', filters), buildCsv(rawExportColumns, rawFrames.items));
Toast.success(`已导出 ${rawFrames.items.length} 条 RAW 帧`);
};
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 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,
anomalySummary: trajectoryAnomalies
}),
'轨迹复盘交接包'
);
};
const openAmapTrajectory = () => {
const url = amapTrajectoryURL(validLocations);
if (!url) {
Toast.warning('当前轨迹没有有效坐标');
return;
}
window.open(url, '_blank', 'noopener,noreferrer');
};
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) } : {})
});
};
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>
<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={(
<Space>
<span></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>
<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" 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 ? '建议核对 RAW 帧和当日里程统计' : '轨迹质量可用'}</Tag>
<Typography.Text type="secondary">
{hasTrajectoryAnomaly ? '异常点会影响轨迹回放、定位复盘和区间里程判断,请结合 RAW 解析字段与里程统计交叉确认。' : '当前页轨迹未发现明显断点、里程回退或速度异常。'}
</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)}> RAW</Button>
</Space>
)
}
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="RAW 帧" 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}> RAW 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()} RAW </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: 'RAW 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">沿 RAW </Tag>
<Tag color="grey"> parsedFields</Tag>
</Space>
</Tabs.TabPane>
</Tabs>
</Card>
<SideSheet title="RAW 解析字段" 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>
);
}