import { IconAlertTriangle, IconChevronDown, IconChevronRight, IconChevronUp, IconClose, IconRefresh, IconSearch } from '@douyinfe/semi-icons'; import { Button, Card, Empty, Input, RadioGroup, Select, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useDeferredValue, useEffect, useMemo, useState } from 'react'; import { api } from '../../api/client'; import type { ReconciliationIssue, ReconciliationSummary } from '../../api/types'; import { InlineError } from '../shared/AsyncState'; import { MobileFilterToggle } from '../shared/MobileFilterToggle'; import { PlatformTime } from '../shared/PlatformTime'; import { TablePagination } from '../shared/TablePagination'; import { WorkspaceMetricRail } from '../shared/WorkspaceMetricRail'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet'; import { detailTriggerRow } from '../shared/detailTriggerRow'; import { QUERY_MEMORY } from '../queryPolicy'; import { useMobileLayout } from '../hooks/useMobileLayout'; const DESKTOP_PAGE_SIZE = 50; const MOBILE_PAGE_SIZE = 20; const activeStatuses = new Set(['pending', 'confirmed_source_a', 'confirmed_source_b']); function statusLabel(status: string) { return { pending: '待处理', confirmed_source_a: '确认来源 A', confirmed_source_b: '确认来源 B', no_action: '无需处理', fixed: '已修复', recovered: '已恢复' }[status] ?? status; } function severityLabel(severity: string) { return { critical: '严重', major: '重要', minor: '一般' }[severity] ?? severity; } function ReconciliationSeverityTag({ severity }: { severity: string }) { const color = severity === 'critical' ? 'red' : severity === 'major' ? 'orange' : 'grey'; return {severityLabel(severity)}; } function ReconciliationStatusTag({ status }: { status: string }) { const color = status === 'pending' ? 'orange' : status === 'confirmed_source_a' || status === 'confirmed_source_b' ? 'blue' : status === 'fixed' || status === 'recovered' ? 'green' : 'grey'; return {statusLabel(status)}; } function ruleLabel(rule: string) { return { DUPLICATE_PLATE: '重复车牌', DUPLICATE_PHONE: '重复终端', UNBOUND_SOURCE: '来源未绑定', SOURCE_MISSING: '主车无来源', POSITION_DRIFT: '位置漂移', MILEAGE_REVERSE: '里程倒退', MILEAGE_JUMP: '里程跳变', MILEAGE_SOURCE_DIVERGENCE: '里程来源差异', FLEET_COUNT_MISMATCH: '车辆总数不一致', BUSINESS_SCOPE_UNBOUND: '业务车辆未绑定', AUTH_SCOPE_BUSINESS_MISMATCH: '授权与业务范围不一致' }[rule] ?? rule; } function categoryLabel(category: string) { return { identity: '身份', source: '数据来源', location: '实时位置', mileage: '里程', fleet: '车队口径', business: '业务范围' }[category] ?? category; } const evidenceLabels: Record = { date: '统计日期', protocol: '协议来源', protocols: '协议来源', dailyMileageKm: '当日里程', latestTotalMileageKm: '最新总里程', derivedStartMileageKm: '推算起始总里程', minimumKm: '来源最小里程', maximumKm: '来源最大里程', differenceKm: '来源里程差', distanceM: '坐标差距', sourceA: '来源 A 快照', sourceB: '来源 B 快照', longitude: '经度', latitude: '纬度', receivedAt: '平台接收时间', sourceHash: '来源指纹', vin: '车辆 VIN', vins: '关联 VIN', plate: '车牌', vinCount: '关联车辆数', identifierHash: '终端标识指纹', latestSeen: '最近上报', bindingSource: '绑定依据', boundVehicles: '主车辆数', sourceUnionVehicles: '来源车辆并集', unboundSourceVehicles: '未绑定来源数', scopeVersion: '业务范围版本', customerId: '客户 ID', customerName: '客户名称', contractCode: '合同编号', projectName: '项目名称', departmentName: '所属部门', responsibleUserName: '责任人', userId: '账号 ID', username: '账号', customerRef: '客户标识', grantValidFrom: '授权生效时间' }; const mileageEvidenceKeys = new Set(['dailyMileageKm', 'latestTotalMileageKm', 'derivedStartMileageKm', 'minimumKm', 'maximumKm', 'differenceKm']); function evidenceLabel(key: string) { return evidenceLabels[key] ?? key; } function evidenceScalar(key: string, value: unknown) { if (value == null || value === '') return '—'; if (typeof value === 'number') { const formatted = value.toLocaleString('zh-CN', { maximumFractionDigits: 3 }); if (mileageEvidenceKeys.has(key)) return `${formatted} km`; if (key === 'distanceM') return `${formatted} m`; return formatted; } if (Array.isArray(value)) return value.map(String).join('、') || '—'; if (typeof value === 'boolean') return value ? '是' : '否'; return String(value); } function EvidenceValue({ name, value }: { name: string; value: unknown }) { if (value && typeof value === 'object' && !Array.isArray(value)) { return
{Object.entries(value as Record).map(([key, nested]) => {evidenceLabel(key)} {evidenceScalar(key, nested)} )}
; } return {evidenceScalar(name, value)}; } function reviewStatusOptions(issue: ReconciliationIssue) { const options = [{ value: 'pending', label: '待复核' }]; if (issue.protocolA && issue.protocolB) { options.push( { value: 'confirmed_source_a', label: `采信 ${issue.protocolA}` }, { value: 'confirmed_source_b', label: `采信 ${issue.protocolB}` } ); } options.push( { value: 'no_action', label: '无需处理' }, { value: 'fixed', label: '已修复' } ); return options; } function reviewGuidance(issue: ReconciliationIssue) { return { POSITION_DRIFT: '先比对两个来源的接收时间、车辆工况与当前位置,再判断可信来源;系统不会仅凭坐标差自动覆盖原始数据。', MILEAGE_REVERSE: '先核对原始总里程、单位换算和统计起止值;确认计算链路恢复后,再将问题标记为已修复。', MILEAGE_JUMP: '先核对仪表总里程、GPS 里程及跨日边界,排除单位或设备重置后再形成结论。', MILEAGE_SOURCE_DIVERGENCE: '对照仪表里程与 GPS 里程的原始报文、上报周期和业务口径,再选择本次可信来源。', DUPLICATE_PLATE: '核对车辆档案、VIN 与业务归属,确认真实主车辆后再处理重复绑定。', DUPLICATE_PHONE: '核对终端换绑记录与设备清单,避免将正常换车误判为重复终端。', UNBOUND_SOURCE: '先确认来源 VIN 的车辆档案和业务归属,再完成主车辆绑定。', SOURCE_MISSING: '核对设备接入、绑定关系与最近上报,确认是离线、未接入还是身份缺失。', FLEET_COUNT_MISMATCH: '分别核对主车辆、实时来源并集和未绑定来源,避免直接修改统计总数。', BUSINESS_SCOPE_UNBOUND: '核对 OneOS 当前业务范围与中台主车辆档案,再补齐主车辆绑定。', AUTH_SCOPE_BUSINESS_MISMATCH: '核对客户账号授权区间与 OneOS 活跃业务范围,再调整车辆权限。' }[issue.ruleCode] ?? '先核对原始证据、影响对象和时间范围,再选择结论并留下可追溯说明。'; } function ReconciliationTrend({ data, maxTrend }: { data?: ReconciliationSummary; maxTrend: number; }) { return 最近运行 } />
{data?.trend.slice(-14).map((item) =>
{item.active}
)}
{!data?.trend.length ?

完成首次每日检测后显示趋势。

: null}
存量新增恢复
; } function ReconciliationDetail({ issue, onClose, sheet = false }: { issue: ReconciliationIssue; onClose: () => void; sheet?: boolean }) { const queryClient = useQueryClient(); const [status, setStatus] = useState(issue.status === 'recovered' ? 'pending' : issue.status); const [note, setNote] = useState(issue.resolutionNote ?? ''); useEffect(() => { setStatus(issue.status === 'recovered' ? 'pending' : issue.status); setNote(issue.resolutionNote ?? ''); }, [issue.id, issue.resolutionNote, issue.status]); const save = useMutation({ mutationFn: () => api.updateReconciliationIssue(issue.id, { version: issue.version, status, note: note.trim() }), onSuccess: async (updated) => { queryClient.setQueryData(['reconciliation-detail', issue.id], updated); await Promise.all([ queryClient.invalidateQueries({ queryKey: ['reconciliation-summary'] }), queryClient.invalidateQueries({ queryKey: ['reconciliation-issues'] }) ]); } }); const requiresNote = status !== 'pending'; const evidenceEntries = Object.entries(issue.evidence ?? {}); const statusOptions = reviewStatusOptions(issue); return {!sheet ? } actions={