feat(platform): harden telemetry pipeline and unify Semi UI workspaces
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { IconClose, IconDownload, IconRefresh, IconSave, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { IconChevronRight, IconClose, IconDownload, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons';
|
||||
import { Button, Card, CardGroup, Collapse, Descriptions, Empty, Input, Select, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
@@ -6,11 +7,18 @@ 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 { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||
import { MetricActionButton } from '../shared/MetricActionButton';
|
||||
import { PageHeader } from '../shared/PageHeader';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { detailTriggerRow } from '../shared/detailTriggerRow';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister } from '../auth/session';
|
||||
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
import { downloadBlob } from '../domain/download';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||
|
||||
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const;
|
||||
const compactAccessTimeFormatter = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
@@ -35,19 +43,27 @@ 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 color = state === 'online' ? 'green' : state === 'offline' || state === 'unknown' ? 'orange' : 'grey';
|
||||
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 <Card
|
||||
className={`v2-access-protocol-detail is-${state}`}
|
||||
title={<strong>{status?.protocol}</strong>}
|
||||
headerExtraContent={<Tag className={`v2-access-protocol-tag is-${state}`} color={color} type="light" size="small"><i />{label}</Tag>}
|
||||
headerLine
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<Descriptions className="v2-access-protocol-descriptions" align="left" size="small" data={[
|
||||
{ key: '接入厂家', value: status?.provider || '—' },
|
||||
{ key: '首次接入', value: formatAccessTime(status?.firstSeenAt || '') },
|
||||
{ key: '最新上报', value: formatAccessTime(status?.latestReceivedAt || '') },
|
||||
{ key: '当前离线', value: formatSeconds(status?.freshnessSec) },
|
||||
{ key: '上报间隔', value: formatSeconds(status?.reportIntervalSec) },
|
||||
{ key: '数据延迟', value: <Typography.Text type={status?.delayAbnormal ? 'danger' : 'primary'}>{formatSeconds(status?.dataDelaySec)}</Typography.Text> }
|
||||
]} />
|
||||
<p className="v2-access-protocol-evidence" title={status?.firstSeenEvidence || '当前未发现该协议来源'}>
|
||||
{status?.firstSeenEvidence || '当前未发现该协议来源'}
|
||||
</p>
|
||||
</Card>;
|
||||
}
|
||||
return <div className={`v2-access-protocol-cell is-${state}`} title={status?.latestReceivedAt ? `最新上报:${formatAccessTime(status.latestReceivedAt)}` : '当前未发现该协议来源'}>
|
||||
<span><i />{label}</span>
|
||||
@@ -57,7 +73,8 @@ function ProtocolState({ status, detailed = false }: { status?: AccessProtocolSt
|
||||
}
|
||||
|
||||
function ConnectionState({ row }: { row: AccessVehicleRow }) {
|
||||
return <div className={`v2-access-connection is-${row.connectionState}`}><strong>{connectionLabels[row.connectionState]}</strong><span>{row.actualProtocols.length} 个真实来源</span></div>;
|
||||
const color = row.connectionState === 'healthy' ? 'green' : row.connectionState === 'not_connected' || row.connectionState === 'offline' ? 'red' : 'orange';
|
||||
return <div className={`v2-access-connection is-${row.connectionState}`}><Tag className="v2-access-connection-tag" color={color} type="light" size="small">{connectionLabels[row.connectionState]}</Tag><span>{row.actualProtocols.length} 个真实来源</span></div>;
|
||||
}
|
||||
|
||||
function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
|
||||
@@ -69,23 +86,81 @@ function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
|
||||
</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.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>
|
||||
function AccessVehicleTable({ rows, selectedVIN, onSelect }: { rows: AccessVehicleRow[]; selectedVIN: string; onSelect: (vin: string) => void }) {
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '车辆', dataIndex: 'plate', width: 165,
|
||||
render: (_: string, row: AccessVehicleRow) => <div className="v2-access-primary-cell"><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div>
|
||||
},
|
||||
{
|
||||
title: '品牌 / 车型', dataIndex: 'oem', width: 140,
|
||||
render: (_: string, row: AccessVehicleRow) => <div className="v2-access-primary-cell"><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></div>
|
||||
},
|
||||
{
|
||||
title: '真实来源', dataIndex: 'actualProtocols', width: 115,
|
||||
render: (_: string[], row: AccessVehicleRow) => <div className="v2-access-source-cell"><b>{row.actualProtocols.length} 个</b><span>{row.actualProtocols.join(' / ') || '尚无来源'}</span></div>
|
||||
},
|
||||
...PROTOCOLS.map((protocol) => ({
|
||||
title: protocol, dataIndex: protocol, width: 160,
|
||||
render: (_: unknown, row: AccessVehicleRow) => <ProtocolState status={statusByProtocol(row, protocol)} />
|
||||
})),
|
||||
{
|
||||
title: '综合状态', dataIndex: 'connectionState', width: 120,
|
||||
render: (_: AccessVehicleRow['connectionState'], row: AccessVehicleRow) => <ConnectionState row={row} />
|
||||
}
|
||||
], []);
|
||||
|
||||
return <Table
|
||||
className="v2-access-semi-table"
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
rowKey="vin"
|
||||
pagination={false}
|
||||
empty={null}
|
||||
onRow={(row) => row ? detailTriggerRow({
|
||||
className: selectedVIN === row.vin ? 'is-selected' : '',
|
||||
expanded: selectedVIN === row.vin,
|
||||
label: `查看 ${row.plate || row.vin} 接入详情`,
|
||||
testId: `access-row-${row.vin}`,
|
||||
onOpen: () => onSelect(row.vin)
|
||||
}) : ({})}
|
||||
/>;
|
||||
}
|
||||
|
||||
function VehicleInspector({ row, onClose, sheet = false }: { row: AccessVehicleRow; onClose: () => void; sheet?: boolean }) {
|
||||
return <Card className={`v2-access-inspector-v3${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader className="v2-access-inspector-header" title={row.plate || '未绑定车牌'} description={row.vin} actions={sheet ? undefined : <Button theme="borderless" icon={<IconClose />} onClick={onClose} aria-label="关闭车辆接入详情" />} />
|
||||
<Descriptions className="v2-access-inspector-summary" align="left" size="small" data={[
|
||||
{ key: '品牌 / 车型', value: [row.oem, row.model].filter(Boolean).join(' / ') || '未维护' },
|
||||
{ key: '真实接入来源', value: row.actualProtocols.length ? row.actualProtocols.join(' / ') : '尚无来源' },
|
||||
{ key: '资料状态', value: row.masterDataIssues.length ? row.masterDataIssues.join(';') : '已维护' },
|
||||
{ key: '综合状态', value: <ConnectionState row={row} /> }
|
||||
]} />
|
||||
<CardGroup className="v2-access-protocol-details" type="grid" spacing={0}>{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} detailed />)}</CardGroup>
|
||||
<footer><span>{row.expectationEvidence}</span><Link to={`/vehicles/${encodeURIComponent(row.vin)}`}>查看车辆详情</Link></footer>
|
||||
</aside>;
|
||||
</Card>;
|
||||
}
|
||||
|
||||
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>;
|
||||
return <Collapse id="access-identity-queue" className="v2-access-identity-queue-v3" keepDOM>
|
||||
<Collapse.Panel itemKey="unresolved-identities" header={<span className="v2-access-collapse-title"><span><strong>来源身份待绑定</strong><small>这些来源不计入主车辆,绑定权威 VIN 后再归档</small></span><Tag color="orange" type="light" size="small">{total.toLocaleString('zh-CN')} 条</Tag></span>}>
|
||||
<div className="v2-access-identity-grid">{items.slice(0, 6).map((item) => <Card key={item.id} className="v2-access-identity-card" bodyStyle={{ padding: 0 }}>
|
||||
<header><b>{item.identifierMasked}</b><Tag color="blue" type="light" size="small">{item.protocol}</Tag></header>
|
||||
<span>{item.plate || '车牌待核对'} · {formatAccessTime(item.latestSeenAt)}</span>
|
||||
<small>{item.recommendedAction}</small>
|
||||
</Card>)}</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>;
|
||||
}
|
||||
|
||||
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>;
|
||||
return <Collapse className="v2-access-settings" keepDOM>
|
||||
<Collapse.Panel itemKey="access-thresholds" header={<span className="v2-access-collapse-title"><span><strong>在线判定阈值</strong><small>统一控制来源在线、长离线和协议新鲜度判定</small></span><Tag color="blue" type="light" size="small">v{config?.version ?? '—'}</Tag></span>}>
|
||||
<fieldset disabled={!editable}><label><span>全局默认</span><Input suffix="秒" type="number" value={String(draft.defaultThresholdSec)} onChange={(value) => onChange({ ...draft, defaultThresholdSec: Number(value) })} /></label><label><span>长离线</span><Input suffix="秒" type="number" value={String(draft.longOfflineSec)} onChange={(value) => onChange({ ...draft, longOfflineSec: Number(value) })} /></label>{PROTOCOLS.map((protocol) => <label key={protocol}><span>{protocol}</span><Input suffix="秒" type="number" value={String(draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec)} onChange={(value) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, Number(value)) })} /></label>)}{error ? <p>{error}</p> : null}{editable ? <Button theme="solid" icon={<IconSave />} onClick={onSave} disabled={saving}>{saving ? '保存中' : '保存阈值'}</Button> : <small>当前账户为只读角色</small>}</fieldset>
|
||||
</Collapse.Panel>
|
||||
</Collapse>;
|
||||
}
|
||||
|
||||
function downloadRows(rows: AccessVehicleRow[]) {
|
||||
@@ -100,7 +175,8 @@ export default function AccessPage() {
|
||||
const [draft, setDraft] = useState(initial); const [criteria, setCriteria] = useState(initial);
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(50); const [selectedVIN, setSelectedVIN] = useState('');
|
||||
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(() => mobileLayout ? 20 : 50); const [selectedVIN, setSelectedVIN] = useState('');
|
||||
const [governanceOpen, setGovernanceOpen] = useState(false);
|
||||
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]);
|
||||
@@ -110,7 +186,14 @@ export default function AccessPage() {
|
||||
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'] })]); } });
|
||||
useEffect(() => {
|
||||
if (!mobileLayout) return;
|
||||
setLimit(20);
|
||||
setOffset(0);
|
||||
}, [mobileLayout]);
|
||||
const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN);
|
||||
useSideSheetA11y(mobileLayout && Boolean(selected), '.v2-access-detail-sidesheet', 'v2-access-detail', '车辆接入详情', '关闭车辆接入详情');
|
||||
useSideSheetA11y(editable && governanceOpen, '.v2-access-governance-sidesheet', 'v2-access-governance', '接入治理配置', '关闭接入治理配置');
|
||||
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); setFiltersCollapsed(true); };
|
||||
@@ -118,18 +201,81 @@ 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>
|
||||
<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="master_data">资料待维护</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>
|
||||
<PageHeader
|
||||
title="车辆接入管理"
|
||||
description="核对车辆真实存在的数据来源、在线健康和待维护资料,不对尚未接入的业务协议作推断。"
|
||||
status={summary ? `${summary.totalVehicles.toLocaleString('zh-CN')} 辆主车辆` : '正在读取车辆'}
|
||||
meta={<Typography.Text type="tertiary">数据时间 {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</Typography.Text>}
|
||||
actions={<>{editable ? <Button theme="light" icon={<IconSetting />} aria-haspopup="dialog" aria-controls="v2-access-governance" aria-expanded={governanceOpen} onClick={() => setGovernanceOpen(true)}>接入治理{unresolvedQuery.data?.total ? ` · ${unresolvedQuery.data.total}` : ''}</Button> : null}<Button theme="light" icon={<IconRefresh />} onClick={() => void refresh()}>刷新</Button></>}
|
||||
/>
|
||||
<MobileFilterToggle summary={Object.values(criteria).filter(Boolean).length ? `已启用 ${Object.values(criteria).filter(Boolean).length} 项` : '全部主车辆'} expanded={!filtersCollapsed} onToggle={() => setFiltersCollapsed((value) => !value)} />
|
||||
<Card className={`v2-access-filter-card-v3${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} bodyStyle={{ padding: 0 }}><form className="v2-access-filter-v3" onSubmit={submit}>
|
||||
<label className="is-search"><span>车辆</span><Input aria-label="车辆" prefix={<IconSearch />} value={draft.keyword} onChange={(value) => setDraft({ ...draft, keyword: value })} placeholder="车牌 / VIN" /></label>
|
||||
<label><span id="access-connection-label">接入状态</span><Select aria-labelledby="access-connection-label" value={draft.connectionState} onChange={(value) => setDraft({ ...draft, connectionState: String(value) })} optionList={[{ value: '', label: '全部状态' }, { value: 'attention', label: '需关注' }, { value: 'healthy', label: '已接来源正常' }, { value: 'master_data', label: '资料待维护' }, { value: 'degraded', label: '部分来源异常' }, { value: 'offline', label: '已接来源离线' }, { value: 'not_connected', label: '尚无来源' }]} /></label>
|
||||
<label><span id="access-protocol-label">真实协议</span><Select aria-labelledby="access-protocol-label" value={draft.protocol} onChange={(value) => setDraft({ ...draft, protocol: String(value) })} optionList={[{ value: '', label: '全部协议' }, ...PROTOCOLS.map((item) => ({ value: item, label: item }))]} /></label>
|
||||
<label><span id="access-oem-label">车辆品牌</span><Select aria-labelledby="access-oem-label" value={draft.oem} onChange={(value) => setDraft({ ...draft, oem: String(value) })} optionList={[{ value: '', label: '全部品牌' }, ...(summary?.oems.map((item) => ({ value: item.name, label: item.name })) ?? [])]} /></label>
|
||||
<Button className="v2-primary-button" theme="solid" htmlType="submit">查询</Button><Button className="v2-secondary-button" theme="light" htmlType="button" onClick={() => apply(EMPTY_FILTERS)}>重置</Button>
|
||||
</form></Card>
|
||||
{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?.masterDataIncompleteVehicles ?? 0, 'incomplete', 'master_data'], ['尚无来源', 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>
|
||||
<Card className="v2-access-kpis-card-v3" bodyStyle={{ padding: 0 }}><section className="v2-access-kpis-v3">{[
|
||||
{ label: '主车辆', value: summary?.totalVehicles ?? 0, tone: 'all', connectionState: '', hint: '车辆主档' },
|
||||
{ label: '需关注', value: Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0)), tone: 'attention', connectionState: 'attention' },
|
||||
{ label: '资料待维护', value: summary?.masterDataIncompleteVehicles ?? 0, tone: 'incomplete', connectionState: 'master_data' },
|
||||
{ label: '尚无来源', value: summary?.neverReported ?? 0, tone: 'never', connectionState: 'not_connected' }
|
||||
].map((item) => {
|
||||
const value = Number(item.value).toLocaleString('zh-CN');
|
||||
return <MetricActionButton key={item.label} label={item.label} value={value} hint={item.hint} tone={item.tone} active={criteria.connectionState === item.connectionState} ariaLabel={`筛选${item.label},共 ${value} 辆`} onClick={() => apply({ ...criteria, connectionState: item.connectionState })} />;
|
||||
})}</section></Card>
|
||||
{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.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}
|
||||
{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 className={`v2-access-workspace-v3 ${selected && !mobileLayout ? 'is-inspector-open' : ''}`}>
|
||||
<Card className="v2-access-table-v3" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="车辆真实接入来源"
|
||||
description="缺席协议只表示“当前未发现”,不会被推断成应接缺失;时间为各来源最后接收时间"
|
||||
actionsClassName="v2-access-table-actions"
|
||||
actions={<><ProtocolCoverage summary={summary} /><Button theme="light" icon={<IconDownload />} onClick={() => downloadRows(rows)} disabled={!rows.length}>导出当前页</Button></>}
|
||||
/>
|
||||
<div className="v2-access-table-scroll-v3">
|
||||
{mobileLayout
|
||||
? <div className="v2-access-mobile-list">{rows.map((row) => <Card key={row.vin} className={`v2-access-mobile-card${selected?.vin === row.vin ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" aria-pressed={selected?.vin === row.vin} aria-expanded={selected?.vin === row.vin} aria-label={`查看 ${row.plate || row.vin} 接入详情`} className="v2-access-mobile-action" onClick={() => setSelectedVIN(row.vin)}><span className="v2-access-mobile-card-content"><header><span><strong>{row.plate || '未绑定车牌'}</strong><small>{row.vin}</small></span><ConnectionState row={row} /></header><p>{row.oem || '品牌未维护'} · {row.model || row.company || '车型未维护'}</p><span className="v2-access-mobile-protocols">{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} />)}</span><footer>查看接入详情<IconChevronRight /></footer></span></Button></Card>)}</div>
|
||||
: <AccessVehicleTable rows={rows} selectedVIN={selectedVIN} onSelect={setSelectedVIN} />}
|
||||
{vehiclesQuery.isFetching ? <div className="v2-access-loading" role="status"><Spin size="middle" tip="正在更新车辆接入状态…" /></div> : null}
|
||||
{!vehiclesQuery.isFetching && !rows.length ? <Empty className="v2-access-empty" title="没有匹配车辆" description="调整车牌、协议或接入状态筛选后重试。" /> : null}
|
||||
</div>
|
||||
<footer><TablePagination page={page} totalPages={totalPages} info={`共 ${(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆`} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={limit} pageSizeLabel="每页车辆数" onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={[{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
|
||||
</Card>
|
||||
{!mobileLayout && selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} /> : null}
|
||||
</div>
|
||||
<SideSheet
|
||||
className="v2-access-detail-sidesheet"
|
||||
visible={mobileLayout && Boolean(selected)}
|
||||
aria-label="车辆接入详情"
|
||||
width="100%"
|
||||
title={<div className="v2-access-sheet-title"><strong>车辆接入详情</strong><span>{selected ? `${selected.plate || '未绑定车牌'} · ${selected.vin}` : '来源、在线状态与接入证据'}</span></div>}
|
||||
onCancel={() => setSelectedVIN('')}
|
||||
>
|
||||
{mobileLayout && selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} sheet /> : null}
|
||||
</SideSheet>
|
||||
{editable ? <SideSheet
|
||||
className="v2-access-governance-sidesheet"
|
||||
visible={governanceOpen}
|
||||
aria-label="接入治理配置"
|
||||
width={560}
|
||||
title={<div className="v2-access-sheet-title"><strong>接入治理</strong><span>集中处理待绑定来源与在线判定阈值</span></div>}
|
||||
onCancel={() => setGovernanceOpen(false)}
|
||||
footer={<Button theme="solid" onClick={() => setGovernanceOpen(false)}>完成</Button>}
|
||||
>
|
||||
{governanceOpen ? <div className="v2-access-governance">
|
||||
<Card className="v2-access-governance-summary" bodyStyle={{ padding: 0 }}>
|
||||
<div><small>待绑定来源</small><strong>{(unresolvedQuery.data?.total ?? 0).toLocaleString('zh-CN')}</strong></div>
|
||||
<div><small>阈值版本</small><strong>v{thresholdQuery.data?.version ?? '—'}</strong></div>
|
||||
<div><small>主车辆口径</small><strong>{(summary?.totalVehicles ?? 0).toLocaleString('zh-CN')}</strong></div>
|
||||
</Card>
|
||||
{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} />
|
||||
{thresholdQuery.isError ? <InlineError message={thresholdQuery.error instanceof Error ? thresholdQuery.error.message : '接入阈值读取失败'} onRetry={() => thresholdQuery.refetch()} /> : null}
|
||||
<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)} />
|
||||
</div> : null}
|
||||
</SideSheet> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user