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 } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>); 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.getByRole('heading', { name: '运行与数据质量', level: 5 })).toBeInTheDocument();
expect(screen.getByLabelText('运维质量操作')).toHaveClass('v2-workspace-command-bar', 'v2-ops-command-bar'); expect(screen.getByLabelText('运维质量操作')).toHaveClass('v2-workspace-command-bar', 'v2-ops-command-bar');
const navigation = screen.getByRole('navigation', { name: '运维质量视图' }); 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(screen.getByRole('tabpanel', { name: '差异处置' })).toHaveAttribute('tabindex', '0');
expect(document.querySelector('.v2-ops-page')).toHaveClass('is-reconciliation'); expect(document.querySelector('.v2-ops-page')).toHaveClass('is-reconciliation');
expect(screen.queryByText('先选择一辆车')).not.toBeInTheDocument(); expect(screen.queryByText('先选择一辆车')).not.toBeInTheDocument();
expect(screen.getByText('数据差异中心').closest('.semi-card')).toHaveClass('v2-reconcile-center'); expect(screen.getByText('质量问题队列').closest('.semi-card')).toHaveClass('v2-reconcile-center');
expect(screen.getByText('数据差异中心').closest('.v2-workspace-panel-header')).toHaveClass('v2-reconcile-heading'); expect(screen.getByText('质量问题队列').closest('.v2-workspace-panel-header')).toHaveClass('v2-reconcile-heading');
expect(document.querySelector('.v2-reconcile-kpi-card small')?.textContent).toBe('活跃差异'); expect(document.querySelector('.v2-reconcile-kpi-card small')?.textContent).toBe('待复核');
expect(document.querySelectorAll('.v2-reconcile-kpi-card')).toHaveLength(4); expect(document.querySelectorAll('.v2-reconcile-kpi-card')).toHaveLength(4);
expect(screen.getByText('超过 24 小时').closest('.semi-card')).toHaveClass('v2-reconcile-sla'); expect(screen.getByText('超过 24 小时').closest('.semi-card')).toHaveClass('v2-reconcile-sla');
expect(screen.queryByText('近 30 天趋势')).not.toBeInTheDocument(); 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('.semi-card')).toHaveClass('v2-reconcile-actions');
expect(screen.getByText('规则证据').closest('.v2-workspace-panel-header')).toHaveClass('is-compact'); expect(screen.getByText('规则证据').closest('.v2-workspace-panel-header')).toHaveClass('is-compact');
expect(document.querySelector('.v2-reconcile-detail-heading.v2-workspace-panel-header')).toBeInTheDocument(); 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: '关闭差异详情' })); fireEvent.click(screen.getByRole('button', { name: '关闭差异详情' }));
expect(reconcileRow).toHaveAttribute('aria-expanded', 'false'); expect(reconcileRow).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(issueTitles[0]); fireEvent.click(issueTitles[0]);
expect(await screen.findByText('规则证据')).toBeInTheDocument(); 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.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', { await waitFor(() => expect(mocks.updateReconciliationIssue).toHaveBeenCalledWith('issue-1', {
version: 1, status: 'confirmed_source_a', note: '来源 A 原始报文可信' version: 1, status: 'confirmed_source_a', note: '来源 A 原始报文可信'
})); }));

View File

@@ -353,7 +353,7 @@ export default function OperationsPage() {
className="v2-ops-command-bar" className="v2-ops-command-bar"
ariaLabel="运维质量操作" ariaLabel="运维质量操作"
title="运行与数据质量" title="运行与数据质量"
description="先定位单车来源问题,再核对服务协议全局健康;所有结论来自服务端证据。" description="集中处理自动对账问题,按需诊断单车来源,并核对服务协议健康;所有结论均可追溯。"
status={data?.runtime.dataMode === 'production' ? '生产模式' : '状态待确认'} status={data?.runtime.dataMode === 'production' ? '生产模式' : '状态待确认'}
statusColor={data?.runtime.dataMode === 'production' ? 'green' : 'orange'} statusColor={data?.runtime.dataMode === 'production' ? 'green' : 'orange'}
meta={<Typography.Text type="tertiary">{data?.runtime.platformRelease ? `版本 ${data.runtime.platformRelease}` : '正在读取运行版本'}</Typography.Text>} 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 { Button, Card, Empty, Input, RadioGroup, Select, SideSheet, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useDeferredValue, useEffect, useMemo, useState } from 'react'; import { useDeferredValue, useEffect, useMemo, useState } from 'react';
@@ -16,14 +16,6 @@ import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
const DESKTOP_PAGE_SIZE = 50; const DESKTOP_PAGE_SIZE = 50;
const MOBILE_PAGE_SIZE = 20; const MOBILE_PAGE_SIZE = 20;
const activeStatuses = new Set(['pending', 'confirmed_source_a', 'confirmed_source_b']); 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) { function statusLabel(status: string) {
return { return {
pending: '待处理', pending: '待处理',
@@ -65,18 +57,125 @@ function ruleLabel(rule: string) {
}[rule] ?? rule; }[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) { function fmt(value?: string) {
if (!value) return '—'; if (!value) return '—';
const parsed = new Date(value.replace(' ', 'T')); const parsed = new Date(value.replace(' ', 'T'));
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false }); 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 }: { function ReconciliationTrend({ data, maxTrend, onClose }: {
data?: ReconciliationSummary; data?: ReconciliationSummary;
maxTrend: number; maxTrend: number;
@@ -124,12 +223,14 @@ function ReconciliationDetail({ issue, onClose, sheet = false }: { issue: Reconc
} }
}); });
const requiresNote = status !== 'pending'; 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="差异证据与处置"> return <Card className={`v2-reconcile-detail${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }} aria-label="差异证据与处置">
<WorkspacePanelHeader <WorkspacePanelHeader
className="v2-reconcile-detail-heading" className="v2-reconcile-detail-heading"
title={issue.title} title={issue.title}
description={ruleLabel(issue.ruleCode)} 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="关闭差异详情" />} actions={sheet ? undefined : <Button theme="borderless" type="tertiary" icon={<IconClose />} onClick={onClose} aria-label="关闭差异详情" />}
/> />
<div className="v2-reconcile-detail-body"> <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>{[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> <div><small>时间</small><strong>{fmt(issue.lastSeenAt)}</strong><span>首次 {fmt(issue.firstSeenAt)}</span></div>
</section> </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 }}> <Card className="v2-reconcile-evidence" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader <WorkspacePanelHeader
variant="compact" variant="compact"
title="规则证据" 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>
<Card className="v2-reconcile-review" bodyStyle={{ padding: 0 }}> <Card className="v2-reconcile-review" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader variant="compact" title="复核结论" meta={` v${issue.version}`} /> <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={reviewStatuses} onChange={(event) => setStatus(String(event.target.value))} /></label> <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> <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} {save.isError ? <p role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</p> : null}
</Card> </Card>
<Card className="v2-reconcile-actions" bodyStyle={{ padding: 0 }}> <Card className="v2-reconcile-actions" bodyStyle={{ padding: 0 }}>
@@ -223,10 +332,9 @@ export default function ReconciliationCenter() {
? <ReconciliationDetail issue={detail.data} onClose={closeDetail} sheet={mobileLayout} /> ? <ReconciliationDetail issue={detail.data} onClose={closeDetail} sheet={mobileLayout} />
: null; : null;
const columns = [ const columns = [
{ title: '等级', dataIndex: 'severity', width: 76, render: (value: string) => <ReconciliationSeverityTag severity={value} /> }, { 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: '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: 'plate', width: 176, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{item.plate || '非单车差异'}</strong><small>{item.vin || '平台级口径'}</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: '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} /> } { 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 }}> return <><Card className="v2-reconcile-center" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader <WorkspacePanelHeader
className="v2-reconcile-heading" className="v2-reconcile-heading"
title="数据差异中心" title="质量问题队列"
description="自动发现、相同问题去重、恢复后闭环;复核人员只确认证据,不凭空修改原始数据。" description="自动发现并合并重复问题;复核人员基于原始证据形成结论,不直接改写数据。"
meta="每日自动对账" meta="每日自动对账"
actions={<> actions={<>
<Button <Button
@@ -254,10 +362,10 @@ export default function ReconciliationCenter() {
{summary.isError ? <InlineError message={summary.error.message} onRetry={() => summary.refetch()} /> : null} {summary.isError ? <InlineError message={summary.error.message} onRetry={() => summary.refetch()} /> : null}
<div className="v2-reconcile-overview"> <div className="v2-reconcile-overview">
<div className="v2-reconcile-kpis"> <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 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?.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?.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> </div>
<Card className={`v2-reconcile-sla${data?.overSla ? ' is-overdue' : ''}`} bodyStyle={{ padding: 0 }}> <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> <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}` })) ?? []) ...(data?.byRule.map((item) => ({ value: item.name, label: `${ruleLabel(item.name)} · ${item.count}` })) ?? [])
]} /> ]} />
</div> </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} {issues.isError ? <InlineError message={issues.error.message} onRetry={() => issues.refetch()} /> : null}
<div <div
className="v2-reconcile-table-wrap is-scroll-region" className="v2-reconcile-table-wrap is-scroll-region"

View File

@@ -10917,3 +10917,530 @@
font-size: 14px; font-size: 14px;
} }
} }
/*
* Semi UI operations review flow.
* Keep the queue readable at a glance, translate protocol evidence into
* business language, and make the review decision the clear end of the flow.
*/
.v2-reconcile-overview .v2-reconcile-kpi-card.is-priority {
background: linear-gradient(145deg, #edf5ff 0%, #f7faff 100%);
box-shadow: inset 0 -3px #1f6feb;
}
.v2-reconcile-overview .v2-reconcile-kpi-card.is-priority small,
.v2-reconcile-overview .v2-reconcile-kpi-card.is-priority strong {
color: #155fc5;
}
.v2-reconcile-result-bar {
display: flex;
min-height: 34px;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid #e7edf4;
background: #fbfcfe;
padding: 0 12px;
color: #8491a2;
font-size: 10px;
}
.v2-reconcile-result-bar > span {
min-width: 0;
}
.v2-reconcile-result-bar strong {
margin-right: 3px;
color: #2f5f9b;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.v2-reconcile-table .semi-table {
min-width: 908px;
}
.v2-reconcile-issue-cell > span {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
}
.v2-reconcile-issue-cell > span > strong {
min-width: 0;
flex: 1;
}
.v2-reconcile-issue-cell .v2-reconcile-severity.semi-tag {
min-height: 22px;
flex: 0 0 auto;
border-radius: 6px;
padding-inline: 7px;
}
.v2-reconcile-detail.semi-card:not(.is-sheet) {
width: min(620px, 58vw);
border-left-color: #c8d6e7;
box-shadow: -18px 0 48px rgba(21, 42, 69, .18);
}
.v2-reconcile-detail-status {
display: inline-flex;
align-items: center;
gap: 5px;
}
.v2-reconcile-detail-body {
padding: 14px 16px 24px;
scrollbar-color: #aebdce transparent;
scrollbar-width: thin;
}
.v2-reconcile-identity {
grid-template-columns: minmax(0, 1.15fr) minmax(0, .9fr) minmax(0, 1.05fr);
border-color: #dce5ef;
border-radius: 10px;
background: #fff;
}
.v2-reconcile-identity div {
padding: 12px 13px;
}
.v2-reconcile-identity small {
color: #7b899b;
font-weight: 650;
}
.v2-reconcile-identity strong {
margin: 5px 0;
color: #263c54;
font-size: 13px;
}
.v2-reconcile-decision-guide {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
align-items: start;
gap: 9px;
margin-top: 12px;
border: 1px solid #f1dfb7;
border-radius: 10px;
background: linear-gradient(135deg, #fffaf0 0%, #fffdf8 100%);
padding: 11px 12px;
color: #82581a;
}
.v2-reconcile-decision-guide > .semi-icon {
margin-top: 1px;
color: #d08b22;
font-size: 18px;
}
.v2-reconcile-decision-guide strong {
color: #6f4d1d;
font-size: 11px;
}
.v2-reconcile-decision-guide p {
margin: 3px 0 0;
color: #806a4a;
font-size: 10px;
line-height: 1.55;
}
.v2-reconcile-summary {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 9px;
border: 1px solid #dce9fa;
background: #f3f8ff;
padding: 10px 12px;
}
.v2-reconcile-summary > strong {
color: #2c64a4;
font-size: 10px;
white-space: nowrap;
}
.v2-reconcile-summary > span {
color: #4e6279;
font-size: 11px;
line-height: 1.5;
}
.v2-reconcile-evidence.semi-card,
.v2-reconcile-review.semi-card,
.v2-reconcile-actions.semi-card {
border-color: #dce5ef;
border-radius: 10px;
}
.v2-reconcile-evidence dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
background: #f7f9fc;
padding: 10px;
}
.v2-reconcile-evidence dl > div {
display: grid;
min-width: 0;
grid-template-columns: minmax(112px, .82fr) minmax(0, 1.18fr);
overflow: hidden;
border: 1px solid #e1e8f0;
border-radius: 8px;
background: #fff;
}
.v2-reconcile-evidence dl > div.is-object {
grid-column: 1 / -1;
grid-template-columns: 132px minmax(0, 1fr);
}
.v2-reconcile-evidence dt {
display: grid;
align-content: center;
gap: 3px;
border-right: 1px solid #e7edf4;
background: #fbfcfe;
padding: 9px 10px;
}
.v2-reconcile-evidence dt > strong {
color: #53677e;
font-size: 10px;
}
.v2-reconcile-evidence dt > code {
overflow: hidden;
color: #96a2b1;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 8px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-reconcile-evidence dd {
display: flex;
min-width: 0;
align-items: center;
margin: 0;
padding: 9px 10px;
}
.v2-reconcile-evidence-value {
overflow-wrap: anywhere;
color: #243e5a;
font-size: 11px;
font-weight: 720;
font-variant-numeric: tabular-nums;
}
.v2-reconcile-evidence-object {
display: grid;
width: 100%;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px 12px;
}
.v2-reconcile-evidence-object > span {
display: grid;
min-width: 0;
gap: 3px;
}
.v2-reconcile-evidence-object small {
color: #8a97a8;
font-size: 8px;
}
.v2-reconcile-evidence-object strong {
overflow: hidden;
color: #334d68;
font-size: 10px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-reconcile-review > .semi-card-body {
padding-bottom: 12px !important;
}
.v2-reconcile-review > .semi-card-body > label {
gap: 7px;
margin: 10px 12px;
color: #53677e;
font-size: 10px;
font-weight: 650;
}
.v2-reconcile-review > .semi-card-body > label > .semi-radioGroup {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.v2-reconcile-review .semi-radio-addon-buttonRadio {
height: 36px;
border-radius: 7px;
font-size: 10px;
}
.v2-reconcile-review > .semi-card-body > .semi-button {
min-width: 166px;
height: 38px;
margin: 3px 12px 0;
border-radius: 8px;
font-weight: 680;
}
@media (min-width: 681px) {
.v2-reconcile-heading.v2-workspace-panel-header {
min-height: 64px;
}
.v2-reconcile-overview .v2-reconcile-kpi-card > .semi-card-body,
.v2-reconcile-sla > .semi-card-body {
min-height: 82px;
}
.v2-reconcile-overview .v2-reconcile-kpi-card small {
font-size: 10px;
font-weight: 650;
}
.v2-reconcile-overview .v2-reconcile-kpi-card strong {
margin-block: 7px 4px;
font-size: 24px;
}
.v2-reconcile-overview .v2-reconcile-kpi-card span {
font-size: 9px;
}
.v2-reconcile-table .semi-table-thead > .semi-table-row > .semi-table-row-head {
height: 40px;
color: #697a90;
font-size: 10px;
}
.v2-reconcile-table .semi-table-tbody > .semi-table-row > .semi-table-row-cell {
height: 64px;
}
.v2-reconcile-cell strong {
font-size: 11px;
}
.v2-reconcile-cell small {
font-size: 9px;
}
}
@media (max-width: 680px) {
.v2-reconcile-heading.v2-workspace-panel-header {
min-height: 50px;
padding: 5px 7px 5px 10px;
}
.v2-reconcile-heading > .v2-workspace-panel-copy {
display: flex;
}
.v2-reconcile-heading > .v2-workspace-panel-copy > h5.semi-typography {
display: block;
font-size: 12px;
}
.v2-reconcile-heading > .v2-workspace-panel-copy > .semi-typography,
.v2-reconcile-heading > .v2-workspace-panel-actions > .v2-workspace-panel-meta {
display: none;
}
.v2-reconcile-heading > .v2-workspace-panel-actions {
display: flex;
width: auto;
max-width: none;
flex: 0 0 auto;
grid-template-columns: none;
align-items: center;
justify-content: flex-end;
gap: 5px;
margin-left: auto;
}
.v2-reconcile-heading > .v2-workspace-panel-actions > .semi-button {
width: 38px !important;
min-width: 38px !important;
max-width: 38px;
flex: 0 0 38px;
min-height: 38px;
padding: 0;
font-size: 0;
}
.v2-reconcile-heading > .v2-workspace-panel-actions > .semi-button .semi-button-content-right {
display: none;
}
.v2-reconcile-heading > .v2-workspace-panel-actions > .semi-button .semi-icon {
font-size: 15px;
}
.v2-reconcile-overview .v2-reconcile-kpi-card.is-priority {
background: #eff6ff;
box-shadow: inset 0 -3px #1f6feb;
}
.v2-reconcile-result-bar {
display: none;
}
.v2-reconcile-detail.is-sheet .v2-reconcile-detail-heading {
min-height: 56px;
padding: 8px 11px;
}
.v2-reconcile-detail.is-sheet .v2-reconcile-detail-heading .v2-workspace-panel-copy > h5.semi-typography {
font-size: 13px;
}
.v2-reconcile-detail.is-sheet .v2-reconcile-detail-heading .v2-workspace-panel-copy > .semi-typography {
font-size: 9px;
}
.v2-reconcile-detail-body {
padding: 10px 10px 18px;
}
.v2-reconcile-identity {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.v2-reconcile-identity > div:first-child {
grid-column: 1 / -1;
}
.v2-reconcile-identity > div + div {
border-top: 1px solid #e5ebf2;
border-left: 0;
}
.v2-reconcile-identity > div:last-child {
border-left: 1px solid #e5ebf2;
}
.v2-reconcile-identity div {
padding: 9px 10px;
}
.v2-reconcile-identity strong {
margin-block: 4px;
font-size: 11px;
}
.v2-reconcile-identity small,
.v2-reconcile-identity span {
font-size: 8px;
}
.v2-reconcile-decision-guide {
grid-template-columns: 20px minmax(0, 1fr);
gap: 7px;
margin-top: 9px;
padding: 9px 10px;
}
.v2-reconcile-decision-guide > .semi-icon {
font-size: 15px;
}
.v2-reconcile-decision-guide strong,
.v2-reconcile-decision-guide p {
font-size: 9px;
}
.v2-reconcile-summary {
gap: 7px;
margin-block: 9px;
padding: 9px 10px;
}
.v2-reconcile-summary > strong,
.v2-reconcile-summary > span {
font-size: 9px;
}
.v2-reconcile-evidence.semi-card,
.v2-reconcile-review.semi-card,
.v2-reconcile-actions.semi-card {
margin-top: 9px;
}
.v2-reconcile-evidence dl {
gap: 6px;
padding: 7px;
}
.v2-reconcile-evidence dl > div,
.v2-reconcile-evidence dl > div.is-object {
grid-template-columns: minmax(0, 1fr);
}
.v2-reconcile-evidence dl > div.is-object {
grid-column: 1 / -1;
}
.v2-reconcile-evidence dt {
gap: 2px;
border-right: 0;
border-bottom: 1px solid #edf1f5;
padding: 7px 8px 5px;
}
.v2-reconcile-evidence dt > strong {
font-size: 9px;
}
.v2-reconcile-evidence dt > code {
font-size: 7px;
}
.v2-reconcile-evidence dd {
min-height: 34px;
padding: 6px 8px 8px;
}
.v2-reconcile-evidence-value {
font-size: 10px;
}
.v2-reconcile-evidence-object {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px 9px;
}
.v2-reconcile-review > .semi-card-body > .v2-workspace-panel-header,
.v2-reconcile-evidence > .semi-card-body > .v2-workspace-panel-header {
min-height: 48px;
}
.v2-reconcile-review > .semi-card-body > label {
margin-inline: 9px;
}
.v2-reconcile-review > .semi-card-body > .semi-button {
width: calc(100% - 18px);
min-height: 42px;
margin-inline: 9px;
justify-content: center;
}
}