feat: build vehicle data platform and production pipeline
This commit is contained in:
117
vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx
Normal file
117
vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user