refine Semi UI operations review flow

This commit is contained in:
lingniu
2026-07-18 09:35:16 +08:00
parent 71f334d259
commit 04d5992252
4 changed files with 680 additions and 38 deletions

View File

@@ -58,7 +58,7 @@ test('renders reconciliation queue, loads evidence on demand and records review
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
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 原始报文可信'
}));

View File

@@ -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={<Typography.Text type="tertiary">{data?.runtime.platformRelease ? `版本 ${data.runtime.platformRelease}` : '正在读取运行版本'}</Typography.Text>}

View File

@@ -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<string, string> = {
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 <div className="v2-reconcile-evidence-object">
{Object.entries(value as Record<string, unknown>).map(([key, nested]) => <span key={key}>
<small>{evidenceLabel(key)}</small>
<strong>{evidenceScalar(key, nested)}</strong>
</span>)}
</div>;
}
return <strong className="v2-reconcile-evidence-value">{evidenceScalar(name, value)}</strong>;
}
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 <Card className={`v2-reconcile-detail${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }} aria-label="差异证据与处置">
<WorkspacePanelHeader
className="v2-reconcile-detail-heading"
title={issue.title}
description={ruleLabel(issue.ruleCode)}
meta={<ReconciliationSeverityTag severity={issue.severity} />}
meta={<span className="v2-reconcile-detail-status"><ReconciliationSeverityTag severity={issue.severity} /><ReconciliationStatusTag status={issue.status} /></span>}
actions={sheet ? undefined : <Button theme="borderless" type="tertiary" icon={<IconClose />} onClick={onClose} aria-label="关闭差异详情" />}
/>
<div className="v2-reconcile-detail-body">
@@ -138,20 +239,28 @@ function ReconciliationDetail({ issue, onClose, sheet = false }: { issue: Reconc
<div><small>来源</small><strong>{[issue.protocolA, issue.protocolB].filter(Boolean).join(' ↔ ') || '平台口径'}</strong><span>累计发现 {issue.occurrenceCount.toLocaleString('zh-CN')} 次</span></div>
<div><small>时间</small><strong>{fmt(issue.lastSeenAt)}</strong><span>首次 {fmt(issue.firstSeenAt)}</span></div>
</section>
<p className="v2-reconcile-summary">{issue.summary}</p>
<section className="v2-reconcile-decision-guide">
<IconAlertTriangle />
<div><strong>复核提示</strong><p>{reviewGuidance(issue)}</p></div>
</section>
<p className="v2-reconcile-summary"><strong>命中说明</strong><span>{issue.summary}</span></p>
<Card className="v2-reconcile-evidence" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
variant="compact"
title="规则证据"
description="保留来源值、时间与影响对象;未知来源不自动判对错"
description="中文口径优先,保留原始字段名便于技术追溯"
meta={`${evidenceEntries.length} `}
/>
<dl>{Object.entries(issue.evidence ?? {}).map(([key, value]) => <div key={key}><dt>{key}</dt><dd><pre>{evidenceValue(value)}</pre></dd></div>)}</dl>
<dl>{evidenceEntries.map(([key, value]) => <div className={value && typeof value === 'object' && !Array.isArray(value) ? 'is-object' : undefined} key={key}>
<dt><strong>{evidenceLabel(key)}</strong>{evidenceLabel(key) !== key ? <code>{key}</code> : null}</dt>
<dd><EvidenceValue name={key} value={value} /></dd>
</div>)}</dl>
</Card>
<Card className="v2-reconcile-review" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader variant="compact" title="复核结论" meta={` v${issue.version}`} />
<label><span id="reconcile-review-status-label">处置状态</span><RadioGroup aria-labelledby="reconcile-review-status-label" type="button" buttonSize="small" value={status} options={reviewStatuses} onChange={(event) => setStatus(String(event.target.value))} /></label>
<WorkspacePanelHeader variant="compact" title="复核结论" description="结论会写入处理履历,不改写原始证据" meta={` v${issue.version}`} />
<label><span id="reconcile-review-status-label">选择结论</span><RadioGroup aria-labelledby="reconcile-review-status-label" type="button" buttonSize="small" value={status} options={statusOptions} onChange={(event) => setStatus(String(event.target.value))} /></label>
<label><span>说明{requiresNote ? '(必填)' : '(可选)'}</span><TextArea aria-label={`${requiresNote ? '(必填)' : '(可选)'}`} value={note} maxCount={500} autosize={{ minRows: 3, maxRows: 6 }} onChange={setNote} placeholder="记录核对来源、原始值、责任人或修复结果" /></label>
<Button theme="solid" disabled={save.isPending || (requiresNote && !note.trim())} loading={save.isPending} onClick={() => save.mutate()}>保存复核结论</Button>
<Button theme="solid" disabled={save.isPending || (requiresNote && !note.trim())} loading={save.isPending} onClick={() => save.mutate()}>保存结论并记录履历</Button>
{save.isError ? <p role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</p> : null}
</Card>
<Card className="v2-reconcile-actions" bodyStyle={{ padding: 0 }}>
@@ -223,10 +332,9 @@ export default function ReconciliationCenter() {
? <ReconciliationDetail issue={detail.data} onClose={closeDetail} sheet={mobileLayout} />
: null;
const columns = [
{ title: '等级', dataIndex: 'severity', width: 76, render: (value: string) => <ReconciliationSeverityTag severity={value} /> },
{ title: '差异 / 规则', dataIndex: 'title', width: 230, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{item.title}</strong><small>{ruleLabel(item.ruleCode)} · 命中 {item.occurrenceCount.toLocaleString('zh-CN')} 次</small></span> },
{ title: '问题 / 等级', dataIndex: 'title', width: 286, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell v2-reconcile-issue-cell"><span><strong>{item.title}</strong><ReconciliationSeverityTag severity={item.severity} /></span><small>{ruleLabel(item.ruleCode)} · 命中 {item.occurrenceCount.toLocaleString('zh-CN')} 次</small></span> },
{ title: '车辆', dataIndex: 'plate', width: 176, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{item.plate || '非单车差异'}</strong><small>{item.vin || '平台级口径'}</small></span> },
{ title: '来源', dataIndex: 'protocolA', width: 150, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</strong><small>{item.category}</small></span> },
{ title: '证据来源', dataIndex: 'protocolA', width: 164, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</strong><small>{categoryLabel(item.category)}</small></span> },
{ title: '最近发现', dataIndex: 'lastSeenAt', width: 190, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{fmt(item.lastSeenAt)}</strong><small>首次 {fmt(item.firstSeenAt)}</small></span> },
{ title: '状态', dataIndex: 'status', width: 112, render: (value: string) => <ReconciliationStatusTag status={value} /> }
];
@@ -234,8 +342,8 @@ export default function ReconciliationCenter() {
return <><Card className="v2-reconcile-center" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
className="v2-reconcile-heading"
title="数据差异中心"
description="自动发现、相同问题去重、恢复后闭环;复核人员只确认证据,不凭空修改原始数据。"
title="质量问题队列"
description="自动发现并合并重复问题;复核人员基于原始证据形成结论,不直接改写数据。"
meta="每日自动对账"
actions={<>
<Button
@@ -254,10 +362,10 @@ export default function ReconciliationCenter() {
{summary.isError ? <InlineError message={summary.error.message} onRetry={() => summary.refetch()} /> : null}
<div className="v2-reconcile-overview">
<div className="v2-reconcile-kpis">
<Card className="v2-reconcile-kpi-card is-active" bodyStyle={{ padding: 0 }}><small>活跃差异</small><strong>{data?.active.toLocaleString('zh-CN') ?? '—'}</strong><span>待处理与已确认来源</span></Card>
<Card className="v2-reconcile-kpi-card" bodyStyle={{ padding: 0 }}><small>待复核</small><strong>{data?.pending.toLocaleString('zh-CN') ?? '—'}</strong><span>尚未形成结论</span></Card>
<Card className="v2-reconcile-kpi-card is-priority" bodyStyle={{ padding: 0 }}><small>待复核</small><strong>{data?.pending.toLocaleString('zh-CN') ?? '—'}</strong><span>尚未形成结论</span></Card>
<Card className="v2-reconcile-kpi-card" bodyStyle={{ padding: 0 }}><small>活跃问题</small><strong>{data?.active.toLocaleString('zh-CN') ?? '—'}</strong><span>待复核与已确认来源</span></Card>
<Card className="v2-reconcile-kpi-card" bodyStyle={{ padding: 0 }}><small>已确认来源</small><strong>{data?.confirmed.toLocaleString('zh-CN') ?? '—'}</strong><span>保留为活跃差异</span></Card>
<Card className="v2-reconcile-kpi-card" bodyStyle={{ padding: 0 }}><small>自动恢复</small><strong>{data?.recovered.toLocaleString('zh-CN') ?? '—'}</strong><span>检测不再命中</span></Card>
<Card className="v2-reconcile-kpi-card" bodyStyle={{ padding: 0 }}><small>自动恢复</small><strong>{data?.recovered.toLocaleString('zh-CN') ?? '—'}</strong><span>规则已不再命中</span></Card>
</div>
<Card className={`v2-reconcile-sla${data?.overSla ? ' is-overdue' : ''}`} bodyStyle={{ padding: 0 }}>
<span><Tag color={data?.overSla ? 'red' : 'green'} type="light" size="small">SLA</Tag><small>超过 24 小时</small></span>
@@ -294,6 +402,10 @@ export default function ReconciliationCenter() {
...(data?.byRule.map((item) => ({ value: item.name, label: `${ruleLabel(item.name)} · ${item.count}` })) ?? [])
]} />
</div>
<div className="v2-reconcile-result-bar" aria-live="polite">
<span><strong>{(page?.total ?? 0).toLocaleString('zh-CN')}</strong> 个符合条件的问题</span>
<span>按严重程度、最近发现时间排序</span>
</div>
{issues.isError ? <InlineError message={issues.error.message} onRetry={() => issues.refetch()} /> : null}
<div
className="v2-reconcile-table-wrap is-scroll-region"