Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx
2026-07-19 16:38:17 +08:00

592 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { IconChevronDown, IconChevronRight, IconClose, IconDownload, IconMore, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Collapse, Descriptions, Dropdown, Empty, Input, Select, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, KeyboardEvent, memo, useCallback, 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 { accessIssueSummary, accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { TablePagination } from '../shared/TablePagination';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { ProtocolTag } from '../shared/ProtocolTag';
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';
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const;
const protocolThresholdMeta: Record<(typeof PROTOCOLS)[number], { label: string; description: string }> = {
GB32960: { label: 'GB32960', description: '国标远程服务' },
JT808: { label: 'JT808', description: '道路运输终端' },
YUTONG_MQTT: { label: 'YUTONG MQTT', description: '宇通车辆平台' }
};
const compactAccessTimeFormatter = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Shanghai' });
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: '尚无来源'
};
const scopeLabels: Record<string, string> = {
'': '全部主车辆',
attention: '需关注车辆',
master_data: '资料待维护',
healthy: '已接来源正常',
degraded: '部分来源异常',
offline: '已接来源离线',
not_connected: '尚无来源'
};
function statusByProtocol(row: AccessVehicleRow, protocol: string) {
return row.protocolStatuses.find((item) => item.protocol === protocol);
}
function compactProtocolLabel(protocol: string) {
if (protocol === 'GB32960') return '32960';
if (protocol === 'JT808') return '808';
if (protocol === 'YUTONG_MQTT') return '宇通';
return protocol;
}
function compactTime(value: string) {
if (!value) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '—';
return compactAccessTimeFormatter.format(parsed).replace(/\//g, '-');
}
function AccessTime({ value, compact = false }: { value?: string; compact?: boolean }) {
if (!value) return <span className="v2-access-time"></span>;
return <time className="v2-access-time" dateTime={value} title={`${formatAccessTime(value)} · 上海时间`}>{compact ? compactTime(value) : formatAccessTime(value)}</time>;
}
function protocolNeedsAttention(status?: AccessProtocolStatus) {
return Boolean(status?.connected && (status.onlineState !== 'online' || status.delayAbnormal));
}
function protocolPriority(status?: AccessProtocolStatus) {
if (status?.connected && status.onlineState !== 'online') return 0;
if (status?.connected && status.delayAbnormal) return 1;
if (status?.connected) return 2;
return 3;
}
const ProtocolState = memo(function ProtocolState({
status,
detailed = false,
protocolLabel,
expanded = false,
onToggle
}: {
status?: AccessProtocolStatus;
detailed?: boolean;
protocolLabel?: string;
expanded?: boolean;
onToggle?: (protocol: string) => void;
}) {
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) {
const protocol = status?.protocol || '未知协议';
const evidenceId = `access-protocol-${protocol.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-evidence`;
const evidence = status?.firstSeenEvidence || '当前未发现该协议来源';
return <Card
className={`v2-access-protocol-detail is-${state}${expanded ? ' is-expanded' : ''}`}
title={<strong>{protocol}</strong>}
headerExtraContent={<Tag className={`v2-access-protocol-tag is-${state}`} color={color} type="light" size="small"><i />{label}</Tag>}
headerLine
bodyStyle={{ padding: 0 }}
>
{status?.connected ? <>
<div className="v2-access-protocol-glance" role="group" aria-label={`${protocol} 关键状态`}>
<span><small></small><strong title={status.provider || '接入方未维护'}>{status.provider || '接入方未维护'}</strong></span>
<span><small></small><strong><AccessTime value={status.latestReceivedAt} compact /></strong></span>
<span><small>线</small><strong>{formatSeconds(status.freshnessSec)}</strong></span>
<span className={status.delayAbnormal ? 'is-danger' : ''}><small></small><strong>{formatSeconds(status.dataDelaySec)}</strong></span>
</div>
<Button
className={`v2-access-protocol-toggle${expanded ? ' is-expanded' : ''}`}
theme="borderless"
type="tertiary"
icon={<IconChevronDown />}
iconPosition="right"
aria-expanded={expanded}
aria-controls={evidenceId}
aria-label={`${expanded ? '收起' : '查看'} ${protocol} 完整证据`}
onClick={() => onToggle?.(protocol)}
>
{expanded ? '收起完整证据' : protocolNeedsAttention(status) ? '查看异常证据' : '查看完整证据'}
</Button>
{expanded ? <div id={evidenceId} className="v2-access-protocol-expanded" role="region" aria-label={`${protocol} 完整接入证据`}>
<Descriptions className="v2-access-protocol-descriptions" align="left" size="small" data={[
{ key: '首次接入', value: <AccessTime value={status.firstSeenAt} /> },
{ key: '上报间隔', value: formatSeconds(status.reportIntervalSec) }
]} />
<p className="v2-access-protocol-evidence" title={evidence}>{evidence}</p>
</div> : null}
</> : <div className="v2-access-protocol-missing">
<span></span>
<small></small>
</div>}
</Card>;
}
return <div className={`v2-access-protocol-cell is-${state}`} title={status?.latestReceivedAt ? `最新上报:${formatAccessTime(status.latestReceivedAt)}` : '当前未发现该协议来源'}>
{protocolLabel ? <b className="v2-access-protocol-label">{protocolLabel}</b> : null}
<span><i />{label}</span>
<strong>{status?.connected ? <AccessTime value={status.latestReceivedAt} compact /> : '—'}</strong>
<small>{status?.provider || (status?.connected ? '接入方未维护' : '未发现来源')}</small>
</div>;
});
function ConnectionTag({ row }: { row: AccessVehicleRow }) {
return <Tag className="v2-access-connection-tag" color={connectionTagColor(row.connectionState)} type="light" size="small">{connectionLabels[row.connectionState]}</Tag>;
}
function connectionTagColor(state: AccessVehicleRow['connectionState']) {
return state === 'healthy' ? 'green' as const : state === 'not_connected' || state === 'offline' ? 'red' as const : 'orange' as const;
}
const ConnectionState = memo(function ConnectionState({ row }: { row: AccessVehicleRow }) {
const summary = accessIssueSummary(row);
return <div className={`v2-access-connection is-${row.connectionState}`}>
<ConnectionTag row={row} />
<span title={summary}>{summary}</span>
<small>{row.actualProtocols.length ? `${row.actualProtocols.length} 个真实来源 · ${row.actualProtocols.join(' / ')}` : '当前没有真实来源证据'}</small>
</div>;
});
function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
return <div className="v2-access-protocol-coverage" aria-label="真实协议来源概览">
<small></small>
{PROTOCOLS.map((protocol) => {
const actual = summary?.protocols.find((item) => item.name === protocol);
return <span key={protocol}><b>{compactProtocolLabel(protocol)}</b><em>{(actual?.total ?? 0).toLocaleString('zh-CN')} </em></span>;
})}
</div>;
}
function accessRecommendation(row: AccessVehicleRow) {
if (row.connectionState === 'healthy') return '无需处理,持续监测来源上报';
if (row.connectionState === 'incomplete') return '补齐品牌、车型或来源标识';
if (row.connectionState === 'not_connected') return '核对车端设备与接入映射';
if (row.connectionState === 'offline') return '优先核对设备供电与接入链路';
return '优先核对离线或延迟异常来源';
}
const AccessVehicleTable = memo(function AccessVehicleTable({ rows, selectedVIN, onSelect }: { rows: AccessVehicleRow[]; selectedVIN: string; onSelect: (vin: string) => void }) {
const columns = useMemo(() => [
{
title: '车辆', dataIndex: 'plate', width: 165, fixed: 'left' as const, className: 'v2-access-vehicle-column',
render: (_: string, row: AccessVehicleRow) => <div className="v2-access-primary-cell"><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div>
},
{
title: '品牌 / 车型', dataIndex: 'oem', width: 140, fixed: 'left' as const, className: 'v2-access-model-column',
render: (_: string, row: AccessVehicleRow) => <div className="v2-access-primary-cell"><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></div>
},
...PROTOCOLS.map((protocol) => ({
title: protocol, dataIndex: protocol, width: 160,
render: (_: unknown, row: AccessVehicleRow) => <ProtocolState status={statusByProtocol(row, protocol)} />
})),
{
title: '差异摘要', dataIndex: 'connectionState', width: 220,
render: (_: AccessVehicleRow['connectionState'], row: AccessVehicleRow) => <ConnectionState row={row} />
}
], []);
return <Table
className="v2-access-semi-table"
style={{ width: '100%' }}
scroll={{ x: 1005 }}
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, mobile = false }: { row: AccessVehicleRow; onClose: () => void; sheet?: boolean; mobile?: boolean }) {
const protocols = useMemo(() => [...PROTOCOLS].sort((left, right) => protocolPriority(statusByProtocol(row, left)) - protocolPriority(statusByProtocol(row, right))), [row]);
const health = useMemo(() => {
const connected = row.protocolStatuses.filter((item) => item.connected);
return {
connected: connected.length,
online: connected.filter((item) => item.onlineState === 'online').length,
attention: connected.filter((item) => protocolNeedsAttention(item)).length
};
}, [row.protocolStatuses]);
const [expandedProtocol, setExpandedProtocol] = useState(() => protocols.find((protocol) => protocolNeedsAttention(statusByProtocol(row, protocol))) || '');
const toggleProtocol = useCallback((protocol: string) => {
setExpandedProtocol((current) => current === protocol ? '' : protocol);
}, []);
return <Card className={`v2-access-inspector-v3${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}>
{sheet ? null : <WorkspacePanelHeader className="v2-access-inspector-header" title={row.plate || '未绑定车牌'} description={row.vin} actions={<Button theme="borderless" icon={<IconClose />} onClick={onClose} aria-label="关闭车辆接入详情" />} />}
<Card className={`v2-access-inspector-focus is-${row.connectionState}`} bodyStyle={{ padding: 0 }}>
<div><small>{row.connectionState === 'healthy' ? '接入结论' : '优先核对'}</small><strong>{accessIssueSummary(row)}</strong><span>{row.actualProtocols.length ? `已发现 ${row.actualProtocols.join(' / ')} ${row.actualProtocols.length} 个真实来源` : '当前没有可用的真实来源证据'}</span><b> · {accessRecommendation(row)}</b></div>
<ConnectionTag row={row} />
</Card>
<div className="v2-access-health-strip" role="group" aria-label="接入健康概览">
<span><small></small><strong>{health.connected} / {PROTOCOLS.length}</strong></span>
<span><small>线</small><strong className="is-good">{health.online}</strong></span>
<span><small></small><strong className={health.attention ? 'is-danger' : ''}>{health.attention}</strong></span>
<span><small></small><strong className={row.masterDataIssues.length ? 'is-warning' : ''}>{row.masterDataIssues.length ? `${row.masterDataIssues.length} 项待维护` : '已维护'}</strong></span>
</div>
{mobile ? <div className="v2-access-mobile-profile"><small></small><strong>{[row.oem, row.model].filter(Boolean).join(' / ') || '品牌与车型未维护'}</strong></div> : <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('') : '已维护' }
]} />}
<div className="v2-access-protocol-heading">
<span><strong></strong><small></small></span>
<Tag color={row.actualProtocols.length ? 'blue' : 'grey'} type="light" size="small">{row.actualProtocols.length} / {PROTOCOLS.length} </Tag>
</div>
<CardGroup className="v2-access-protocol-details" type="grid" spacing={0}>{protocols.map((protocol) => <ProtocolState
key={protocol}
status={statusByProtocol(row, protocol)}
detailed
expanded={expandedProtocol === protocol}
onToggle={toggleProtocol}
/>)}</CardGroup>
<footer className="v2-access-inspector-footer">
<span>{row.expectationEvidence}</span>
<nav className="v2-access-inspector-actions" aria-label={`${row.plate || row.vin} 数据操作`}>
<Link to={`/vehicles/${encodeURIComponent(row.vin)}`}></Link>
<Link to={`/tracks?vin=${encodeURIComponent(row.vin)}`}></Link>
<Link to={`/history?vin=${encodeURIComponent(row.vin)}`}></Link>
</nav>
</footer>
</Card>;
}
function IdentityQueue({ items, total }: { items: AccessUnresolvedIdentity[]; total: number }) {
if (!total) return null;
return <Collapse id="access-identity-queue" className="v2-access-identity-queue-v3" defaultActiveKey="unresolved-identities" keepDOM={false} lazyRender>
<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><span><small></small><b>{item.identifierMasked}</b></span><ProtocolTag protocol={item.protocol} compact /></header>
<div className="v2-access-identity-facts">
<span><small></small><strong>{item.plate || '待核对'}</strong></span>
<span><small></small><strong><AccessTime value={item.latestSeenAt} compact /></strong></span>
</div>
<footer><small></small><strong>{item.recommendedAction}</strong></footer>
</Card>)}</div>
{total > 6 ? <p className="v2-access-identity-more"> 6 {(total - 6).toLocaleString('zh-CN')} </p> : null}
</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;
const thresholdField = (label: string, description: string, value: number, update: (next: number) => void) => <label>
<span><b>{label}</b><small>{description}</small></span>
<Input suffix="秒" type="number" value={String(value)} onChange={(next) => update(Number(next))} />
<em>{formatSeconds(value)}</em>
</label>;
return <Collapse className="v2-access-settings" keepDOM={false} lazyRender>
<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}>
<section className="v2-access-threshold-group">
<header><strong></strong><small></small></header>
<div className="v2-access-threshold-fields">
{thresholdField('全局在线阈值', '未单独配置协议时使用', draft.defaultThresholdSec, (value) => onChange({ ...draft, defaultThresholdSec: value }))}
{thresholdField('数据延迟异常', '事件时间与接收时间差值', draft.delayThresholdSec, (value) => onChange({ ...draft, delayThresholdSec: value }))}
{thresholdField('长离线阈值', '进入长期离线治理范围', draft.longOfflineSec, (value) => onChange({ ...draft, longOfflineSec: value }))}
</div>
</section>
<section className="v2-access-threshold-group">
<header><strong>线</strong><small></small></header>
<div className="v2-access-threshold-fields">
{PROTOCOLS.map((protocol) => {
const value = draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec;
const meta = protocolThresholdMeta[protocol];
return <span key={protocol}>{thresholdField(meta.label, meta.description, value, (next) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, next) }))}</span>;
})}
</div>
</section>
{error ? <p className="v2-access-threshold-error" role="alert">{error}</p> : null}
{editable ? <footer className="v2-access-threshold-footer"><span><strong></strong><small>线</small></span><Button theme="solid" icon={<IconSave />} onClick={onSave} disabled={saving}>{saving ? '保存中' : '保存阈值'}</Button></footer> : <small></small>}
</fieldset>
</Collapse.Panel>
</Collapse>;
}
function downloadRows(rows: AccessVehicleRow[]) {
const blob = new Blob([accessRowsToCSV(rows)], { type: 'text/csv;charset=utf-8' });
downloadBlob(blob, `vehicle-access-${new Date().toISOString().slice(0, 10)}.csv`);
}
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 [filtersCollapsed, setFiltersCollapsed] = useState(true);
const mobileLayout = useMobileLayout();
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(() => mobileLayout ? 20 : 50); const [selectedVIN, setSelectedVIN] = useState('');
const [governanceOpen, setGovernanceOpen] = useState(false);
const [mobileToolsOpen, setMobileToolsOpen] = 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]);
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'] })]); } });
useEffect(() => {
if (!mobileLayout) {
setMobileToolsOpen(false);
return;
}
setLimit(20);
setOffset(0);
}, [mobileLayout]);
const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN);
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
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 applyDraft = () => { apply(draft); setFiltersCollapsed(true); };
const resetMobileDraft = () => setDraft(EMPTY_FILTERS);
const resetFilters = () => { apply(EMPTY_FILTERS); setFiltersCollapsed(true); };
const submit = (event: FormEvent) => { event.preventDefault(); applyDraft(); };
const page = Math.floor(offset / limit) + 1; const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit)); const summary = summaryQuery.data;
const activeFilterCount = Object.values(criteria).filter(Boolean).length;
const refresh = () => Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch(), ...(editable ? [unresolvedQuery.refetch(), thresholdQuery.refetch()] : [])]);
const accessMetric = (
label: string,
value: number,
connectionState: string,
options: Pick<WorkspaceQueueMetricRailItem, 'note' | 'tone' | 'emphasis'>
): WorkspaceQueueMetricRailItem => {
const formattedValue = value.toLocaleString('zh-CN');
return {
label,
value: formattedValue,
...options,
active: criteria.connectionState === connectionState,
ariaLabel: `筛选${label},共 ${formattedValue}`,
onClick: () => apply({ ...criteria, connectionState })
};
};
const attentionVehicles = Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0));
const accessMetrics: WorkspaceQueueMetricRailItem[] = [
accessMetric('需关注', attentionVehicles, 'attention', {
note: mobileLayout ? '异常与缺失' : '异常、离线或缺失',
tone: 'warning',
emphasis: 'primary'
}),
accessMetric('全部车辆', summary?.totalVehicles ?? 0, '', {
note: mobileLayout ? '权威主车辆' : '权威主车辆口径',
tone: 'neutral',
emphasis: 'primary'
})
];
const scrollAccessTable = (event: KeyboardEvent<HTMLDivElement>) => {
if (mobileLayout || event.currentTarget !== event.target || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return;
const tableScroller = event.currentTarget.scrollWidth > event.currentTarget.clientWidth
? event.currentTarget
: event.currentTarget.querySelector<HTMLElement>('.semi-table-body');
if (!tableScroller) return;
event.preventDefault();
tableScroller.scrollBy({ left: event.key === 'ArrowRight' ? 260 : -260, behavior: 'smooth' });
};
const filterFields = <>
<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>
</>;
return <div className="v2-access-page v2-access-page-v3">
<div className="v2-access-discovery-shell">
<WorkspaceCommandBar
className="v2-access-command-bar"
ariaLabel="接入管理操作"
title="真实来源与接入健康"
description={mobileLayout
? summary ? `截至 ${compactTime(summary.asOf)} · ${summary.totalVehicles.toLocaleString('zh-CN')} 辆主车辆` : '正在读取最新接入状态'
: '真实来源、在线健康与资料差异一处核对'}
status={summary ? `${summary.totalVehicles.toLocaleString('zh-CN')} 辆主车辆` : '正在读取车辆'}
meta={<Typography.Text type="tertiary"> <AccessTime value={summary?.asOf} /></Typography.Text>}
actions={mobileLayout ? <>
{editable ? <Button className="v2-workspace-mobile-tool-button is-muted" theme="light" icon={<IconSetting />} aria-label="打开接入治理" aria-haspopup="dialog" aria-controls="v2-access-governance" aria-expanded={governanceOpen} onClick={() => setGovernanceOpen(true)}>{unresolvedQuery.data?.total ? ` · ${unresolvedQuery.data.total}` : ''}</Button> : null}
<Dropdown
trigger="click"
position="bottomRight"
visible={mobileToolsOpen}
onVisibleChange={setMobileToolsOpen}
contentClassName="v2-workspace-mobile-tools-menu"
render={<Dropdown.Menu>
<Dropdown.Item disabled={!rows.length} icon={<IconDownload />} onClick={() => { setMobileToolsOpen(false); downloadRows(rows); }}></Dropdown.Item>
<Dropdown.Item icon={<IconRefresh />} onClick={() => { setMobileToolsOpen(false); void refresh(); }}></Dropdown.Item>
</Dropdown.Menu>}
>
<Button className="v2-workspace-mobile-tool-button is-muted" theme="light" aria-label="打开接入管理工具" aria-haspopup="menu" aria-expanded={mobileToolsOpen} icon={<IconMore />}></Button>
</Dropdown>
</> : <>{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></>}
/>
{mobileLayout ? <div className="v2-access-mobile-discovery">
<MobileFilterToggle
title="接入筛选"
summary={activeFilterCount ? `已启用 ${activeFilterCount}` : '全部主车辆'}
expanded={mobileFiltersOpen}
controls="v2-access-mobile-filters"
expandedLabel="关闭"
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
/>
<MobileFilterSheet
className="v2-access-mobile-filter-sidesheet"
visible={mobileFiltersOpen}
height="min(76dvh, 640px)"
ariaLabel="接入车辆筛选"
dialogId="v2-access-mobile-filters"
closeLabel="关闭接入车辆筛选"
title="筛选接入车辆"
description="车辆、接入状态、真实协议与品牌"
onCancel={() => setFiltersCollapsed(true)}
secondaryAction={{ label: '重置条件', onClick: resetMobileDraft }}
primaryAction={{ label: '应用并查询', onClick: applyDraft }}
>
<form className="v2-access-mobile-filter-form" onSubmit={submit}>
<MobileFilterSheetSection ariaLabel="接入范围" title="接入范围" description="优先定位异常、离线、尚无来源和资料差异车辆">
<div>{filterFields}</div>
</MobileFilterSheetSection>
</form>
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-access-filter-card-v3"
title="接入筛选"
description="车辆、接入状态、真实协议与品牌"
mobileSummary={activeFilterCount ? `已启用 ${activeFilterCount}` : '全部主车辆'}
expanded={!filtersCollapsed}
status={activeFilterCount ? `${activeFilterCount} 项已启用` : '全部主车辆'}
statusColor={activeFilterCount ? 'blue' : 'grey'}
onToggle={() => setFiltersCollapsed((value) => !value)}
>
<form className="v2-access-filter-v3" onSubmit={submit}>
{filterFields}
<div className="v2-access-filter-actions">
<Button className="v2-primary-button" theme="solid" htmlType="submit"></Button>
<Button className="v2-secondary-button" theme="light" htmlType="button" onClick={resetFilters}></Button>
</div>
</form>
</WorkspaceFilterPanel>}
</div>
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
{vehiclesQuery.isError ? <InlineError message={vehiclesQuery.error instanceof Error ? vehiclesQuery.error.message : '接入车辆读取失败'} onRetry={() => vehiclesQuery.refetch()} /> : null}
<div className="v2-access-workspace-v3">
<Card className="v2-access-table-v3" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title={scopeLabels[criteria.connectionState] || '车辆接入清单'}
description="缺席协议仅表示当前未发现;优先核对异常、离线与资料差异"
actionsClassName="v2-access-table-actions"
actions={mobileLayout ? null : <Button theme="light" icon={<IconDownload />} onClick={() => downloadRows(rows)} disabled={!rows.length}></Button>}
/>
<nav className="v2-access-status-tabs" aria-label="接入差异筛选">
<WorkspaceMetricRail
variant="queue"
className="v2-access-metric-rail"
ariaLabel="接入差异概览"
items={accessMetrics}
context={!mobileLayout ? <ProtocolCoverage summary={summary} /> : undefined}
/>
</nav>
<div
className={`v2-access-table-scroll-v3${mobileLayout ? ' is-mobile-scroll' : ''}`}
tabIndex={0}
aria-label={mobileLayout ? '车辆接入列表,可上下滚动' : '车辆接入表格,可使用左右方向键浏览协议来源'}
onKeyDown={scrollAccessTable}
>
{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 className={row.connectionState === 'healthy' ? '' : 'is-issue'} title={row.connectionState === 'healthy' ? undefined : accessIssueSummary(row)}>{row.connectionState === 'healthy' ? `${row.oem || '品牌未维护'} · ${row.model || row.company || '车型未维护'}` : `${accessIssueSummary(row)} · ${row.oem || '品牌未维护'} ${row.model || row.company || '车型未维护'}`}</p><span className="v2-access-mobile-protocols">{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} protocolLabel={compactProtocolLabel(protocol)} status={statusByProtocol(row, protocol)} />)}</span><footer><IconChevronRight /></footer></span></Button></Card>)}</div>
: <AccessVehicleTable rows={rows} selectedVIN={selectedVIN} onSelect={setSelectedVIN} />}
{vehiclesQuery.isFetching ? <PanelLoading className="v2-access-loading" title="正在更新车辆接入状态…" description="当前列表返回后会自动替换。" compact={Boolean(rows.length)} /> : null}
{!vehiclesQuery.isFetching && !rows.length ? <PanelEmpty 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={mobileLayout ? undefined : limit} pageSizeLabel="每页车辆数" onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={mobileLayout ? undefined : [{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
</Card>
</div>
<WorkspaceDetailSideSheet
className="v2-access-detail-sidesheet"
visible={Boolean(selected)}
ariaLabel="车辆接入详情"
placement={mobileLayout ? 'bottom' : 'right'}
width={mobileLayout ? undefined : 520}
height={mobileLayout ? 'min(84dvh, 740px)' : undefined}
title="车辆接入详情"
description={selected ? `${selected.plate || '未绑定车牌'} · ${selected.vin}` : '来源、在线状态与接入证据'}
badge={selected ? connectionLabels[selected.connectionState] : undefined}
badgeColor={selected ? connectionTagColor(selected.connectionState) : 'grey'}
onCancel={() => setSelectedVIN('')}
>
{selected ? <VehicleInspector key={selected.vin} row={selected} onClose={() => setSelectedVIN('')} sheet mobile={mobileLayout} /> : null}
</WorkspaceDetailSideSheet>
{editable ? <WorkspaceSideSheet
className="v2-access-governance-sidesheet"
variant="config"
visible={governanceOpen}
ariaLabel="接入治理配置"
closeLabel="关闭接入治理配置"
placement={mobileLayout ? 'bottom' : 'right'}
width={mobileLayout ? undefined : 560}
height={mobileLayout ? 'min(88dvh, 780px)' : undefined}
title="接入治理工作台"
description="核对待绑定来源,统一维护协议在线判定阈值"
badge={(unresolvedQuery.data?.total ?? 0) > 0 ? `${unresolvedQuery.data?.total.toLocaleString('zh-CN')} 项待办` : '治理正常'}
badgeColor={(unresolvedQuery.data?.total ?? 0) > 0 ? 'orange' : 'green'}
summaryItems={[
{
label: '待绑定来源',
value: (unresolvedQuery.data?.total ?? 0).toLocaleString('zh-CN'),
detail: (unresolvedQuery.data?.total ?? 0) > 0 ? '需要核对权威 VIN' : '当前没有身份待办',
tone: (unresolvedQuery.data?.total ?? 0) > 0 ? 'warning' : 'success'
},
{
label: '阈值版本',
value: `v${thresholdQuery.data?.version ?? '—'}`,
detail: '在线与延迟统一口径',
tone: 'primary'
},
{
label: '主车辆口径',
value: (summary?.totalVehicles ?? 0).toLocaleString('zh-CN'),
detail: '仅统计权威主车辆',
tone: 'neutral'
}
]}
footerNote="阈值保存后即时生效;关闭工作台不会丢失已保存配置"
primaryAction={{ label: '完成', onClick: () => setGovernanceOpen(false) }}
onCancel={() => setGovernanceOpen(false)}
>
{governanceOpen ? <div className="v2-access-governance">
{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}
</WorkspaceSideSheet> : null}
</div>;
}