Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx
2026-07-04 04:01:33 +08:00

462 lines
23 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 { Banner, Button, Card, Descriptions, Form, Select, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui';
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api/client';
import type { QualityIssueRow, VehicleDetail as VehicleDetailData, VehicleSourceStatus } from '../api/types';
import { PageHeader } from '../components/PageHeader';
import { StatusTag } from '../components/StatusTag';
import { isLikelyVIN } from '../domain/vehicleLookup';
type VehicleQuery = {
keyword: string;
protocol?: string;
};
const sourceCapabilities: Array<{ key: keyof Pick<VehicleSourceStatus, 'hasRealtime' | 'hasHistory' | 'hasRaw' | 'hasMileage'>; label: string }> = [
{ key: 'hasRealtime', label: '实时' },
{ key: 'hasHistory', label: '历史' },
{ key: 'hasRaw', label: 'RAW' },
{ key: 'hasMileage', label: '里程' }
];
const coverageStatusText: Record<string, string> = {
online: '全部在线',
partial: '部分在线',
offline: '全部离线',
no_data: '暂无来源'
};
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function formatCompactNumber(value?: number, suffix = '') {
if (!isFiniteNumber(value)) return '-';
return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}${suffix}`;
}
function parseTimeMs(value?: string) {
if (!value) return undefined;
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : undefined;
}
function formatSeconds(value?: number) {
if (!isFiniteNumber(value)) return '-';
return `${Math.round(value)}`;
}
export function VehicleDetail({
vin,
protocol,
onOpenHistory,
onOpenMileage,
onQueryChange
}: {
vin: string;
protocol?: string;
onOpenHistory: (vin: string, protocol?: string) => void;
onOpenMileage: (vin: string, protocol?: string) => void;
onQueryChange?: (keyword: string, protocol?: string) => void;
}) {
const [query, setQuery] = useState<VehicleQuery>({ keyword: vin, protocol });
const [detail, setDetail] = useState<VehicleDetailData | null>(null);
const [loading, setLoading] = useState(false);
const requestSeq = useRef(0);
const load = (nextQuery = query) => {
const keyword = nextQuery.keyword.trim();
if (!keyword) {
Toast.warning('请输入 VIN / 车牌 / 手机号');
return;
}
setLoading(true);
const params = new URLSearchParams({ keyword });
if (nextQuery.protocol?.trim()) {
params.set('protocol', nextQuery.protocol.trim());
}
const seq = requestSeq.current + 1;
requestSeq.current = seq;
api.vehicleDetail(params)
.then((nextDetail) => {
if (requestSeq.current === seq) {
setDetail(nextDetail);
}
})
.catch((error: Error) => {
if (requestSeq.current === seq) {
Toast.error(error.message);
}
})
.finally(() => {
if (requestSeq.current === seq) {
setLoading(false);
}
});
};
useEffect(() => {
const nextQuery = { keyword: vin, protocol };
setQuery(nextQuery);
load(nextQuery);
}, [vin, protocol]);
const identity = detail?.identity;
const summary = detail?.realtimeSummary;
const latest = detail?.realtime?.[0];
const resolution = detail?.resolution;
const overview = detail?.serviceOverview;
const resolvedVIN = resolution?.vin || detail?.vin || summary?.vin || identity?.vin || latest?.vin || query.keyword;
const hasResolvedVIN = resolution?.resolved ?? detail?.lookupResolved ?? isLikelyVIN(resolvedVIN);
const displayVIN = hasResolvedVIN ? resolvedVIN : '-';
const displayLookupKey = resolution?.lookupKey || detail?.lookupKey || query.keyword;
const protocols = useMemo(() => resolution?.protocols?.length ? resolution.protocols : detail?.sources ?? [], [detail?.sources, resolution?.protocols]);
const latestRaw = detail?.raw?.items?.[0];
const qualityCount = detail?.quality?.total ?? 0;
const online = resolution?.online || summary?.online || identity?.online || false;
const lastSeen = resolution?.lastSeen || summary?.lastSeen || latest?.lastSeen || '-';
const serviceStatus = detail?.serviceStatus;
const realtimeRows = detail?.realtime ?? [];
const realtimeSources = new Set(realtimeRows.map((row) => row.protocol).filter(Boolean));
const sourceCount = Math.max(detail?.sourceStatus?.length ?? 0, realtimeSources.size, summary?.sourceCount ?? 0, protocols.length);
const onlineSourceCount = detail?.sourceStatus?.length
? detail.sourceStatus.filter((source) => source.online).length
: summary?.onlineSourceCount ?? 0;
const locatedSourceCount = realtimeRows.filter((row) => isFiniteNumber(row.longitude) && isFiniteNumber(row.latitude)).length;
const mileageValues = realtimeRows.map((row) => row.totalMileageKm).filter(isFiniteNumber);
const mileageDelta = mileageValues.length > 1 ? Math.max(...mileageValues) - Math.min(...mileageValues) : undefined;
const timeValues = [
...realtimeRows.map((row) => parseTimeMs(row.lastSeen)),
...(detail?.sourceStatus ?? []).map((source) => parseTimeMs(source.lastSeen))
].filter(isFiniteNumber);
const sourceTimeDeltaSeconds = timeValues.length > 1 ? (Math.max(...timeValues) - Math.min(...timeValues)) / 1000 : undefined;
const consistency = detail?.sourceConsistency;
const consistencySourceCount = consistency?.sourceCount ?? sourceCount;
const consistencyOnlineSourceCount = consistency?.onlineSourceCount ?? onlineSourceCount;
const consistencyLocatedSourceCount = consistency?.locatedSourceCount ?? locatedSourceCount;
const consistencyMileageDelta = consistency?.mileageDeltaKm ?? mileageDelta;
const consistencyTimeDeltaSeconds = consistency?.sourceTimeDeltaSeconds ?? sourceTimeDeltaSeconds;
const activeProtocol = query.protocol?.trim() ?? '';
const scopeText = activeProtocol ? `单一来源:${activeProtocol}` : '全部来源聚合';
const formKey = `${query.keyword}-${query.protocol ?? ''}`;
const switchSource = (nextProtocol = '') => {
const nextQuery = { keyword: resolvedVIN || query.keyword, protocol: nextProtocol };
setQuery(nextQuery);
onQueryChange?.(nextQuery.keyword, nextQuery.protocol);
load(nextQuery);
};
const qualityTable = (
<Table
loading={loading}
pagination={false}
rowKey={(row?: QualityIssueRow) => `${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.lastSeen ?? ''}`}
dataSource={detail?.quality?.items ?? []}
columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ title: '问题', dataIndex: 'issueType', width: 150 },
{ title: '级别', width: 110, render: (_: unknown, row: QualityIssueRow) => <Tag color={row.severity === 'error' ? 'red' : 'orange'}>{row.severity}</Tag> },
{ title: '最后时间', dataIndex: 'lastSeen', width: 190 },
{ title: '说明', dataIndex: 'detail' }
]}
/>
);
return (
<div className="vp-page">
<PageHeader title="车辆服务" description="以 VIN 为主对象聚合身份、实时、历史、RAW 和里程,协议仅作为数据来源" />
<Card bordered>
<Form key={formKey} initValues={query} layout="horizontal" onSubmit={(values) => {
const nextQuery = { keyword: String(values.keyword ?? ''), protocol: String(values.protocol ?? '') };
setQuery(nextQuery);
onQueryChange?.(nextQuery.keyword, nextQuery.protocol);
load(nextQuery);
}}>
<Form.Input field="keyword" label="车辆关键词" placeholder="VIN / 车牌 / 手机号" style={{ width: 280 }} />
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 180 }}>
<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>
<Space>
<Button icon={<IconSearch />} htmlType="submit" theme="solid" type="primary"></Button>
<Button icon={<IconRefresh />} onClick={() => load(query)} loading={loading}></Button>
<Button disabled={!hasResolvedVIN} onClick={() => onOpenHistory(resolvedVIN, activeProtocol)}></Button>
<Button disabled={!hasResolvedVIN} onClick={() => onOpenMileage(resolvedVIN, activeProtocol)}></Button>
</Space>
</Form>
</Card>
<div className="vp-scope-bar">
<span className="vp-scope-label"></span>
<Tag color={activeProtocol ? 'blue' : 'green'}>{scopeText}</Tag>
<Typography.Text type="tertiary">
{activeProtocol ? '下方实时、历史、RAW 和里程仅展示该来源返回的数据。' : '下方数据按车辆聚合展示全部可用来源。'}
</Typography.Text>
</div>
{serviceStatus ? (
<div className={`vp-service-status vp-service-status-${serviceStatus.severity}`}>
<span className="vp-scope-label"></span>
<Tag color={serviceStatus.severity === 'ok' ? 'green' : serviceStatus.severity === 'error' ? 'red' : 'orange'}>
{serviceStatus.title}
</Tag>
<Typography.Text type="tertiary">{serviceStatus.detail}</Typography.Text>
</div>
) : null}
{overview ? (
<Card bordered title="车辆服务概览" style={{ marginTop: 16 }}>
<Descriptions
row
data={[
{ key: '服务车辆', value: [overview.plate, overview.vin].filter(Boolean).join(' / ') || '-' },
{ key: '在线来源', value: `${overview.onlineSourceCount}/${overview.sourceCount}` },
{ key: '覆盖状态', value: <Tag color={overview.coverageStatus === 'online' ? 'green' : overview.coverageStatus === 'partial' ? 'orange' : 'grey'}>{coverageStatusText[overview.coverageStatus] ?? overview.coverageStatus}</Tag> },
{ key: '主来源', value: overview.primaryProtocol || '-' },
{ key: '最后上报', value: overview.lastSeen || '-' },
{ key: '数据规模', value: `历史 ${overview.historyCount} / RAW ${overview.rawCount} / 里程 ${overview.mileageCount}` },
{ key: '实时来源', value: overview.realtimeCount },
{ key: '质量问题', value: <Tag color={overview.qualityIssueCount > 0 ? 'orange' : 'green'}>{overview.qualityIssueCount}</Tag> }
]}
/>
</Card>
) : null}
<Card bordered style={{ marginTop: 16 }}>
<div className="vp-vehicle-summary">
<Descriptions
row
data={[
{ key: '查询关键词', value: displayLookupKey },
{ key: 'VIN', value: displayVIN },
{ key: '解析状态', value: <Tag color={hasResolvedVIN ? 'green' : 'orange'}>{hasResolvedVIN ? '已解析 VIN' : '未解析到 VIN'}</Tag> },
{ key: '车牌', value: resolution?.plate || summary?.plate || identity?.plate || latest?.plate || '-' },
{ key: '手机号', value: resolution?.phone || summary?.phone || identity?.phone || '-' },
{ key: 'OEM', value: resolution?.oem || summary?.oem || identity?.oem || '-' },
{ key: '在线', value: <StatusTag status={online ? 'ok' : 'offline'} /> },
{
key: '数据来源',
value: protocols.length > 0 ? <Space>{protocols.map((item) => <Tag key={item} color={item === summary?.primaryProtocol ? 'blue' : 'grey'}>{item}</Tag>)}</Space> : '-'
},
{ key: '在线来源', value: summary ? `${summary.onlineSourceCount}/${summary.sourceCount}` : '-' },
{ key: '最后位置时间', value: lastSeen },
{ key: '最新 RAW 时间', value: latestRaw?.serverTime ?? '-' },
{ key: '质量问题', value: <Tag color={qualityCount > 0 ? 'orange' : 'green'}>{qualityCount > 0 ? `${qualityCount}` : '无'}</Tag> }
]}
/>
</div>
{!hasResolvedVIN ? (
<Banner
type="warning"
bordered
title="身份绑定待处理"
description={`车辆关键词 ${displayLookupKey} 暂未解析到 VIN请维护车辆身份绑定后再查看历史、里程和跨来源数据。`}
style={{ marginTop: 12 }}
/>
) : null}
</Card>
{hasResolvedVIN ? (
<>
<Card bordered title="数据来源覆盖" style={{ marginTop: 16 }}>
{detail?.sourceStatus?.length ? (
<>
<div className="vp-source-toolbar">
<Space>
<Tag color={activeProtocol ? 'grey' : 'blue'}>{activeProtocol ? `当前 ${activeProtocol}` : '当前 全部来源'}</Tag>
<Button size="small" disabled={!activeProtocol} onClick={() => switchSource('')}></Button>
</Space>
</div>
<div className="vp-source-grid">
{detail.sourceStatus.map((source) => (
<div key={source.protocol} className={`vp-source-card${activeProtocol === source.protocol ? ' vp-source-card-active' : ''}`}>
<div className="vp-source-card-head">
<Space>
<Tag color={source.protocol === summary?.primaryProtocol ? 'blue' : 'grey'}>{source.protocol}</Tag>
<StatusTag status={source.online ? 'ok' : 'offline'} />
</Space>
<Typography.Text type="tertiary">{source.lastSeen || '无上报'}</Typography.Text>
</div>
<Space spacing={4} wrap>
{sourceCapabilities.map((item) => (
<Tag key={item.key} color={source[item.key] ? 'green' : 'grey'}>
{item.label}{source[item.key] ? '有' : '无'}
</Tag>
))}
</Space>
<div className="vp-source-card-actions">
<Space spacing={6} wrap>
<Button
size="small"
theme={activeProtocol === source.protocol ? 'solid' : 'light'}
type={activeProtocol === source.protocol ? 'primary' : 'tertiary'}
disabled={activeProtocol === source.protocol}
onClick={() => switchSource(source.protocol)}
>
{source.protocol}
</Button>
<Button
size="small"
theme="light"
type="tertiary"
disabled={!source.hasHistory}
onClick={() => onOpenHistory(resolvedVIN, source.protocol)}
>
{source.protocol}
</Button>
<Button
size="small"
theme="light"
type="tertiary"
disabled={!source.hasMileage}
onClick={() => onOpenMileage(resolvedVIN, source.protocol)}
>
{source.protocol}
</Button>
</Space>
</div>
</div>
))}
</div>
</>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Card>
<Card bordered title="跨来源一致性" style={{ marginTop: 16 }}>
<Descriptions
row
data={[
{ key: '来源覆盖', value: consistencySourceCount > 0 ? `${consistencySourceCount} 个来源` : '-' },
{ key: '在线来源', value: consistencySourceCount > 0 ? `${consistencyOnlineSourceCount}/${consistencySourceCount} 在线` : '-' },
{ key: '位置覆盖', value: consistencyLocatedSourceCount > 0 ? `${consistencyLocatedSourceCount} 个来源有位置` : '暂无位置' },
{ key: '里程差异', value: formatCompactNumber(consistencyMileageDelta, ' km') },
{ key: '来源时间差', value: formatSeconds(consistencyTimeDeltaSeconds) },
{ key: '车辆服务视角', value: activeProtocol ? `当前只看 ${activeProtocol}` : '三类来源合并为同一车辆服务' }
]}
/>
</Card>
<Card bordered title="来源诊断明细" style={{ marginTop: 16 }}>
<Table
loading={loading}
pagination={false}
rowKey="protocol"
dataSource={detail?.sourceStatus ?? []}
columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ title: '在线', width: 100, render: (_: unknown, row: VehicleSourceStatus) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
{ title: '最后时间', dataIndex: 'lastSeen', width: 190 },
{ title: '实时', width: 90, render: (_: unknown, row: VehicleSourceStatus) => <Tag color={row.hasRealtime ? 'green' : 'grey'}>{row.hasRealtime ? '有' : '无'}</Tag> },
{ title: '历史', width: 90, render: (_: unknown, row: VehicleSourceStatus) => <Tag color={row.hasHistory ? 'green' : 'grey'}>{row.hasHistory ? '有' : '无'}</Tag> },
{ title: 'RAW', width: 90, render: (_: unknown, row: VehicleSourceStatus) => <Tag color={row.hasRaw ? 'green' : 'grey'}>{row.hasRaw ? '有' : '无'}</Tag> },
{ title: '里程', width: 90, render: (_: unknown, row: VehicleSourceStatus) => <Tag color={row.hasMileage ? 'green' : 'grey'}>{row.hasMileage ? '有' : '无'}</Tag> }
]}
/>
</Card>
</>
) : null}
{!hasResolvedVIN ? (
<Card bordered style={{ marginTop: 16 }}>
<Tabs>
<Tabs.TabPane tab="质量问题" itemKey="quality">
{qualityTable}
</Tabs.TabPane>
</Tabs>
</Card>
) : (
<Card bordered style={{ marginTop: 16 }}>
<Tabs>
<Tabs.TabPane tab="实时状态" itemKey="realtime">
<div className="vp-realtime-strip">
{[
{ label: '最新来源', value: summary?.primaryProtocol || '-' },
{ label: '速度 km/h', value: summary ? summary.speedKmh : '-' },
{ label: 'SOC %', value: summary ? summary.socPercent : '-' },
{ label: '总里程 km', value: summary ? summary.totalMileageKm : '-' },
{ label: '经纬度', value: summary ? `${summary.longitude}, ${summary.latitude}` : '-' },
{ label: '最后时间', value: lastSeen }
].map((item) => (
<div key={item.label} className="vp-realtime-strip-item">
<div className="vp-realtime-strip-label">{item.label}</div>
<div className="vp-realtime-strip-value">{item.value}</div>
</div>
))}
</div>
<Table
loading={loading}
pagination={false}
rowKey="protocol"
dataSource={detail?.realtime ?? []}
columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ title: '经度', dataIndex: 'longitude', width: 120 },
{ title: '纬度', dataIndex: 'latitude', width: 120 },
{ title: '速度 km/h', dataIndex: 'speedKmh', width: 120 },
{ title: 'SOC %', dataIndex: 'socPercent', width: 120 },
{ title: '总里程 km', dataIndex: 'totalMileageKm', width: 130 },
{ title: '最后时间', dataIndex: 'lastSeen', width: 190 }
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="历史位置" itemKey="history">
<Table
loading={loading}
pagination={false}
rowKey="deviceTime"
dataSource={detail?.history?.items ?? []}
columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ 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 }
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="RAW 字段" itemKey="raw">
<Table
loading={loading}
pagination={false}
rowKey="id"
dataSource={detail?.raw?.items ?? []}
columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ title: '帧类型', dataIndex: 'frameType', width: 190 },
{ title: '大小 B', dataIndex: 'rawSizeBytes', width: 100 },
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
{ title: '入库时间', dataIndex: 'serverTime', width: 190 }
]}
/>
<Typography.Text type="tertiary"> RAW </Typography.Text>
<pre className="vp-json">{JSON.stringify(latestRaw?.parsedFields ?? {}, null, 2)}</pre>
</Tabs.TabPane>
<Tabs.TabPane tab="里程" itemKey="mileage">
<Table
loading={loading}
pagination={false}
rowKey="date"
dataSource={detail?.mileage?.items ?? []}
columns={[
{ title: '日期', dataIndex: 'date', width: 130 },
{ title: '来源', dataIndex: 'source', width: 130 },
{ title: '起始里程', dataIndex: 'startMileageKm', width: 130 },
{ title: '结束里程', dataIndex: 'endMileageKm', width: 130 },
{ title: '日里程', dataIndex: 'dailyMileageKm', width: 130 }
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="质量问题" itemKey="quality">
{qualityTable}
</Tabs.TabPane>
</Tabs>
</Card>
)}
</div>
);
}