132 lines
18 KiB
TypeScript
132 lines
18 KiB
TypeScript
import { IconClose, IconDownload, IconRefresh, IconSave, IconSearch } from '@douyinfe/semi-icons';
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||
import { Link, useSearchParams } from 'react-router-dom';
|
||
import { api } from '../../api/client';
|
||
import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page } from '../../api/types';
|
||
import { accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access';
|
||
import { InlineError } from '../shared/AsyncState';
|
||
import { usePlatformSession } from '../auth/AuthGate';
|
||
import { canAdminister } from '../auth/session';
|
||
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
|
||
|
||
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const;
|
||
const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' };
|
||
type Filters = typeof EMPTY_FILTERS;
|
||
|
||
const connectionLabels: Record<AccessVehicleRow['connectionState'], string> = {
|
||
healthy: '三协议正常', degraded: '协议离线', incomplete: '接入不完整', offline: '全部离线', not_connected: '尚未接入'
|
||
};
|
||
|
||
function statusByProtocol(row: AccessVehicleRow, protocol: string) {
|
||
return row.protocolStatuses.find((item) => item.protocol === protocol);
|
||
}
|
||
|
||
function compactTime(value: string) {
|
||
if (!value) return '—';
|
||
const parsed = new Date(value);
|
||
if (Number.isNaN(parsed.getTime())) return '—';
|
||
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(parsed).replace(/\//g, '-');
|
||
}
|
||
|
||
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' ? '未知' : '从未上报';
|
||
if (detailed) {
|
||
return <article className={`v2-access-protocol-detail is-${state}`}>
|
||
<header><strong>{status?.protocol}</strong><span><i />{label}</span></header>
|
||
<dl>
|
||
<div><dt>接入厂家</dt><dd>{status?.provider || '—'}</dd></div>
|
||
<div><dt>首次接入</dt><dd>{formatAccessTime(status?.firstSeenAt || '')}</dd></div>
|
||
<div><dt>最新上报</dt><dd>{formatAccessTime(status?.latestReceivedAt || '')}</dd></div>
|
||
<div><dt>当前离线</dt><dd>{formatSeconds(status?.freshnessSec)}</dd></div>
|
||
<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>
|
||
</article>;
|
||
}
|
||
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>
|
||
</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>;
|
||
}
|
||
|
||
function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
|
||
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>;
|
||
})}
|
||
</div>;
|
||
}
|
||
|
||
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>
|
||
<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>;
|
||
}
|
||
|
||
function IdentityQueue({ items, total }: { items: AccessUnresolvedIdentity[]; total: number }) {
|
||
if (!total) return null;
|
||
return <details id="access-identity-queue" className="v2-access-identity-queue-v3"><summary><strong>另有 {total.toLocaleString('zh-CN')} 条来源身份待绑定</strong><span>这些来源不计入主车辆,绑定权威 VIN 后再归档</span></summary><div>{items.slice(0, 6).map((item) => <article key={item.id}><b>{item.identifierMasked}</b><span>{item.protocol} · {item.plate || '车牌待核对'} · {formatAccessTime(item.latestSeenAt)}</span><small>{item.recommendedAction}</small></article>)}</div></details>;
|
||
}
|
||
|
||
function ThresholdSettings({ config, draft, editable, saving, error, onChange, onSave }: { config?: AccessThresholdConfig; draft?: AccessThresholdUpdate; editable: boolean; saving: boolean; error?: string; onChange: (next: AccessThresholdUpdate) => void; onSave: () => void }) {
|
||
if (!draft) return null;
|
||
return <details className="v2-access-settings"><summary>在线判定阈值 · v{config?.version ?? '—'}</summary><fieldset disabled={!editable}><label><span>全局默认</span><input type="number" value={draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, defaultThresholdSec: Number(event.target.value) })} /></label><label><span>长离线</span><input type="number" value={draft.longOfflineSec} onChange={(event) => onChange({ ...draft, longOfflineSec: Number(event.target.value) })} /></label>{PROTOCOLS.map((protocol) => <label key={protocol}><span>{protocol}</span><input type="number" value={draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, Number(event.target.value)) })} /></label>)}{error ? <p>{error}</p> : null}{editable ? <button type="button" onClick={onSave} disabled={saving}><IconSave />{saving ? '保存中' : '保存阈值'}</button> : <small>当前账户为只读角色</small>}</fieldset></details>;
|
||
}
|
||
|
||
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 editable = canAdminister(session);
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const initial = 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 = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]);
|
||
const vehicleScope = useMemo(() => queryScopeKey(baseQuery), [baseQuery]);
|
||
const summaryQuery = useQuery({ queryKey: ['access-summary'], queryFn: ({ signal }) => api.accessSummary({}, signal), staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||
const vehiclesQuery = useQuery<Page<AccessVehicleRow>>({ queryKey: ['access-vehicles', vehicleScope, limit, offset], queryFn: ({ signal }) => api.accessVehicles({ ...baseQuery, limit, offset }, signal), placeholderData: retainPreviousPageWithinScope<Page<AccessVehicleRow>>(vehicleScope), gcTime: QUERY_MEMORY.highVolumeGcTime });
|
||
const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: ({ signal }) => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }, signal), enabled: editable, staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||
const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: ({ signal }) => api.accessThresholds(signal), enabled: editable, staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||
useEffect(() => { if (thresholdQuery.data && !thresholdDraft) setThresholdDraft({ version: thresholdQuery.data.version, defaultThresholdSec: thresholdQuery.data.defaultThresholdSec, delayThresholdSec: thresholdQuery.data.delayThresholdSec, longOfflineSec: thresholdQuery.data.longOfflineSec, protocols: thresholdQuery.data.protocols }); }, [thresholdDraft, thresholdQuery.data]);
|
||
const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (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);
|
||
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 apply = (next: Filters) => { setDraft(next); setCriteria(next); setOffset(0); setSelectedVIN(''); syncURL(next); };
|
||
const submit = (event: FormEvent) => { event.preventDefault(); apply(draft); };
|
||
const page = Math.floor(offset / limit) + 1; const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit)); const summary = summaryQuery.data;
|
||
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>
|
||
<form className="v2-access-filter-v3" 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>
|
||
{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']
|
||
].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"><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>
|
||
{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}
|
||
{editable ? <ThresholdSettings config={thresholdQuery.data} draft={thresholdDraft} editable saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} /> : null}
|
||
</div>;
|
||
}
|