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 { TablePagination } from '../shared/TablePagination'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; 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 = { 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, protocolLabel }: { status?: AccessProtocolStatus; detailed?: boolean; protocolLabel?: string }) { 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 {status?.protocol}} headerExtraContent={{label}} headerLine bodyStyle={{ padding: 0 }} > {formatSeconds(status?.dataDelaySec)} } ]} />

{status?.firstSeenEvidence || '当前未发现该协议来源'}

; } return
{protocolLabel ? {protocolLabel} : null} {label} {status?.connected ? compactTime(status.latestReceivedAt) : '—'} {status?.provider || (status?.connected ? '接入方未维护' : '未发现来源')}
; } function ConnectionState({ row }: { row: AccessVehicleRow }) { const color = row.connectionState === 'healthy' ? 'green' : row.connectionState === 'not_connected' || row.connectionState === 'offline' ? 'red' : 'orange'; return
{connectionLabels[row.connectionState]}{row.actualProtocols.length} 个真实来源
; } function ProtocolCoverage({ summary }: { summary?: AccessSummary }) { return
{PROTOCOLS.map((protocol) => { const actual = summary?.protocols.find((item) => item.name === protocol); return {protocol}{(actual?.total ?? 0).toLocaleString('zh-CN')} 辆; })}
; } 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) =>
{row.plate || '未绑定车牌'}{row.vin}
}, { title: '品牌 / 车型', dataIndex: 'oem', width: 140, render: (_: string, row: AccessVehicleRow) =>
{row.oem || '品牌未维护'}{row.model || row.company || '车型未维护'}
}, { title: '真实来源', dataIndex: 'actualProtocols', width: 115, render: (_: string[], row: AccessVehicleRow) =>
{row.actualProtocols.length} 个{row.actualProtocols.join(' / ') || '尚无来源'}
}, ...PROTOCOLS.map((protocol) => ({ title: protocol, dataIndex: protocol, width: 160, render: (_: unknown, row: AccessVehicleRow) => })), { title: '综合状态', dataIndex: 'connectionState', width: 120, render: (_: AccessVehicleRow['connectionState'], row: AccessVehicleRow) => } ], []); return 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 } onClick={onClose} aria-label="关闭车辆接入详情" />} /> } ]} /> {PROTOCOLS.map((protocol) => )}
{row.expectationEvidence}查看车辆详情
; } function IdentityQueue({ items, total }: { items: AccessUnresolvedIdentity[]; total: number }) { if (!total) return null; return 来源身份待绑定这些来源不计入主车辆,绑定权威 VIN 后再归档{total.toLocaleString('zh-CN')} 条}>
{items.slice(0, 6).map((item) =>
{item.identifierMasked}{item.protocol}
{item.plate || '车牌待核对'} · {formatAccessTime(item.latestSeenAt)} {item.recommendedAction}
)}
; } 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 在线判定阈值统一控制来源在线、长离线和协议新鲜度判定v{config?.version ?? '—'}}>
{PROTOCOLS.map((protocol) => )}{error ?

{error}

: null}{editable ? : 当前账户为只读角色}
; } 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(); 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>({ queryKey: ['access-vehicles', vehicleScope, limit, offset], queryFn: ({ signal }) => api.accessVehicles({ ...baseQuery, limit, offset }, signal), placeholderData: retainPreviousPageWithinScope>(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
数据时间 {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}} actions={<>{editable ? : null}} /> setFiltersCollapsed((value) => !value)} >