feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

View File

@@ -0,0 +1,205 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import {
IconAlarm, IconArrowRight, IconBox, IconCalendar, IconClock, IconCopy,
IconMapPin, IconSearch, IconTickCircle
} from '@douyinfe/semi-icons';
import { FormEvent, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult } from '../../api/types';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from '../domain/telemetry';
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
import { FleetMap } from '../map/FleetMap';
import { InlineError, PageLoading } from '../shared/AsyncState';
function fmt(value?: string) { return value?.trim() || '—'; }
function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(value) : fallback; }
function timeOnly(value?: string) { if (!value) return '—'; const parts = value.split(' '); return parts[parts.length - 1] || value; }
function issueTone(issue: QualityIssueRow) { return issue.severity === 'error' ? 'error' : 'warning'; }
function durationHours(seconds?: number | null) { return seconds == null ? '—' : `${new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(seconds / 3600)} 小时`; }
function localDateTime(value?: string) { return value ? value.slice(0, 16) : ''; }
const operationStatusLabels = { unknown: '待维护', active: '运营中', inactive: '停运', maintenance: '维保中', retired: '已退役' } as const;
function ProfileSyncPanel({ onClose }: { onClose: () => void }) {
const [sourceSystem, setSourceSystem] = useState('');
const [sourceVersion, setSourceVersion] = useState('');
const [conflictPolicy, setConflictPolicy] = useState<'preserve' | 'overwrite'>('preserve');
const [items, setItems] = useState<VehicleProfileSyncItem[]>([]);
const [fileName, setFileName] = useState('');
const [parseError, setParseError] = useState('');
const sync = useMutation<VehicleProfileSyncResult, Error, boolean>({
mutationFn: (dryRun) => api.syncVehicleProfiles({ sourceSystem: sourceSystem.trim(), sourceVersion: sourceVersion.trim(), conflictPolicy, dryRun, items })
});
const readFile = async (file?: File) => {
sync.reset(); setItems([]); setFileName(file?.name ?? ''); setParseError('');
if (!file) return;
try { setItems(parseVehicleProfileSyncCSV(await file.text())); } catch (error) { setParseError(error instanceof Error ? error.message : 'CSV 解析失败'); }
};
const ready = sourceSystem.trim() !== '' && sourceVersion.trim() !== '' && items.length > 0 && !sync.isPending;
const issues = sync.data?.items.filter((item) => item.status.startsWith('conflict_') || item.status === 'missing_vehicle').slice(0, 20) ?? [];
const applied = sync.data && !sync.data.dryRun;
return <section className="v2-profile-sync-panel" aria-label="车辆主档批量同步">
<header><div><strong></strong><p>CSV 500 </p></div><button type="button" onClick={onClose}></button></header>
<div className="v2-profile-sync-fields">
<label><span></span><input value={sourceSystem} onChange={(event) => { setSourceSystem(event.target.value); sync.reset(); }} placeholder="例如 oem-tsp" maxLength={64} /></label>
<label><span></span><input value={sourceVersion} onChange={(event) => { setSourceVersion(event.target.value); sync.reset(); }} placeholder="例如 snapshot-20260714-01" maxLength={128} /></label>
<label><span></span><select value={conflictPolicy} onChange={(event) => { setConflictPolicy(event.target.value as 'preserve' | 'overwrite'); sync.reset(); }}><option value="preserve"></option><option value="overwrite"></option></select></label>
<label className="is-file"><span>CSV </span><input type="file" accept=".csv,text/csv" onChange={(event) => { void readFile(event.target.files?.[0]); }} /></label>
</div>
<p className="v2-profile-sync-format"><code>{vehicleProfileSyncCSVHeader}</code></p>
{fileName ? <p className="v2-profile-sync-file">{fileName} · {items.length} </p> : null}
{parseError ? <p className="v2-profile-sync-error">{parseError}</p> : null}
{sync.isError ? <p className="v2-profile-sync-error">{sync.error.message}</p> : null}
{sync.data ? <div className="v2-profile-sync-result">
<div><span><strong>{sync.data.received}</strong></span><span><strong>{sync.data.created}</strong></span><span><strong>{sync.data.updated}</strong></span><span><strong>{sync.data.unchanged}</strong></span><span><strong>{sync.data.conflicted}</strong></span><span><strong>{sync.data.missing}</strong></span></div>
{issues.length ? <ul>{issues.map((item) => <li key={item.vin}><b>{item.vin}</b><span>{item.status === 'missing_vehicle' ? '网关身份不存在' : item.status === 'conflict_source_version' ? '同来源版本内容不一致' : `现有来源 ${item.previousSource || '未知'} 已保护`}</span></li>)}</ul> : <p></p>}
</div> : null}
{conflictPolicy === 'overwrite' ? <p className="v2-profile-sync-warning"></p> : null}
<footer><button type="button" onClick={() => sync.mutate(true)} disabled={!ready}>{sync.isPending ? '处理中…' : '预演同步'}</button><button className="is-primary" type="button" onClick={() => sync.mutate(false)} disabled={!ready || !sync.data?.dryRun}>{applied ? '已完成写入' : '确认写入'}</button></footer>
</section>;
}
function VehicleSearch() {
const navigate = useNavigate();
const { session } = usePlatformSession();
const [keyword, setKeyword] = useState('');
const [syncOpen, setSyncOpen] = useState(false);
const submit = (event: FormEvent) => {
event.preventDefault();
const value = keyword.trim();
if (value) navigate(`/vehicles/${encodeURIComponent(value)}`);
};
return <section className={`v2-vehicle-search-page ${syncOpen ? 'has-sync-panel' : ''}`}>
<div className="v2-vehicle-search-card">
<span className="v2-search-hero-icon"><IconBox size="extra-large" /></span>
<h2></h2>
<p>VIN </p>
<form onSubmit={submit}>
<IconSearch /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="输入车牌 / VIN / 终端手机号" autoFocus />
<button type="submit"> <IconArrowRight /></button>
</form>
{canAdminister(session) ? <button className="v2-profile-sync-open" type="button" onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起批量同步' : '批量同步主档'}</button> : null}
</div>
{syncOpen ? <ProfileSyncPanel onClose={() => setSyncOpen(false)} /> : null}
</section>;
}
function Archive({ detail, editable, onUpdated }: { detail: VehicleDetail; editable: boolean; onUpdated: () => void }) {
const profile = detail.profile;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState({ modelName: '', vehicleType: '', companyName: '', operationStatus: 'unknown', accessProvider: '', firstAccessAt: '', runtimeHours: '' });
const save = useMutation({
mutationFn: () => api.updateVehicleProfile(detail.vin, {
modelName: draft.modelName.trim(), vehicleType: draft.vehicleType.trim(), companyName: draft.companyName.trim(),
operationStatus: draft.operationStatus as NonNullable<typeof profile>['operationStatus'], accessProvider: draft.accessProvider.trim(), firstAccessAt: draft.firstAccessAt,
runtimeSeconds: draft.runtimeHours.trim() === '' ? null : Math.round(Number(draft.runtimeHours) * 3600), version: profile?.version ?? 0
}),
onSuccess: () => { setEditing(false); onUpdated(); }
});
const startEditing = () => {
setDraft({ modelName: profile?.modelName ?? '', vehicleType: profile?.vehicleType ?? '', companyName: profile?.companyName ?? '', operationStatus: profile?.operationStatus ?? 'unknown', accessProvider: profile?.accessProvider ?? '', firstAccessAt: localDateTime(profile?.firstAccessAt), runtimeHours: profile?.runtimeSeconds == null ? '' : String(profile.runtimeSeconds / 3600) });
save.reset(); setEditing(true);
};
const submit = (event: FormEvent) => { event.preventDefault(); if (draft.runtimeHours === '' || Number.isFinite(Number(draft.runtimeHours))) save.mutate(); };
return <section className="v2-record-card v2-archive-card">
<header><strong></strong><span className="v2-profile-heading"> {profile?.completeness ?? 0}%{editable && !editing ? <button type="button" onClick={startEditing}></button> : null}</span></header>
{editing ? <form className="v2-profile-form" onSubmit={submit}>
<label><span></span><input maxLength={128} value={draft.modelName} onChange={(event) => setDraft({ ...draft, modelName: event.target.value })} /></label>
<label><span></span><input maxLength={64} value={draft.vehicleType} onChange={(event) => setDraft({ ...draft, vehicleType: event.target.value })} /></label>
<label><span></span><input maxLength={128} value={draft.companyName} onChange={(event) => setDraft({ ...draft, companyName: event.target.value })} /></label>
<label><span></span><select value={draft.operationStatus} onChange={(event) => setDraft({ ...draft, operationStatus: event.target.value })}>{Object.entries(operationStatusLabels).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label>
<label><span></span><input maxLength={128} value={draft.accessProvider} onChange={(event) => setDraft({ ...draft, accessProvider: event.target.value })} /></label>
<label><span></span><input type="datetime-local" value={draft.firstAccessAt} onChange={(event) => setDraft({ ...draft, firstAccessAt: event.target.value })} /></label>
<label><span></span><input type="number" min="0" step="0.1" value={draft.runtimeHours} onChange={(event) => setDraft({ ...draft, runtimeHours: event.target.value })} /></label>
{save.isError ? <p>{save.error.message}</p> : null}<footer><button type="button" onClick={() => setEditing(false)}></button><button className="is-primary" type="submit" disabled={save.isPending}>{save.isPending ? '保存中' : '保存档案'}</button></footer>
</form> : <><dl className="v2-record-list">
<div><dt> / </dt><dd>{[profile?.modelName, profile?.vehicleType].filter(Boolean).join(' / ') || '—'}</dd></div>
<div><dt></dt><dd>{fmt(profile?.companyName)}</dd></div>
<div><dt></dt><dd>{operationStatusLabels[profile?.operationStatus ?? 'unknown']}</dd></div>
<div><dt></dt><dd>{fmt(profile?.accessProvider)}</dd></div>
<div><dt></dt><dd>{fmt(profile?.firstAccessAt)}</dd></div>
<div><dt></dt><dd>{durationHours(profile?.runtimeSeconds)}</dd></div>
</dl><p className="v2-record-note"> {profile?.sourceSystem || '未配置'}{profile?.updatedAt ? ` · v${profile.version} · ${profile.updatedBy} 更新` : ''}</p></>}
</section>;
}
function Events({ detail }: { detail: VehicleDetail }) {
const events = [
...detail.quality.items.slice(0, 3).map((item) => ({ tone: issueTone(item), title: item.severity === 'error' ? '质量异常' : '质量提醒', detail: item.detail, time: item.lastSeen })),
...detail.sourceStatus.slice(0, 3).map((item) => ({ tone: item.online ? 'success' : 'muted', title: item.online ? '数据上报' : '来源离线', detail: `${item.protocol} · ${item.online ? '当前在线' : '暂无在线数据'}`, time: item.lastSeen }))
].slice(0, 5);
return <section className="v2-record-card v2-events-card">
<header><strong></strong><Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}></Link></header>
<div className="v2-event-list">{events.length ? events.map((event, index) => <div className={`v2-event-row is-${event.tone}`} key={`${event.title}-${event.time}-${index}`}>
<span className="v2-event-icon">{event.tone === 'success' ? <IconTickCircle /> : <IconAlarm />}</span>
<div><strong>{event.title}</strong><p>{event.detail}</p></div><time>{fmt(event.time)}</time>
</div>) : <div className="v2-empty-compact"></div>}</div>
</section>;
}
function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryResponse; pending: boolean; error?: string }) {
const [selectedCategory, setSelectedCategory] = useState('vehicle');
const indexed = useMemo(() => {
const valuesByCategory = new Map<string, LatestTelemetryResponse['values']>();
const sources = new Map<string, { protocol: string; endpoint?: string }>();
for (const value of data?.values ?? []) {
const values = valuesByCategory.get(value.category);
if (values) values.push(value); else valuesByCategory.set(value.category, [value]);
const sourceKey = `${value.protocol}\u0000${value.sourceEndpoint ?? ''}`;
if (!sources.has(sourceKey)) sources.set(sourceKey, { protocol: value.protocol, endpoint: value.sourceEndpoint });
}
return { valuesByCategory, sources: [...sources.values()] };
}, [data]);
const categories = data?.categories ?? [];
const activeCategory = indexed.valuesByCategory.has(selectedCategory) ? selectedCategory : categories[0]?.key ?? '';
const visibleMetrics = indexed.valuesByCategory.get(activeCategory) ?? [];
return <section className="v2-record-card v2-telemetry-card">
<nav>{categories.map((item) => <button className={activeCategory === item.key ? 'is-active' : ''} onClick={() => setSelectedCategory(item.key)} type="button" key={item.key}>{item.label}<span>{item.count}</span></button>)}</nav>
<div className="v2-telemetry-list">
{pending ? <div className="v2-empty-compact"></div> : error ? <div className="v2-empty-compact is-error">{error}</div> : visibleMetrics.length ? visibleMetrics.map((item) => <div key={item.key}>
<span>{item.label}<small title={item.sourceField}>{item.sourceField} · {item.protocol}{item.sourceEndpoint ? ` · ${item.sourceEndpoint}` : ''}</small></span>
<strong>{formatTelemetryValue(item.value)} <em>{item.unit}</em></strong>
<time title={`设备时间 ${item.deviceTime || '缺失'};接收时间 ${item.serverTime || '缺失'}${item.qualityReason};帧 ${item.frameId}`}><i className={`is-${item.quality}`}>{telemetryQualityLabel(item.quality)}</i>{formatTelemetryTime(item.deviceTime || item.serverTime)}</time>
</div>) : <div className="v2-empty-compact"> {data?.scannedFrames ?? 0} </div>}
</div>
<footer><b></b>{indexed.sources.map((source) => <span key={`${source.protocol}-${source.endpoint ?? ''}`} title={source.endpoint}>{source.protocol}</span>)}<small> {data?.scannedFrames ?? 0} · {formatTelemetryTime(data?.asOf)}</small></footer>
</section>;
}
function VehicleRecord({ detail, telemetry, telemetryPending, telemetryError, onUpdated }: { detail: VehicleDetail; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; onUpdated: () => void }) {
const { session } = usePlatformSession();
const realtime = detail.realtimeSummary;
const identity = detail.identity;
const mapVehicles = realtime ? [realtime] : [];
const lastMileage = detail.mileage.items[0];
return <div className="v2-vehicle-record-page">
<section className="v2-identity-band">
<div className="v2-identity-primary"><span className="v2-plate"><IconBox />{fmt(identity?.plate || realtime?.plate)}</span><span className={`v2-online-label ${realtime?.online ? 'is-online' : ''}`}><i />{realtime?.online ? '在线' : '离线'}</span><small>VIN</small><b>{detail.vin}</b><button type="button" title="复制 VIN" onClick={() => navigator.clipboard?.writeText(detail.vin)}><IconCopy /></button></div>
<div className="v2-identity-meta"><div><small></small><p>{detail.sources.map((source) => <span key={source}>{source}</span>)}</p></div><div><small></small><strong>{fmt(realtime?.lastSeen || identity?.lastSeen)}</strong></div></div>
<div className="v2-identity-actions"><Link to={`/tracks?vin=${encodeURIComponent(detail.vin)}`}><IconMapPin /></Link><Link to={`/history?vin=${encodeURIComponent(detail.vin)}`}><IconCalendar /></Link><Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><IconAlarm /></Link></div>
</section>
<div className="v2-record-grid">
<section className="v2-single-map-card"><FleetMap vehicles={mapVehicles} selectedVin={detail.vin} onSelect={() => undefined} /><footer><span><IconMapPin />{realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</span><time>{fmt(realtime?.lastSeen)}</time></footer></section>
<Archive detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} />
<section className="v2-record-card v2-live-card"><header><strong></strong><span><IconClock />{timeOnly(realtime?.lastSeen)}</span></header><div className="v2-live-grid">
<div><small></small><strong>{metric(realtime?.speedKmh)}<em>km/h</em></strong></div><div><small>SOC</small><strong>{metric(realtime?.socPercent)}<em>%</em></strong></div><div><small></small><strong>{metric(realtime?.totalMileageKm)}<em>km</em></strong></div><div><small></small><strong>{metric(lastMileage?.dailyMileageKm)}<em>km</em></strong></div><div><small>线</small><strong>{realtime?.onlineSourceCount ?? 0}<em></em></strong></div><div><small></small><strong>{detail.sourceStatus.length}<em></em></strong></div>
</div></section>
<TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} />
<Events detail={detail} />
</div>
</div>;
}
export default function VehiclePage() {
const { vin } = useParams();
const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: Boolean(vin), queryFn: () => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' })) });
const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: () => api.latestTelemetry(vin!), staleTime: 10_000, refetchInterval: 20_000, refetchIntervalInBackground: false });
if (!vin) return <VehicleSearch />;
if (query.isPending) return <PageLoading />;
if (query.isError) return <div className="v2-page-error"><InlineError message={query.error instanceof Error ? query.error.message : '车辆档案加载失败'} onRetry={() => query.refetch()} /></div>;
if (!query.data.lookupResolved) return <section className="v2-not-found"><IconSearch size="extra-large" /><h2></h2><p>{vin}VIN </p><Link to="/vehicles"></Link></section>;
return <VehicleRecord detail={query.data} telemetry={telemetry.data} telemetryPending={telemetry.isPending} telemetryError={telemetry.isError ? (telemetry.error instanceof Error ? telemetry.error.message : '请求失败') : undefined} onUpdated={() => { void query.refetch(); void telemetry.refetch(); }} />;
}