refine Semi UI operations review flow
This commit is contained in:
@@ -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 原始报文可信'
|
||||
}));
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -10917,3 +10917,530 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user