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 { PageHeader } from '../components/PageHeader'; 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 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 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 mapPointStyle(row: HistoryLocationRow, index: number) { if (!hasValidCoordinate(row)) { return { left: `${18 + (index % 4) * 18}%`, top: `${28 + (index % 3) * 17}%` }; } const left = Math.min(88, Math.max(10, ((row.longitude + 180) / 360) * 100)); const top = Math.min(82, Math.max(12, ((90 - row.latitude) / 180) * 100)); return { left: `${left}%`, top: `${top}%` }; } 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 { return { ...defaultFilters, keyword: initialVin || defaultFilters.keyword, protocol: initialProtocol, ...initialFilters, includeFields: isIncludeFieldsEnabled(initialFilters.includeFields) }; } export function History({ initialVin, initialProtocol, initialTab, initialFilters = {}, onFiltersChange, onOpenVehicle }: { initialVin: string; initialProtocol?: string; initialTab?: string; initialFilters?: Record; onFiltersChange?: (filters: HistoryFilters, tab?: HistoryTabKey) => void; onOpenVehicle: (vin: string, protocol?: string) => 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 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 }); }) .catch((error: Error) => Toast.error(error.message)) .finally(() => setLoadingLocations(false)); }; const loadRawFrames = (nextFilters = filters, page = rawPagination.currentPage, pageSize = rawPagination.pageSize) => { 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 selectedFieldCount = splitFields(filters.fields).length; 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 } : {}); }; 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')}
{validLocations.slice(0, 120).map((row, index) => ( ))}
{[ { 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}
))}
{(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))}> 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: 110, render: (_: unknown, row: HistoryLocationRow) => ( ) } ]} />
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: 200, render: (_: unknown, row: RawFrameRow) => ( ) } ]} /> setSelectedRaw(null)} width={720}> {selectedRaw?.protocol ?? '-'} / {selectedRaw?.plate || selectedRaw?.vin || '-'} / {rawFieldCount} 个字段 {rawFieldCount === 0 ? ( 当前查询未返回解析字段,请勾选“返回解析字段”或配置字段裁剪后重新查询。 ) : null}
{JSON.stringify(selectedRaw?.parsedFields ?? {}, null, 2)}
); }