From 04d59922526185d2d759ea00d49248fcdf0ed687 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 18 Jul 2026 09:35:16 +0800 Subject: [PATCH] refine Semi UI operations review flow --- .../web/src/v2/pages/OperationsPage.test.tsx | 17 +- .../apps/web/src/v2/pages/OperationsPage.tsx | 2 +- .../web/src/v2/pages/ReconciliationCenter.tsx | 172 +++++- .../apps/web/src/v2/styles/workspace.css | 527 ++++++++++++++++++ 4 files changed, 680 insertions(+), 38 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx index dcd3679e..e5dda3fd 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx @@ -58,7 +58,7 @@ test('renders reconciliation queue, loads evidence on demand and records review const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render(); - expect(await screen.findByText('数据差异中心')).toBeInTheDocument(); + expect(await screen.findByText('质量问题队列')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '运行与数据质量', level: 5 })).toBeInTheDocument(); expect(screen.getByLabelText('运维质量操作')).toHaveClass('v2-workspace-command-bar', 'v2-ops-command-bar'); const navigation = screen.getByRole('navigation', { name: '运维质量视图' }); @@ -69,9 +69,9 @@ test('renders reconciliation queue, loads evidence on demand and records review expect(screen.getByRole('tabpanel', { name: '差异处置' })).toHaveAttribute('tabindex', '0'); expect(document.querySelector('.v2-ops-page')).toHaveClass('is-reconciliation'); expect(screen.queryByText('先选择一辆车')).not.toBeInTheDocument(); - expect(screen.getByText('数据差异中心').closest('.semi-card')).toHaveClass('v2-reconcile-center'); - expect(screen.getByText('数据差异中心').closest('.v2-workspace-panel-header')).toHaveClass('v2-reconcile-heading'); - expect(document.querySelector('.v2-reconcile-kpi-card small')?.textContent).toBe('活跃差异'); + expect(screen.getByText('质量问题队列').closest('.semi-card')).toHaveClass('v2-reconcile-center'); + expect(screen.getByText('质量问题队列').closest('.v2-workspace-panel-header')).toHaveClass('v2-reconcile-heading'); + expect(document.querySelector('.v2-reconcile-kpi-card small')?.textContent).toBe('待复核'); expect(document.querySelectorAll('.v2-reconcile-kpi-card')).toHaveLength(4); expect(screen.getByText('超过 24 小时').closest('.semi-card')).toHaveClass('v2-reconcile-sla'); expect(screen.queryByText('近 30 天趋势')).not.toBeInTheDocument(); @@ -96,14 +96,17 @@ test('renders reconciliation queue, loads evidence on demand and records review expect(screen.getByText('处理履历').closest('.semi-card')).toHaveClass('v2-reconcile-actions'); expect(screen.getByText('规则证据').closest('.v2-workspace-panel-header')).toHaveClass('is-compact'); expect(document.querySelector('.v2-reconcile-detail-heading.v2-workspace-panel-header')).toBeInTheDocument(); - expect(screen.getByText('1286')).toBeInTheDocument(); + expect(screen.getByText('复核提示')).toBeInTheDocument(); + expect(screen.getByText('坐标差距')).toBeInTheDocument(); + expect(screen.getByText('distanceM')).toBeInTheDocument(); + expect(screen.getByText('1,286 m')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '关闭差异详情' })); expect(reconcileRow).toHaveAttribute('aria-expanded', 'false'); fireEvent.click(issueTitles[0]); expect(await screen.findByText('规则证据')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('radio', { name: '确认来源 A' })); + fireEvent.click(screen.getByRole('radio', { name: '采信 GB32960' })); fireEvent.change(screen.getByLabelText('说明(必填)'), { target: { value: '来源 A 原始报文可信' } }); - fireEvent.click(screen.getByRole('button', { name: '保存复核结论' })); + fireEvent.click(screen.getByRole('button', { name: '保存结论并记录履历' })); await waitFor(() => expect(mocks.updateReconciliationIssue).toHaveBeenCalledWith('issue-1', { version: 1, status: 'confirmed_source_a', note: '来源 A 原始报文可信' })); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx index 4f98c5ee..18b1b8dc 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx @@ -353,7 +353,7 @@ export default function OperationsPage() { className="v2-ops-command-bar" ariaLabel="运维质量操作" title="运行与数据质量" - description="先定位单车多来源问题,再核对服务和协议全局健康;所有结论来自服务端证据。" + description="集中处理自动对账问题,按需诊断单车来源,并核对服务与协议健康;所有结论均可追溯。" status={data?.runtime.dataMode === 'production' ? '生产模式' : '状态待确认'} statusColor={data?.runtime.dataMode === 'production' ? 'green' : 'orange'} meta={{data?.runtime.platformRelease ? `版本 ${data.runtime.platformRelease}` : '正在读取运行版本'}} diff --git a/vehicle-data-platform/apps/web/src/v2/pages/ReconciliationCenter.tsx b/vehicle-data-platform/apps/web/src/v2/pages/ReconciliationCenter.tsx index 4045c47f..cbede868 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/ReconciliationCenter.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/ReconciliationCenter.tsx @@ -1,4 +1,4 @@ -import { IconChevronDown, IconChevronRight, IconChevronUp, IconClose, IconRefresh, IconSearch } from '@douyinfe/semi-icons'; +import { IconAlertTriangle, IconChevronDown, IconChevronRight, IconChevronUp, IconClose, IconRefresh, IconSearch } from '@douyinfe/semi-icons'; import { Button, Card, Empty, Input, RadioGroup, Select, SideSheet, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useDeferredValue, useEffect, useMemo, useState } from 'react'; @@ -16,14 +16,6 @@ import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; const DESKTOP_PAGE_SIZE = 50; const MOBILE_PAGE_SIZE = 20; const activeStatuses = new Set(['pending', 'confirmed_source_a', 'confirmed_source_b']); -const reviewStatuses = [ - { value: 'pending', label: '待处理' }, - { value: 'confirmed_source_a', label: '确认来源 A' }, - { value: 'confirmed_source_b', label: '确认来源 B' }, - { value: 'no_action', label: '无需处理' }, - { value: 'fixed', label: '已修复' } -]; - function statusLabel(status: string) { return { pending: '待处理', @@ -65,18 +57,125 @@ function ruleLabel(rule: string) { }[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 fmt(value?: string) { if (!value) return '—'; const parsed = new Date(value.replace(' ', 'T')); return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false }); } -function evidenceValue(value: unknown) { - if (value == null || value === '') return '—'; - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value); - return JSON.stringify(value, null, 2); -} - function ReconciliationTrend({ data, maxTrend, onClose }: { data?: ReconciliationSummary; maxTrend: number; @@ -124,12 +223,14 @@ function ReconciliationDetail({ issue, onClose, sheet = false }: { issue: Reconc } }); const requiresNote = status !== 'pending'; + const evidenceEntries = Object.entries(issue.evidence ?? {}); + const statusOptions = reviewStatusOptions(issue); return } + meta={} actions={sheet ? undefined :