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,117 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { IconDownload, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow } from '../../api/types';
import { accessRowsToCSV, accessStateLabels, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access';
import { InlineError } from '../shared/AsyncState';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', onlineState: '', delayState: '' };
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'];
const protocolColors = ['#1685c5', '#6f2da8', '#15a46d', '#7c8fd6', '#9aa4b2'];
type Filters = typeof EMPTY_FILTERS;
function StatusLabel({ state }: { state: AccessVehicleRow['onlineState'] }) {
return <span className={`v2-access-status is-${state}`}><i />{accessStateLabels[state]}</span>;
}
function ProtocolDistribution({ summary }: { summary?: AccessSummary }) {
const rows = summary?.protocols ?? [];
const total = Math.max(1, rows.reduce((sum, item) => sum + item.total, 0));
return <section className="v2-access-protocols"><header><strong></strong><span> · 线</span></header>
<div className="v2-access-segments">{rows.map((item, index) => <i key={item.name} style={{ width: `${item.total / total * 100}%`, background: protocolColors[index % protocolColors.length] }} title={`${item.name} ${item.total}`} />)}</div>
<div className="v2-access-legends">{rows.map((item, index) => <span key={item.name}><i style={{ background: protocolColors[index % protocolColors.length] }} /><b>{item.name}</b>{item.total.toLocaleString('zh-CN')} <em>{item.onlineRate.toFixed(1)}% 线</em></span>)}{!rows.length ? <span></span> : null}</div>
</section>;
}
function IdentityQueue({ items, total, loading }: { items: AccessUnresolvedIdentity[]; total: number; loading: boolean }) {
const [copied, setCopied] = useState('');
if (!loading && total === 0) return null;
const copyEvidence = async (item: AccessUnresolvedIdentity) => {
const text = [`身份待绑定:${item.identifierMasked}`, `协议:${item.protocol}`, `车牌:${item.plate || '待核对'}`, `厂家:${item.manufacturer || '待核对'}`, `来源:${item.sourceEndpoint || '未知'}`, `最近上报:${formatAccessTime(item.latestSeenAt)}`, `问题:${item.issueCode}`, `建议动作:${item.recommendedAction}`].join('\n');
await navigator.clipboard?.writeText(text);
setCopied(item.id);
};
return <details id="access-identity-queue" className="v2-access-identity-queue" open={total > 0}><summary><span><b></b><strong>{loading ? '…' : total.toLocaleString('zh-CN')}</strong></span><em> · VIN</em></summary>
<div>{items.slice(0, 5).map((item) => <article key={item.id}><span className="v2-access-identity-code">{item.identifierMasked}</span><dl><div><dt></dt><dd>{[item.plate, item.manufacturer, item.sourceEndpoint].filter(Boolean).join(' · ') || '仅有终端上报'}</dd></div><div><dt></dt><dd>{formatAccessTime(item.latestSeenAt)} · {formatSeconds(item.freshnessSec)}</dd></div></dl><button type="button" onClick={() => void copyEvidence(item)}>{copied === item.id ? '已复制' : '复制处置证据'}</button></article>)}</div>
</details>;
}
function AccessInspector({ row }: { row?: AccessVehicleRow }) {
if (!row) return <section className="v2-access-inspector"><header><strong></strong></header><div className="v2-access-side-empty"></div></section>;
return <section className="v2-access-inspector"><header><strong></strong><StatusLabel state={row.onlineState} /></header>
<dl className="v2-access-identity"><div><dt></dt><dd>{row.plate || '—'}</dd></div><div><dt>VIN</dt><dd>{row.vin}</dd></div><div><dt> / </dt><dd>{[row.model, row.company].filter(Boolean).join(' / ') || '—'}</dd></div><div><dt></dt><dd>{row.protocol || '—'}</dd></div><div><dt> / </dt><dd>{[row.oem, row.provider].filter(Boolean).join(' / ') || '—'}</dd></div></dl>
<Link className="v2-access-vehicle-link" to={`/vehicles/${encodeURIComponent(row.vin)}`}></Link>
<section><h3></h3><dl><div><dt></dt><dd>{formatAccessTime(row.latestEventAt)}</dd></div><div><dt></dt><dd>{formatAccessTime(row.latestReceivedAt)}</dd></div><div><dt></dt><dd className={row.delayAbnormal ? 'is-danger' : 'is-good'}>{formatSeconds(row.dataDelaySec)}</dd></div><div><dt></dt><dd>{formatSeconds(row.reportIntervalSec)}</dd></div><div><dt></dt><dd>{formatSeconds(row.freshnessSec)}</dd></div><div><dt></dt><dd>{formatSeconds(row.thresholdSec)}</dd></div></dl></section>
<section><h3></h3><dl><div><dt></dt><dd>{row.latestMessageType || '—'}</dd></div><div><dt> ID</dt><dd>{row.latestEventId || '—'}</dd></div><div><dt></dt><dd className={row.latestError ? 'is-danger' : ''}>{row.latestError || '无已知错误'}</dd></div><div><dt></dt><dd>{row.source || '—'}</dd></div></dl></section>
<section className="v2-access-proof"><h3></h3><p><b></b>{row.firstSeenAt ? `${formatAccessTime(row.firstSeenAt)} · ${row.firstSeenEvidence}` : row.firstSeenEvidence}</p><p><b></b>{row.reportIntervalEvidence || (row.reportIntervalSec !== null ? `${row.reportSampleCount} 个持久样本计算` : '等待连续样本')}</p></section>
</section>;
}
function ThresholdPanel({ config, draft, saving, error, editable, onChange, onSave }: { config?: AccessThresholdConfig; draft?: AccessThresholdUpdate; saving: boolean; error?: string; editable: boolean; onChange: (next: AccessThresholdUpdate) => void; onSave: () => void }) {
return <section className="v2-access-threshold"><header><strong>线</strong><span>v{config?.version ?? '—'}</span></header>
{draft ? <fieldset className="v2-threshold-form" disabled={!editable}><label><span></span><select value={draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, defaultThresholdSec: Number(event.target.value) })}><option value="60">1 </option><option value="300">5 </option><option value="600">10 </option><option value="1800">30 </option></select></label><label><span></span><input type="number" min="1" max="3600" value={draft.delayThresholdSec} onChange={(event) => onChange({ ...draft, delayThresholdSec: Number(event.target.value) })} /><em></em></label><label><span>线</span><select value={draft.longOfflineSec} onChange={(event) => onChange({ ...draft, longOfflineSec: Number(event.target.value) })}><option value="1800">30 </option><option value="3600">1 </option><option value="21600">6 </option><option value="86400">24 </option></select></label>
{PROTOCOLS.map((protocol) => <label key={protocol}><span>{protocol}</span><input type="number" min="30" max="86400" value={draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, Number(event.target.value)) })} /><em></em></label>)}
{error ? <p className="v2-threshold-error">{error}</p> : null}{editable ? <button type="button" disabled={saving} onClick={onSave}><IconSave />{saving ? '保存中' : '保存并重算'}</button> : <p className="v2-role-notice"></p>}</fieldset> : <div className="v2-access-side-empty"></div>}
{config?.audit[0] ? <footer><span></span><b>{config.audit[0].actor} · {formatAccessTime(config.audit[0].changedAt)}</b></footer> : <footer><span></span><b>MySQL </b></footer>}
</section>;
}
function downloadRows(rows: AccessVehicleRow[]) {
const blob = new Blob([accessRowsToCSV(rows)], { type: 'text/csv;charset=utf-8' });
const href = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = href; anchor.download = `vehicle-access-${new Date().toISOString().slice(0, 10)}.csv`; anchor.click();
URL.revokeObjectURL(href);
}
export default function AccessPage() {
const { session } = usePlatformSession(); const thresholdEditable = canAdminister(session);
const [searchParams, setSearchParams] = useSearchParams();
const initial: Filters = Object.fromEntries(Object.keys(EMPTY_FILTERS).map((key) => [key, searchParams.get(key) ?? ''])) as Filters;
const [draft, setDraft] = useState(initial);
const [criteria, setCriteria] = useState(initial);
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(50);
const [selectedVIN, setSelectedVIN] = useState('');
const [thresholdDraft, setThresholdDraft] = useState<AccessThresholdUpdate>();
const queryClient = useQueryClient();
const baseQuery: AccessQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]);
const summaryQuery = useQuery({ queryKey: ['access-summary', baseQuery], queryFn: () => api.accessSummary(baseQuery), staleTime: 10_000 });
const vehiclesQuery = useQuery({ queryKey: ['access-vehicles', baseQuery, limit, offset], queryFn: () => api.accessVehicles({ ...baseQuery, limit, offset }), placeholderData: (previous) => previous });
const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: () => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }), staleTime: 10_000 });
const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: api.accessThresholds, staleTime: 60_000 });
const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (config) => { queryClient.setQueryData(['access-thresholds'], config); setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); await Promise.all([queryClient.invalidateQueries({ queryKey: ['access-summary'] }), queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })]); } });
const rows = vehiclesQuery.data?.items ?? [];
const selected = rows.find((row) => row.vin === selectedVIN) ?? rows[0];
useEffect(() => { if (rows.length && !rows.some((row) => row.vin === selectedVIN)) setSelectedVIN(rows[0].vin); }, [rows, selectedVIN]);
useEffect(() => { const config = thresholdQuery.data; if (config && !thresholdDraft) setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); }, [thresholdDraft, thresholdQuery.data]);
const syncURL = (filters: Filters) => { const next = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { if (value) next.set(key, value); }); setSearchParams(next, { replace: true }); };
const submit = (event: FormEvent) => { event.preventDefault(); setCriteria(draft); setOffset(0); syncURL(draft); };
const reset = () => { setDraft(EMPTY_FILTERS); setCriteria(EMPTY_FILTERS); setOffset(0); setSearchParams({}, { replace: true }); };
const applyState = (onlineState: string, delayState = '') => { const next = { ...criteria, onlineState, delayState }; setDraft(next); setCriteria(next); setOffset(0); syncURL(next); };
const showIdentityQueue = () => document.getElementById('access-identity-queue')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
const page = Math.floor(offset / limit) + 1;
const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit));
const summary = summaryQuery.data;
return <div className="v2-access-page">
<form className="v2-access-filter" onSubmit={submit}><label><span></span><div><IconSearch /><input value={draft.keyword} onChange={(event) => setDraft((current) => ({ ...current, keyword: event.target.value }))} placeholder="车牌 / VIN" /></div></label><label><span></span><select value={draft.protocol} onChange={(event) => setDraft((current) => ({ ...current, protocol: event.target.value }))}><option value=""></option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span></span><select value={draft.oem} onChange={(event) => setDraft((current) => ({ ...current, oem: event.target.value }))}><option value=""></option>{summary?.oems.filter((item) => item.name !== '未维护').map((item) => <option key={item.name}>{item.name}</option>)}</select></label><label><span>线</span><select value={draft.onlineState} onChange={(event) => setDraft((current) => ({ ...current, onlineState: event.target.value }))}><option value=""></option><option value="online">线</option><option value="offline">线</option><option value="never_reported"></option><option value="unknown"></option></select></label><label><span></span><select value={draft.delayState} onChange={(event) => setDraft((current) => ({ ...current, delayState: event.target.value }))}><option value=""></option><option value="normal"></option><option value="abnormal"></option></select></label><button className="v2-primary-button" type="submit"></button><button className="v2-secondary-button" type="button" onClick={reset}></button><details className="v2-access-advanced"><summary> · / / </summary><div><label><span></span><input value={draft.model} onChange={(event) => setDraft((current) => ({ ...current, model: event.target.value }))} placeholder="输入车型关键词" /></label><label><span></span><input value={draft.provider} onChange={(event) => setDraft((current) => ({ ...current, provider: event.target.value }))} placeholder="输入平台名称" /></label><label><span></span><input type="datetime-local" value={draft.firstSeenFrom} onChange={(event) => setDraft((current) => ({ ...current, firstSeenFrom: event.target.value }))} /></label><label><span></span><input type="datetime-local" value={draft.firstSeenTo} onChange={(event) => setDraft((current) => ({ ...current, firstSeenTo: event.target.value }))} /></label><label><span></span><input type="datetime-local" value={draft.latestSeenFrom} onChange={(event) => setDraft((current) => ({ ...current, latestSeenFrom: event.target.value }))} /></label><label><span></span><input type="datetime-local" value={draft.latestSeenTo} onChange={(event) => setDraft((current) => ({ ...current, latestSeenTo: event.target.value }))} /></label></div></details></form>
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
<section className="v2-access-kpis">{[
['接入车辆', summary?.totalVehicles ?? 0, '', () => applyState('')], ['在线', summary?.onlineVehicles ?? 0, 'online', () => applyState('online')], ['长离线', summary?.longOfflineVehicles ?? 0, 'offline', () => applyState('offline')], ['从未上报', summary?.neverReported ?? 0, 'never', () => applyState('never_reported')], ['延迟异常', summary?.delayAbnormal ?? 0, 'delay', () => applyState('', 'abnormal')], ['身份待绑定', unresolvedQuery.data?.total ?? 0, 'identity', showIdentityQueue], ['今日上报', summary?.reportedToday ?? 0, 'today', () => applyState('')]
].map(([label, value, tone, action]) => <button key={String(label)} type="button" className={`is-${tone}`} onClick={action as () => void}><small>{label as string}</small><strong>{Number(value).toLocaleString('zh-CN')}</strong>{label === '在线' ? <em>{(summary?.onlineRate ?? 0).toFixed(1)}%</em> : null}</button>)}</section>
<ProtocolDistribution summary={summary} />
{unresolvedQuery.isError ? <InlineError message={unresolvedQuery.error instanceof Error ? unresolvedQuery.error.message : '身份待绑定队列读取失败'} onRetry={() => unresolvedQuery.refetch()} /> : null}
<IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} loading={unresolvedQuery.isLoading} />
{vehiclesQuery.isError ? <InlineError message={vehiclesQuery.error instanceof Error ? vehiclesQuery.error.message : '接入车辆读取失败'} onRetry={() => vehiclesQuery.refetch()} /> : null}
<div className="v2-access-workspace"><section className="v2-access-table-card"><header><strong></strong><div><span> v{summary?.thresholdVersion ?? '—'}</span><button type="button" onClick={() => vehiclesQuery.refetch()}><IconRefresh /></button><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload /></button><button type="button"><IconSetting /></button></div></header><div className="v2-access-table-scroll"><table><thead><tr><th /><th>线</th><th></th><th>VIN</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} className={selected?.vin === row.vin ? 'is-selected' : ''}><td><input type="radio" name="access-row" checked={selected?.vin === row.vin} onChange={() => setSelectedVIN(row.vin)} aria-label={`选择 ${row.plate || row.vin}`} /></td><td><StatusLabel state={row.onlineState} /></td><td>{row.plate || '—'}</td><td title={row.vin}>{row.vin}</td><td>{row.oem || '—'}</td><td>{row.protocol || '—'}</td><td title={row.firstSeenEvidence}>{formatAccessTime(row.firstSeenAt)}</td><td>{formatAccessTime(row.latestEventAt)}</td><td>{formatAccessTime(row.latestReceivedAt)}</td><td title={row.reportIntervalEvidence}>{formatSeconds(row.reportIntervalSec)}</td><td className={row.delayAbnormal ? 'is-danger' : 'is-good'}>{formatSeconds(row.dataDelaySec)}</td><td>{formatSeconds(row.thresholdSec)}</td><td>{row.latestMessageType || '—'}</td><td className={row.latestError ? 'is-danger' : ''} title={row.latestError}>{row.latestError || '—'}</td><td><button type="button" onClick={() => setSelectedVIN(row.vin)}></button></td></tr>)}</tbody></table>{vehiclesQuery.isFetching ? <div className="v2-access-loading"><i /></div> : null}{!vehiclesQuery.isFetching && !rows.length ? <div className="v2-access-empty"></div> : null}</div><footer><span> {page} / {totalPages} {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} </span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer></section>
<aside className="v2-access-side"><AccessInspector row={selected} /><ThresholdPanel config={thresholdQuery.data} draft={thresholdDraft} saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} editable={thresholdEditable} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} /></aside></div>
</div>;
}