import { Button, Card, Form, Select, SideSheet, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui'; import { 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 { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport'; type HistoryFilters = { keyword?: string; protocol?: string; dateFrom?: string; dateTo?: string; fields?: string; includeFields?: boolean | string; }; 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'; function normalizeHistoryTab(value?: string): HistoryTabKey { 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 mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record = {}): 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[] = [ { 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 ?? {} } ]; function exportFileName(prefix: string, filters: HistoryFilters) { const keyword = filters.keyword?.trim() || 'all'; const protocol = filters.protocol?.trim() || 'all-source'; return `${prefix}-${keyword}-${protocol}.csv`; } export function History({ initialVin, initialProtocol, initialTab, initialFilters = {}, onFiltersChange, onOpenVehicle, onOpenMileage, onOpenRaw }: { 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 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): 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 (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); }) .catch((error: Error) => Toast.error(error.message)) .finally(() => setLoadingLocations(false)); }; const loadRawFrames = (nextFilters = filters, page = rawPagination.currentPage, pageSize = rawPagination.pageSize) => { if (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)) .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); }; const applyFilters = (nextFilters: HistoryFilters) => { setFilters(nextFilters); onFiltersChange?.(nextFilters, activeTab); loadLocations(nextFilters, 1, locationPagination.pageSize); loadRawFrames(nextFilters, 1, rawPagination.pageSize); }; const reset = () => { const nextFilters = mergeInitialFilters(initialVin, initialProtocol, {}); applyFilters(nextFilters); }; useEffect(() => { const nextFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters); setFilters(nextFilters); setActiveTab(normalizeHistoryTab(initialTab)); loadLocations(nextFilters, 1, locationPagination.pageSize); loadRawFrames(nextFilters, 1, rawPagination.pageSize); }, [initialVin, initialProtocol, initialTab, JSON.stringify(initialFilters)]); const changeTab = (key: string) => { const nextTab = normalizeHistoryTab(String(key)); setActiveTab(nextTab); onFiltersChange?.(filters, nextTab); }; const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]); 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 currentVehicleKeyword = filters.keyword?.trim() ?? ''; const currentProtocol = filters.protocol?.trim() ?? ''; 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 playbackPoints: VehicleMapPoint[] = validLocations.map((row, index) => ({ id: `${row.vin}-${row.deviceTime || row.serverTime || 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 movePlayback = (delta: number) => { if (playbackRows.length === 0) return; setPlaybackIndex((current) => Math.min(Math.max(current + delta, 0), playbackRows.length - 1)); }; 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 (
onOpenVehicle(currentVehicleKeyword, currentProtocol)}> 当前车辆服务 )} />
当前车辆:{currentVehicleKeyword || '-'} 当前来源:{currentProtocol || '全部来源'} 历史位置和 RAW 帧按当前车辆与来源范围查询。
submit(values)}> GB32960 JT808 YUTONG_MQTT 返回解析字段
{filterSummary.length > 0 ? ( {filterSummary.map((item) => ( {item} ))} ) : null}
{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')} ) : ( 暂无可回放位置点 )}
{(validLocations.length > 0 ? validLocations : locationItems).slice(0, 8).map((row, index) => (
{index === 0 ? '起点' : index === Math.min((validLocations.length || locationItems.length), 8) - 1 ? '终点' : `点 ${index + 1}`}
{row.deviceTime || row.serverTime || '-'}
{formatNumber(row.speedKmh, ' km/h')} / {formatNumber(row.totalMileageKm, ' km')}
))} {locationItems.length === 0 ? ( 当前筛选范围暂无轨迹点,请调整车辆、来源或时间范围。 ) : null}
起点:{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) => ( ) } ]} /> setSelectedRaw(null)} width={720}> {selectedRaw?.protocol ?? '-'} / {selectedRaw?.plate || selectedRaw?.vin || '-'} / {rawFieldCount} 个字段 {rawFieldCount === 0 ? ( 当前查询未返回解析字段,请勾选“返回解析字段”或配置字段裁剪后重新查询。 ) : null}
{JSON.stringify(selectedRaw?.parsedFields ?? {}, null, 2)}
); }