import './governance-workflows.css'; import { IconChevronDown, IconChevronLeft, IconChevronRight, IconClose, IconConnectionPoint1, IconDownload, IconMore, IconRefresh, IconSave, IconSearch, IconSetting, IconTickCircle } from '@douyinfe/semi-icons'; import { Button, Card, CardGroup, Collapse, Descriptions, Dropdown, Input, Select, Table, Tag, Typography } from '@douyinfe/semi-ui'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { FormEvent, KeyboardEvent, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; import type { AccessIdentityClaimResult, AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page, VehicleRow } 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 { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from './GovernanceStatusStrip'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet'; import { ProtocolTag } from '../shared/ProtocolTag'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; 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; type AccessProtocol = (typeof PROTOCOLS)[number]; const protocolThresholdMeta: Record = { 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 ACCESS_FILTER_KEYS = Object.keys(EMPTY_FILTERS) as Array; type AccessRouteView = { filters: Filters; page: number; limit: number; selectedVIN: string }; export function accessRouteViewFromParams(params: URLSearchParams, defaultLimit = 50): AccessRouteView { const requestedLimit = Number(params.get('accessLimit')); const limit = requestedLimit === 20 || requestedLimit === 50 || requestedLimit === 100 ? requestedLimit : defaultLimit; const requestedPage = Number(params.get('accessPage')); return { filters: Object.fromEntries(ACCESS_FILTER_KEYS.map((key) => [key, (params.get(key) || '').slice(0, 160)])) as Filters, page: Number.isSafeInteger(requestedPage) && requestedPage > 0 && requestedPage <= 100_000 ? requestedPage : 1, limit, selectedVIN: (params.get('accessVin') || '').slice(0, 160) }; } 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; } function AccessProtocolTitle({ protocol, compact = false }: { protocol: AccessProtocol; compact?: boolean }) { return {protocolThresholdMeta[protocol].description} ; } const ProtocolState = memo(function ProtocolState({ status, protocol, detailed = false, protocolLabel, expanded = false, onToggle }: { status?: AccessProtocolStatus; protocol: AccessProtocol; 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 evidenceId = `access-protocol-${protocol.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-evidence`; const evidence = status?.firstSeenEvidence || '当前未发现该协议来源'; return } 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} {status?.connected ? <> {label}{status.provider ? {status.provider} : null} : 未发现来源}
; }); 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
{row.actualProtocols.length} 个来源
{row.connectionState === 'healthy' ? {row.actualProtocols.join(' / ')} : row.connectionState !== 'not_connected' ? {summary} : null}
; }); 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: 'connectionState', width: 220, fixed: 'left' as const, className: 'v2-access-difference-column', render: (_: AccessVehicleRow['connectionState'], row: AccessVehicleRow) => }, ...PROTOCOLS.map((protocol) => ({ title: , dataIndex: protocol, width: 150, render: (_: unknown, row: AccessVehicleRow) => })), { title: '品牌 / 车型', dataIndex: 'oem', width: 140, className: 'v2-access-model-column', render: (_: string, row: AccessVehicleRow) =>
{row.oem || '品牌未维护'}{row.model || row.company || '车型未维护'}
} ], []); return } onRow={(row) => row ? detailTriggerRow({ className: [`is-state-${row.connectionState}`, selectedVIN === row.vin ? 'is-selected' : ''].filter(Boolean).join(' '), 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, onClaim }: { items: AccessUnresolvedIdentity[]; total: number; onClaim: (item: AccessUnresolvedIdentity) => void }) { 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}
; } type AccessClaimCandidate = VehicleRow & { protocols: string[] }; function accessClaimCandidates(rows: VehicleRow[]) { const byVIN = new Map(); for (const row of rows) { const current = byVIN.get(row.vin); const protocols = Array.from(new Set([...(current?.protocols ?? []), row.protocol].filter(Boolean))); if (!current || (!current.online && row.online)) byVIN.set(row.vin, { ...row, protocols }); else current.protocols = protocols; } return Array.from(byVIN.values()); } function AccessIdentityClaimPanel({ identity, selected, confirmed, note, error, onSelect, onConfirm, onNote }: { identity: AccessUnresolvedIdentity; selected?: AccessClaimCandidate; confirmed: boolean; note: string; error?: string; onSelect: (vehicle: AccessClaimCandidate) => void; onConfirm: (confirmed: boolean) => void; onNote: (note: string) => void; }) { const initialSearch = identity.plate && identity.plate !== '待维护' ? identity.plate : ''; const [search, setSearch] = useState(initialSearch); const deferredSearch = useDeferredValue(search.trim()); const candidateParams = useMemo(() => { const params = new URLSearchParams({ limit: '10', offset: '0' }); if (deferredSearch) params.set('keyword', deferredSearch); return params; }, [deferredSearch]); const candidates = useQuery({ queryKey: ['access-identity-claim-candidates', identity.id, deferredSearch], queryFn: ({ signal }) => api.vehicles(candidateParams, signal), staleTime: 30_000, gcTime: QUERY_MEMORY.summaryGcTime }); const options = useMemo(() => accessClaimCandidates(candidates.data?.items ?? []), [candidates.data?.items]); const selectedVINs = useMemo(() => new Set(selected ? [selected.vin] : []), [selected]); const plateComparable = identity.plate && identity.plate !== '待维护'; const plateMatches = Boolean(plateComparable && selected?.plate && identity.plate === selected.plate); return
  1. 1核对来源证据已保留
  2. 2选择主车辆{selected ? '已选择权威 VIN' : '车牌或 VIN 搜索'}
  3. 3确认并留痕保存后生成审计记录
待认领来源{identity.identifierMasked}
来源车牌{identity.plate || '待核对'} 终端厂家{identity.manufacturer || '未提供'} 最后出现

{identity.recommendedAction}

选择权威主车辆只从现有主车辆中选择,不允许根据来源标识猜测 VIN{selected ? 已选 1 辆 : null}
} showClear value={search} onChange={setSearch} aria-label="搜索要认领的权威车辆" placeholder="搜索车牌 / VIN" /> candidates.refetch()} emptyText={deferredSearch ? '没有匹配的权威主车辆' : '当前没有可认领的主车辆'} selectedVins={selectedVINs} selectedLabel="已选择" actionLabel="选择" showProtocols onSelect={onSelect} />
{selected ?
认领预览保存前再次核对来源与目标车辆{plateMatches ? '车牌一致' : plateComparable ? '车牌需复核' : '来源无权威车牌'}
来源{identity.identifierMasked}{identity.plate || '待核对车牌'}权威车辆{selected.plate || '未绑定车牌'}{selected.vin}