fix(access): report only real vehicle sources

This commit is contained in:
lingniu
2026-07-16 18:07:45 +08:00
parent 85de053962
commit ff6df370cb
10 changed files with 106 additions and 54 deletions

View File

@@ -26,8 +26,8 @@ function accessRow(vin: string, plate: string): AccessVehicleRow {
firstSeenAt: '2026-07-16T00:00:00Z', latestEventAt: '2026-07-16T04:00:00Z', latestReceivedAt: '2026-07-16T04:00:01Z', reportIntervalSec: 10,
dataDelaySec: 1, freshnessSec: 3, onlineState: 'online', thresholdSec: 300, latestMessageType: 'location', latestEventId: `${vin}-event`, latestError: '', delayAbnormal: false,
firstSeenEvidence: 'test first seen', firstSeenSource: 'test', reportIntervalEvidence: 'test interval', reportSampleCount: 10,
expectedProtocols: ['JT808'], actualProtocols: ['JT808'], missingProtocols: [], connectionState: 'healthy', expectationEvidence: 'test expectation',
protocolStatuses: [{ protocol: 'JT808', expected: true, connected: true, provider: '测试厂家', firstSeenAt: '2026-07-16T00:00:00Z', latestEventAt: '2026-07-16T04:00:00Z', latestReceivedAt: '2026-07-16T04:00:01Z', reportIntervalSec: 10, dataDelaySec: 1, freshnessSec: 3, onlineState: 'online', thresholdSec: 300, delayAbnormal: false, firstSeenEvidence: 'test first seen', reportIntervalEvidence: 'test interval' }]
expectedProtocols: [], actualProtocols: ['JT808'], missingProtocols: [], masterDataIssues: [], connectionState: 'healthy', expectationEvidence: '尚未接业务口径',
protocolStatuses: [{ protocol: 'JT808', expected: false, connected: true, provider: '测试厂家', firstSeenAt: '2026-07-16T00:00:00Z', latestEventAt: '2026-07-16T04:00:00Z', latestReceivedAt: '2026-07-16T04:00:01Z', reportIntervalSec: 10, dataDelaySec: 1, freshnessSec: 3, onlineState: 'online', thresholdSec: 300, delayAbnormal: false, firstSeenEvidence: 'test first seen', reportIntervalEvidence: 'test interval' }]
};
}

View File

@@ -18,7 +18,7 @@ const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '',
type Filters = typeof EMPTY_FILTERS;
const connectionLabels: Record<AccessVehicleRow['connectionState'], string> = {
healthy: '三协议正常', degraded: '协议离线', incomplete: '接入不完整', offline: '全部离线', not_connected: '尚未接入'
healthy: '已接来源正常', degraded: '部分来源异常', incomplete: '资料待维护', offline: '已接来源离线', not_connected: '尚无来源'
};
function statusByProtocol(row: AccessVehicleRow, protocol: string) {
@@ -34,7 +34,7 @@ function compactTime(value: string) {
function ProtocolState({ status, detailed = false }: { status?: AccessProtocolStatus; detailed?: boolean }) {
const state = !status?.connected ? 'missing' : status.onlineState;
const label = state === 'missing' ? '未接入' : state === 'online' ? '在线' : state === 'offline' ? '离线' : state === 'unknown' ? '未知' : '从未上报';
const label = state === 'missing' ? '无来源' : state === 'online' ? '在线' : state === 'offline' ? '离线' : state === 'unknown' ? '未知' : '从未上报';
if (detailed) {
return <article className={`v2-access-protocol-detail is-${state}`}>
<header><strong>{status?.protocol}</strong><span><i />{label}</span></header>
@@ -46,26 +46,25 @@ function ProtocolState({ status, detailed = false }: { status?: AccessProtocolSt
<div><dt></dt><dd>{formatSeconds(status?.reportIntervalSec)}</dd></div>
<div><dt></dt><dd className={status?.delayAbnormal ? 'is-danger' : ''}>{formatSeconds(status?.dataDelaySec)}</dd></div>
</dl>
<p>{status?.firstSeenEvidence || '应接协议尚未形成实时快照'}</p>
<p>{status?.firstSeenEvidence || '当前未发现该协议来源'}</p>
</article>;
}
return <div className={`v2-access-protocol-cell is-${state}`} title={status?.latestReceivedAt ? `最新上报:${formatAccessTime(status.latestReceivedAt)}` : '应接协议尚未形成实时快照'}>
return <div className={`v2-access-protocol-cell is-${state}`} title={status?.latestReceivedAt ? `最新上报:${formatAccessTime(status.latestReceivedAt)}` : '当前未发现该协议来源'}>
<span><i />{label}</span>
<strong>{status?.connected ? compactTime(status.latestReceivedAt) : '等待接入'}</strong>
<small>{status?.provider || (status?.connected ? '接入方未维护' : '无实时快照')}</small>
<strong>{status?.connected ? compactTime(status.latestReceivedAt) : ''}</strong>
<small>{status?.provider || (status?.connected ? '接入方未维护' : '未发现来源')}</small>
</div>;
}
function ConnectionState({ row }: { row: AccessVehicleRow }) {
return <div className={`v2-access-connection is-${row.connectionState}`}><strong>{connectionLabels[row.connectionState]}</strong><span>{row.actualProtocols.length} / {row.expectedProtocols.length} </span></div>;
return <div className={`v2-access-connection is-${row.connectionState}`}><strong>{connectionLabels[row.connectionState]}</strong><span>{row.actualProtocols.length} </span></div>;
}
function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
return <div className="v2-access-protocol-coverage" aria-label="三协议接入概览">
return <div className="v2-access-protocol-coverage" aria-label="真实协议来源概览">
{PROTOCOLS.map((protocol) => {
const actual = summary?.protocols.find((item) => item.name === protocol);
const total = summary?.totalVehicles ?? 0;
return <span key={protocol}><b>{protocol}</b><em>{(actual?.total ?? 0).toLocaleString('zh-CN')} / {total.toLocaleString('zh-CN')}</em></span>;
return <span key={protocol}><b>{protocol}</b><em>{(actual?.total ?? 0).toLocaleString('zh-CN')} </em></span>;
})}
</div>;
}
@@ -73,7 +72,7 @@ function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
function VehicleInspector({ row, onClose }: { row: AccessVehicleRow; onClose: () => void }) {
return <aside className="v2-access-inspector-v3">
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><button type="button" onClick={onClose} aria-label="关闭车辆接入详情"><IconClose /></button></header>
<section className="v2-access-inspector-summary"><div><span> / </span><strong>{[row.oem, row.model].filter(Boolean).join(' / ') || '未维护'}</strong></div><div><span></span><strong>{row.expectedProtocols.join(' / ')}</strong></div><div><span></span><strong>{row.actualProtocols.length ? row.actualProtocols.join(' / ') : '尚未接入'}</strong></div><div><span></span><ConnectionState row={row} /></div></section>
<section className="v2-access-inspector-summary"><div><span> / </span><strong>{[row.oem, row.model].filter(Boolean).join(' / ') || '未维护'}</strong></div><div><span></span><strong>{row.actualProtocols.length ? row.actualProtocols.join(' / ') : '尚无来源'}</strong></div><div><span></span><strong>{row.masterDataIssues.length ? row.masterDataIssues.join('') : '已维护'}</strong></div><div><span></span><ConnectionState row={row} /></div></section>
<div className="v2-access-protocol-details">{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} detailed />)}</div>
<footer><span>{row.expectationEvidence}</span><Link to={`/vehicles/${encodeURIComponent(row.vin)}`}></Link></footer>
</aside>;
@@ -119,15 +118,15 @@ export default function AccessPage() {
const refresh = () => Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch(), ...(editable ? [unresolvedQuery.refetch(), thresholdQuery.refetch()] : [])]);
return <div className="v2-access-page v2-access-page-v3">
<header className="v2-access-heading"><div><h2></h2><p></p></div><div><span> {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</span><button type="button" onClick={() => void refresh()}><IconRefresh /></button></div></header>
<header className="v2-access-heading"><div><h2></h2><p>线</p></div><div><span> {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</span><button type="button" onClick={() => void refresh()}><IconRefresh /></button></div></header>
<button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b></b><small>{Object.values(criteria).filter(Boolean).length ? `已启用 ${Object.values(criteria).filter(Boolean).length}` : '全部主车辆'}</small></span><em>{filtersCollapsed ? '展开' : '收起'}</em></button>
<form className={`v2-access-filter-v3${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}><label className="is-search"><span></span><div><IconSearch /><input aria-label="车辆" value={draft.keyword} onChange={(event) => setDraft({ ...draft, keyword: event.target.value })} placeholder="车牌 / VIN" /></div></label><label><span></span><select aria-label="接入状态" value={draft.connectionState} onChange={(event) => setDraft({ ...draft, connectionState: event.target.value })}><option value=""></option><option value="attention"></option><option value="healthy"></option><option value="incomplete"></option><option value="degraded">线</option><option value="offline">线</option><option value="not_connected"></option></select></label><label><span></span><select aria-label="关注协议" value={draft.protocol} onChange={(event) => setDraft({ ...draft, protocol: event.target.value })}><option value=""></option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span></span><select aria-label="车辆品牌" value={draft.oem} onChange={(event) => setDraft({ ...draft, oem: event.target.value })}><option value=""></option>{summary?.oems.filter((item) => item.name !== '未维护').map((item) => <option key={item.name}>{item.name}</option>)}</select></label><button className="v2-primary-button" type="submit"></button><button className="v2-secondary-button" type="button" onClick={() => apply(EMPTY_FILTERS)}></button></form>
<form className={`v2-access-filter-v3${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}><label className="is-search"><span></span><div><IconSearch /><input aria-label="车辆" value={draft.keyword} onChange={(event) => setDraft({ ...draft, keyword: event.target.value })} placeholder="车牌 / VIN" /></div></label><label><span></span><select aria-label="接入状态" value={draft.connectionState} onChange={(event) => setDraft({ ...draft, connectionState: event.target.value })}><option value=""></option><option value="attention"></option><option value="healthy"></option><option value="incomplete"></option><option value="degraded"></option><option value="offline">线</option><option value="not_connected"></option></select></label><label><span></span><select aria-label="关注协议" value={draft.protocol} onChange={(event) => setDraft({ ...draft, protocol: event.target.value })}><option value=""></option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span></span><select aria-label="车辆品牌" value={draft.oem} onChange={(event) => setDraft({ ...draft, oem: event.target.value })}><option value=""></option>{summary?.oems.map((item) => <option key={item.name}>{item.name}</option>)}</select></label><button className="v2-primary-button" type="submit"></button><button className="v2-secondary-button" type="button" onClick={() => apply(EMPTY_FILTERS)}></button></form>
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
<section className="v2-access-kpis-v3">{[
['主车辆', summary?.totalVehicles ?? 0, 'all', ''], ['有接入差异', Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0)), 'attention', 'attention'], ['全部离线', summary?.offlineVehicles ?? 0, 'offline', 'offline'], ['尚未接入', summary?.neverReported ?? 0, 'never', 'not_connected']
['主车辆', summary?.totalVehicles ?? 0, 'all', ''], ['需关注', Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0)), 'attention', 'attention'], ['资料待维护', summary?.incompleteVehicles ?? 0, 'incomplete', 'incomplete'], ['尚无来源', summary?.neverReported ?? 0, 'never', 'not_connected']
].map(([label, value, tone, connectionState]) => <button key={String(label)} className={`is-${tone}`} type="button" onClick={() => apply({ ...criteria, connectionState: String(connectionState) })}><small>{label}</small><strong>{Number(value).toLocaleString('zh-CN')}</strong>{label === '主车辆' ? <em></em> : null}</button>)}</section>
{vehiclesQuery.isError ? <InlineError message={vehiclesQuery.error instanceof Error ? vehiclesQuery.error.message : '接入车辆读取失败'} onRetry={() => vehiclesQuery.refetch()} /> : null}
<div className={`v2-access-workspace-v3 ${selected ? 'is-inspector-open' : ''}`}><section className="v2-access-table-v3"><header><div className="v2-access-table-title"><strong></strong><span></span></div><div className="v2-access-table-actions"><ProtocolCoverage summary={summary} /><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload /></button></div></header><div className="v2-access-table-scroll-v3">{mobileLayout ? <div className="v2-access-mobile-list">{rows.map((row) => <button type="button" key={row.vin} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)}><header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><ConnectionState row={row} /></header><p>{row.oem || '品牌未维护'} · {row.model || row.company || '车型未维护'}</p><div>{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} />)}</div></button>)}</div> : <table><thead><tr><th></th><th> / </th><th></th>{PROTOCOLS.map((item) => <th key={item}>{item}</th>)}<th></th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} data-testid={`access-row-${row.vin}`} tabIndex={0} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedVIN(row.vin); }}><td><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></td><td><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></td><td><b>{row.expectedProtocols.length} </b><span>{row.expectedProtocols.join(' / ')}</span></td>{PROTOCOLS.map((protocol) => <td key={protocol}><ProtocolState status={statusByProtocol(row, protocol)} /></td>)}<td><ConnectionState row={row} /></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 aria-label="每页数量" 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>{selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} /> : null}</div>
<div className={`v2-access-workspace-v3 ${selected ? 'is-inspector-open' : ''}`}><section className="v2-access-table-v3"><header><div className="v2-access-table-title"><strong></strong><span></span></div><div className="v2-access-table-actions"><ProtocolCoverage summary={summary} /><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload /></button></div></header><div className="v2-access-table-scroll-v3">{mobileLayout ? <div className="v2-access-mobile-list">{rows.map((row) => <button type="button" key={row.vin} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)}><header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><ConnectionState row={row} /></header><p>{row.oem || '品牌未维护'} · {row.model || row.company || '车型未维护'}</p><div>{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} />)}</div></button>)}</div> : <table><thead><tr><th></th><th> / </th><th></th>{PROTOCOLS.map((item) => <th key={item}>{item}</th>)}<th></th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} data-testid={`access-row-${row.vin}`} tabIndex={0} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedVIN(row.vin); }}><td><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></td><td><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></td><td><b>{row.actualProtocols.length} </b><span>{row.actualProtocols.join(' / ') || '尚无来源'}</span></td>{PROTOCOLS.map((protocol) => <td key={protocol}><ProtocolState status={statusByProtocol(row, protocol)} /></td>)}<td><ConnectionState row={row} /></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 aria-label="每页数量" 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>{selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} /> : null}</div>
{editable && unresolvedQuery.isError ? <InlineError message={unresolvedQuery.error instanceof Error ? unresolvedQuery.error.message : '待绑定身份读取失败'} onRetry={() => unresolvedQuery.refetch()} /> : null}
{editable ? <IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} /> : null}
{editable && thresholdQuery.isError ? <InlineError message={thresholdQuery.error instanceof Error ? thresholdQuery.error.message : '接入阈值读取失败'} onRetry={() => thresholdQuery.refetch()} /> : null}