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 = { healthy: '已接来源正常', degraded: '部分来源异常', incomplete: '资料待维护', offline: '已接来源离线', not_connected: '尚无来源' }; const scopeLabels: Record = { '': '全部主车辆', 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 ; return ; } 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 {protocol}} headerExtraContent={{label}} headerLine bodyStyle={{ padding: 0 }} > {status?.connected ? <>
接入厂家{status.provider || '接入方未维护'} 最新上报 当前离线{formatSeconds(status.freshnessSec)} 数据延迟{formatSeconds(status.dataDelaySec)}
{expanded ?
}, { key: '上报间隔', value: formatSeconds(status.reportIntervalSec) } ]} />

{evidence}

: null} :
当前未发现真实接入来源 无需展开,待收到该协议报文后自动补充证据。
}
; } return
{protocolLabel ? {protocolLabel} : null} {label} {status?.connected ? : '—'} {status?.provider || (status?.connected ? '接入方未维护' : '未发现来源')}
; }); function ConnectionTag({ row }: { row: AccessVehicleRow }) { return {connectionLabels[row.connectionState]}; } 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
{summary} {row.actualProtocols.length ? `${row.actualProtocols.length} 个真实来源 · ${row.actualProtocols.join(' / ')}` : '当前没有真实来源证据'}
; }); function ProtocolCoverage({ summary }: { summary?: AccessSummary }) { return
真实来源覆盖 {PROTOCOLS.map((protocol) => { const actual = summary?.protocols.find((item) => item.name === protocol); return {compactProtocolLabel(protocol)}{(actual?.total ?? 0).toLocaleString('zh-CN')} 辆; })}
; } 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) =>
{row.plate || '未绑定车牌'}{row.vin}
}, { title: '品牌 / 车型', dataIndex: 'oem', width: 140, fixed: 'left' as const, className: 'v2-access-model-column', render: (_: string, row: AccessVehicleRow) =>
{row.oem || '品牌未维护'}{row.model || row.company || '车型未维护'}
}, ...PROTOCOLS.map((protocol) => ({ title: protocol, dataIndex: protocol, width: 160, render: (_: unknown, row: AccessVehicleRow) => })), { title: '差异摘要', dataIndex: 'connectionState', width: 220, 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, 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 {sheet ? null : } onClick={onClose} aria-label="关闭车辆接入详情" />} />}
{row.connectionState === 'healthy' ? '接入结论' : '优先核对'}{accessIssueSummary(row)}{row.actualProtocols.length ? `已发现 ${row.actualProtocols.join(' / ')} ${row.actualProtocols.length} 个真实来源` : '当前没有可用的真实来源证据'}处置建议 · {accessRecommendation(row)}
已接入{health.connected} / {PROTOCOLS.length} 当前在线{health.online} 异常来源{health.attention} 资料状态{row.masterDataIssues.length ? `${row.masterDataIssues.length} 项待维护` : '已维护'}
{mobile ?
车辆档案{[row.oem, row.model].filter(Boolean).join(' / ') || '品牌与车型未维护'}
: }
协议证据异常优先展示,按需展开完整证据 {row.actualProtocols.length} / {PROTOCOLS.length} 已接入
{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.plate || '待核对'} 最后出现
建议动作{item.recommendedAction}
)}
{total > 6 ?

当前展示最近 6 条,剩余 {(total - 6).toLocaleString('zh-CN')} 条可通过来源检索继续核对。

: null}
; } 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) => ; return 在线判定阈值统一控制来源在线、长离线和协议新鲜度判定v{config?.version ?? '—'}}>
通用判定先定义全局兜底,再按协议覆盖
{thresholdField('全局在线阈值', '未单独配置协议时使用', draft.defaultThresholdSec, (value) => onChange({ ...draft, defaultThresholdSec: value }))} {thresholdField('数据延迟异常', '事件时间与接收时间差值', draft.delayThresholdSec, (value) => onChange({ ...draft, delayThresholdSec: value }))} {thresholdField('长离线阈值', '进入长期离线治理范围', draft.longOfflineSec, (value) => onChange({ ...draft, longOfflineSec: value }))}
协议在线阈值匹配不同协议的真实上报周期
{PROTOCOLS.map((protocol) => { const value = draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec; const meta = protocolThresholdMeta[protocol]; return {thresholdField(meta.label, meta.description, value, (next) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, next) }))}; })}
{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 [mobileToolsOpen, setMobileToolsOpen] = 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) { 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 => { 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) => { 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('.semi-table-body'); if (!tableScroller) return; event.preventDefault(); tableScroller.scrollBy({ left: event.key === 'ArrowRight' ? 260 : -260, behavior: 'smooth' }); }; const filterFields = <>