Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx
2026-07-18 01:29:30 +08:00

291 lines
26 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 { 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';
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, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MetricActionButton } from '../shared/MetricActionButton';
import { PageHeader } from '../shared/PageHeader';
import { TablePagination } from '../shared/TablePagination';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
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 });
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 compactAccessTimeFormatter.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' ? '未知' : '从未上报';
const color = state === 'online' ? 'green' : state === 'offline' || state === 'unknown' ? 'orange' : 'grey';
if (detailed) {
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>
<strong>{status?.connected ? compactTime(status.latestReceivedAt) : '—'}</strong>
<small>{status?.provider || (status?.connected ? '接入方未维护' : '未发现来源')}</small>
</div>;
}
function ConnectionState({ row }: { row: AccessVehicleRow }) {
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 }) {
return <div className="v2-access-protocol-coverage" aria-label="真实协议来源概览">
{PROTOCOLS.map((protocol) => {
const actual = summary?.protocols.find((item) => item.name === protocol);
return <span key={protocol}><b>{protocol}</b><em>{(actual?.total ?? 0).toLocaleString('zh-CN')} </em></span>;
})}
</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>
</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" 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 <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[]) {
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 [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) 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); };
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">
<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></>}
/>
<WorkspaceFilterPanel
className="v2-access-filter-card-v3"
title="接入筛选"
description="车辆、接入状态、真实协议与品牌"
mobileSummary={Object.values(criteria).filter(Boolean).length ? `已启用 ${Object.values(criteria).filter(Boolean).length}` : '全部主车辆'}
expanded={!filtersCollapsed}
status={Object.values(criteria).filter(Boolean).length ? `${Object.values(criteria).filter(Boolean).length} 项已启用` : '全部主车辆'}
statusColor={Object.values(criteria).filter(Boolean).length ? 'blue' : 'grey'}
onToggle={() => setFiltersCollapsed((value) => !value)}
>
<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></WorkspaceFilterPanel>
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
<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 && !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 ? <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={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>;
}