407 lines
19 KiB
TypeScript
407 lines
19 KiB
TypeScript
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<string, string> = {}): 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<string, string>;
|
||
onFiltersChange?: (filters: HistoryFilters, tab?: HistoryTabKey) => void;
|
||
onOpenVehicle: (vin: string, protocol?: 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 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<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);
|
||
};
|
||
|
||
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<number | undefined>((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 (
|
||
<div className="vp-page">
|
||
<PageHeader
|
||
title="轨迹回放"
|
||
description="按车辆查询历史位置、轨迹回放和 RAW 帧证据,数据来源只作为过滤和诊断维度"
|
||
actions={(
|
||
<Button disabled={!currentVehicleKeyword} onClick={() => onOpenVehicle(currentVehicleKeyword, currentProtocol)}>
|
||
当前车辆服务
|
||
</Button>
|
||
)}
|
||
/>
|
||
<div className="vp-scope-bar">
|
||
<span className="vp-scope-label">当前车辆:{currentVehicleKeyword || '-'}</span>
|
||
<Tag color={currentProtocol ? 'blue' : 'green'}>当前来源:{currentProtocol || '全部来源'}</Tag>
|
||
<Typography.Text type="tertiary">历史位置和 RAW 帧按当前车辆与来源范围查询。</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="轨迹回放作业台" 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>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-map vp-map-monitor">
|
||
{validLocations.slice(0, 120).map((row, index) => (
|
||
<span
|
||
key={`${row.vin}-${row.deviceTime}-${index}`}
|
||
className={`vp-map-dot ${index === 0 ? 'vp-map-dot-start' : index === validLocations.length - 1 ? 'vp-map-dot-end' : 'vp-map-dot-online'}`}
|
||
title={`${row.deviceTime} ${row.speedKmh ?? '-'} km/h`}
|
||
style={mapPointStyle(row, index)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</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>
|
||
</div>
|
||
<div className="vp-playback-timeline">
|
||
{(validLocations.length > 0 ? validLocations : locationItems).slice(0, 8).map((row, index) => (
|
||
<div key={`${row.deviceTime}-${index}`} className="vp-playback-step">
|
||
<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>
|
||
</div>
|
||
))}
|
||
{locationItems.length === 0 ? (
|
||
<Typography.Text type="secondary">当前筛选范围暂无轨迹点,请调整车辆、来源或时间范围。</Typography.Text>
|
||
) : null}
|
||
</div>
|
||
<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">
|
||
<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: 110,
|
||
render: (_: unknown, row: HistoryLocationRow) => (
|
||
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => onOpenVehicle(row.vin, row.protocol)}>车辆服务</Button>
|
||
)
|
||
}
|
||
]}
|
||
/>
|
||
</Tabs.TabPane>
|
||
<Tabs.TabPane tab="RAW 帧" itemKey="raw">
|
||
<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: 200,
|
||
render: (_: unknown, row: RawFrameRow) => (
|
||
<Space>
|
||
<Button onClick={() => setSelectedRaw(row)}>字段</Button>
|
||
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => onOpenVehicle(row.vin, row.protocol)}>车辆服务</Button>
|
||
</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>
|
||
);
|
||
}
|