Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/ReconciliationCenter.tsx
2026-07-20 03:22:30 +08:00

524 lines
29 KiB
TypeScript

import { IconAlertTriangle, IconChevronDown, IconChevronRight, IconChevronUp, IconClose, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { Button, Card, Empty, Input, RadioGroup, Select, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import type { ReconciliationIssue, ReconciliationSummary } from '../../api/types';
import { InlineError } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { PlatformTime } from '../shared/PlatformTime';
import { TablePagination } from '../shared/TablePagination';
import { WorkspaceMetricRail } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { detailTriggerRow } from '../shared/detailTriggerRow';
import { QUERY_MEMORY } from '../queryPolicy';
import { useMobileLayout } from '../hooks/useMobileLayout';
const DESKTOP_PAGE_SIZE = 50;
const MOBILE_PAGE_SIZE = 20;
const activeStatuses = new Set(['pending', 'confirmed_source_a', 'confirmed_source_b']);
function statusLabel(status: string) {
return {
pending: '待处理',
confirmed_source_a: '确认来源 A',
confirmed_source_b: '确认来源 B',
no_action: '无需处理',
fixed: '已修复',
recovered: '已恢复'
}[status] ?? status;
}
function severityLabel(severity: string) {
return { critical: '严重', major: '重要', minor: '一般' }[severity] ?? severity;
}
function ReconciliationSeverityTag({ severity }: { severity: string }) {
const color = severity === 'critical' ? 'red' : severity === 'major' ? 'orange' : 'grey';
return <Tag className={`v2-reconcile-severity is-${severity}`} color={color} type="light" size="small">{severityLabel(severity)}</Tag>;
}
function ReconciliationStatusTag({ status }: { status: string }) {
const color = status === 'pending' ? 'orange' : status === 'confirmed_source_a' || status === 'confirmed_source_b' ? 'blue' : status === 'fixed' || status === 'recovered' ? 'green' : 'grey';
return <Tag className={`v2-reconcile-status is-${status}`} color={color} type="light" size="small">{statusLabel(status)}</Tag>;
}
function ruleLabel(rule: string) {
return {
DUPLICATE_PLATE: '重复车牌',
DUPLICATE_PHONE: '重复终端',
UNBOUND_SOURCE: '来源未绑定',
SOURCE_MISSING: '主车无来源',
POSITION_DRIFT: '位置漂移',
MILEAGE_REVERSE: '里程倒退',
MILEAGE_JUMP: '里程跳变',
MILEAGE_SOURCE_DIVERGENCE: '里程来源差异',
FLEET_COUNT_MISMATCH: '车辆总数不一致',
BUSINESS_SCOPE_UNBOUND: '业务车辆未绑定',
AUTH_SCOPE_BUSINESS_MISMATCH: '授权与业务范围不一致'
}[rule] ?? rule;
}
function categoryLabel(category: string) {
return {
identity: '身份',
source: '数据来源',
location: '实时位置',
mileage: '里程',
fleet: '车队口径',
business: '业务范围'
}[category] ?? category;
}
const evidenceLabels: Record<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 ReconciliationTrend({ data, maxTrend }: {
data?: ReconciliationSummary;
maxTrend: number;
}) {
return <Card className="v2-reconcile-trend" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
variant="compact"
title="趋势明细"
description="最近 14 次自动对账运行的存量、新增与恢复"
meta={<span> <PlatformTime className="v2-ops-time" value={data?.lastRunAt} sourceZone="utc" /></span>}
/>
<div>{data?.trend.slice(-14).map((item) => <article key={item.date}><time>{item.date.slice(5)}</time><div title={`存量 ${item.active},新增 ${item.new},恢复 ${item.recovered}`}><i className="is-active" style={{ width: `${Math.max(2, item.active / maxTrend * 100)}%` }} /><i className="is-new" style={{ width: `${Math.max(0, item.new / maxTrend * 100)}%` }} /><i className="is-recovered" style={{ width: `${Math.max(0, item.recovered / maxTrend * 100)}%` }} /></div><b>{item.active}</b></article>)}</div>
{!data?.trend.length ? <p>完成首次每日检测后显示趋势。</p> : null}
<footer><span><i className="is-active" />存量</span><span><i className="is-new" />新增</span><span><i className="is-recovered" />恢复</span></footer>
</Card>;
}
function ReconciliationDetail({ issue, onClose, sheet = false }: { issue: ReconciliationIssue; onClose: () => void; sheet?: boolean }) {
const queryClient = useQueryClient();
const [status, setStatus] = useState(issue.status === 'recovered' ? 'pending' : issue.status);
const [note, setNote] = useState(issue.resolutionNote ?? '');
useEffect(() => {
setStatus(issue.status === 'recovered' ? 'pending' : issue.status);
setNote(issue.resolutionNote ?? '');
}, [issue.id, issue.resolutionNote, issue.status]);
const save = useMutation({
mutationFn: () => api.updateReconciliationIssue(issue.id, { version: issue.version, status, note: note.trim() }),
onSuccess: async (updated) => {
queryClient.setQueryData(['reconciliation-detail', issue.id], updated);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['reconciliation-summary'] }),
queryClient.invalidateQueries({ queryKey: ['reconciliation-issues'] })
]);
}
});
const requiresNote = status !== 'pending';
const evidenceEntries = Object.entries(issue.evidence ?? {});
const statusOptions = reviewStatusOptions(issue);
return <Card className={`v2-reconcile-detail${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }} aria-label="差异证据与处置">
{!sheet ? <WorkspacePanelHeader
className="v2-reconcile-detail-heading"
title={issue.title}
description={ruleLabel(issue.ruleCode)}
meta={<span className="v2-reconcile-detail-status"><ReconciliationSeverityTag severity={issue.severity} /><ReconciliationStatusTag status={issue.status} /></span>}
actions={<Button theme="borderless" type="tertiary" icon={<IconClose />} onClick={onClose} aria-label="关闭差异详情" />}
/> : null}
<div className="v2-reconcile-detail-body">
{!sheet ? <section className="v2-reconcile-identity">
<div><small>车辆</small><strong>{issue.plate || '未登记车牌'}</strong><span>{issue.vin || '非单车差异'}</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><PlatformTime className="v2-ops-time" value={issue.lastSeenAt} sourceZone="utc" /></strong><span>首次 <PlatformTime className="v2-ops-time" value={issue.firstSeenAt} sourceZone="utc" /></span></div>
</section> : null}
<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="中文口径优先,保留原始字段名便于技术追溯"
meta={`${evidenceEntries.length} `}
/>
<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="复核结论" 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>
{save.isError ? <p role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</p> : null}
</Card>
<Card className="v2-reconcile-actions" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader variant="compact" title="处理履历" meta={`${issue.actions?.length ?? 0} `} />
{issue.actions?.length ? <ol>{issue.actions.map((action) => <li key={action.id}><i /><div><strong>{statusLabel(action.toStatus)}</strong><p>{action.note || action.action}</p><span>{action.actor} · <PlatformTime className="v2-ops-time" value={action.createdAt} sourceZone="utc" /></span></div></li>)}</ol> : <p>暂无处理记录。</p>}
</Card>
</div>
</Card>;
}
export default function ReconciliationCenter() {
const mobileLayout = useMobileLayout();
const pageSize = mobileLayout ? MOBILE_PAGE_SIZE : DESKTOP_PAGE_SIZE;
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword.trim());
const [status, setStatus] = useState('active');
const [severity, setSeverity] = useState('all');
const [ruleCode, setRuleCode] = useState('all');
const [offset, setOffset] = useState(0);
const [selectedID, setSelectedID] = useState('');
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const [trendExpanded, setTrendExpanded] = useState(false);
const closeDetail = () => setSelectedID('');
const openDetail = (id: string) => {
setTrendExpanded(false);
setSelectedID(id);
};
const toggleTrend = () => {
setSelectedID('');
setTrendExpanded((value) => !value);
};
useEffect(() => setOffset(0), [deferredKeyword, mobileLayout, ruleCode, severity, status]);
const summary = useQuery({
queryKey: ['reconciliation-summary', 30],
queryFn: ({ signal }) => api.reconciliationSummary(30, signal),
staleTime: 60_000,
gcTime: QUERY_MEMORY.summaryGcTime
});
const issues = useQuery({
queryKey: ['reconciliation-issues', deferredKeyword, ruleCode, severity, status, pageSize, offset],
queryFn: ({ signal }) => api.reconciliationIssues({
keyword: deferredKeyword, ruleCode, severity, status, limit: pageSize, offset
}, signal),
staleTime: 20_000,
gcTime: QUERY_MEMORY.highVolumeGcTime
});
const detail = useQuery({
queryKey: ['reconciliation-detail', selectedID],
queryFn: ({ signal }) => api.reconciliationIssue(selectedID, signal),
enabled: Boolean(selectedID),
staleTime: 10_000,
gcTime: QUERY_MEMORY.optionGcTime
});
const maxTrend = useMemo(() => {
let result = 1;
for (const item of summary.data?.trend ?? []) result = Math.max(result, item.active, item.new, item.recovered);
return result;
}, [summary.data?.trend]);
const refresh = () => Promise.all([summary.refetch(), issues.refetch(), selectedID ? detail.refetch() : Promise.resolve()]);
const data = summary.data;
const page = issues.data;
const activeCount = page?.items.filter((item) => activeStatuses.has(item.status)).length ?? 0;
const currentPage = Math.floor(offset / pageSize) + 1;
const totalPages = Math.max(1, Math.ceil((page?.total ?? 0) / pageSize));
const issueRows = page?.items ?? [];
const activeFilterCount = Number(Boolean(deferredKeyword)) + Number(severity !== 'all') + Number(ruleCode !== 'all');
const filterSummary = `${status === 'active' ? '活跃差异' : statusLabel(status)} · ${(page?.total ?? 0).toLocaleString('zh-CN')} ${activeFilterCount ? ` · 另 ${activeFilterCount}` : ''}`;
const selectedIssue = detail.data ?? issueRows.find((item) => item.id === selectedID);
const detailPanel = selectedID && detail.isPending
? <Card className="v2-reconcile-detail is-sheet" bodyStyle={{ padding: 0 }}><div className="v2-reconcile-detail-loading" role="status"><Spin size="middle" tip="正在读取差异证据…" /></div></Card>
: selectedID && detail.isError
? <Card className="v2-reconcile-detail is-sheet" bodyStyle={{ padding: 0 }}><InlineError message={detail.error.message} onRetry={() => detail.refetch()} /></Card>
: detail.data
? <ReconciliationDetail issue={detail.data} onClose={closeDetail} sheet />
: null;
const columns = [
{ 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: 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><PlatformTime className="v2-ops-time" value={item.lastSeenAt} sourceZone="utc" /></strong><small>首次 <PlatformTime className="v2-ops-time" value={item.firstSeenAt} sourceZone="utc" /></small></span> },
{ title: '状态', dataIndex: 'status', width: 112, render: (value: string) => <ReconciliationStatusTag status={value} /> }
];
return <><Card className="v2-reconcile-center" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
className="v2-reconcile-heading"
eyebrow="自动对账"
tone="health"
title="质量问题队列"
description="自动发现并合并重复问题;复核人员基于原始证据形成结论,不直接改写数据。"
meta="每日自动对账"
actions={<>
<Button
className="v2-reconcile-trend-open v2-workspace-mobile-tool-button is-muted"
theme="borderless"
type="tertiary"
icon={trendExpanded ? <IconChevronUp /> : <IconChevronDown />}
aria-label="30 天趋势"
aria-expanded={trendExpanded}
aria-controls="v2-reconcile-trend"
onClick={toggleTrend}
>30 天趋势</Button>
<Button className="v2-workspace-mobile-tool-button is-muted" theme="light" icon={<IconRefresh />} aria-label="刷新结果" loading={summary.isFetching || issues.isFetching} onClick={refresh}>刷新结果</Button>
</>}
/>
{summary.isError ? <InlineError message={summary.error.message} onRetry={() => summary.refetch()} /> : null}
<WorkspaceMetricRail
variant="queue"
className="v2-reconcile-metric-rail"
ariaLabel="差异处置队列概览"
items={[
{
label: '待复核',
value: data?.pending.toLocaleString('zh-CN') ?? '—',
note: ` ${data?.active.toLocaleString('zh-CN') ?? '—'} `,
tone: 'primary'
},
{
label: 'SLA 超时',
value: data?.overSla.toLocaleString('zh-CN') ?? '—',
note: data?.overSla ? '超过 24 小时' : '当前无超时',
tone: data?.overSla ? 'danger' : 'success'
},
{
label: '已确认来源',
value: data?.confirmed.toLocaleString('zh-CN') ?? '—',
note: '保留活跃差异',
emphasis: 'secondary'
},
{
label: '已自动恢复',
value: data?.recovered.toLocaleString('zh-CN') ?? '—',
note: '规则不再命中',
tone: 'success',
emphasis: 'secondary'
}
]}
context={<span aria-live="polite"><small>当前筛选</small><strong>{(page?.total ?? 0).toLocaleString('zh-CN')}</strong><em>按严重程度、最近发现时间排序</em></span>}
/>
<div className="v2-reconcile-layout">
<div className="v2-reconcile-main">
<MobileFilterToggle
title="筛选差异"
summary={filterSummary}
expanded={!filtersCollapsed}
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
/>
<div className={`v2-reconcile-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`}>
<Input className="v2-reconcile-search" prefix={<IconSearch />} aria-label="搜索差异车辆或规则" value={keyword} onChange={setKeyword} placeholder="车牌、VIN、标题或说明" />
<span className="v2-sr-only" id="reconcile-status-filter-label">筛选差异状态</span>
<Select aria-labelledby="reconcile-status-filter-label" value={status} onChange={(value) => setStatus(String(value))} optionList={[
{ value: 'active', label: '活跃差异' }, { value: 'pending', label: '待处理' },
{ value: 'confirmed_source_a', label: '确认来源 A' }, { value: 'confirmed_source_b', label: '确认来源 B' },
{ value: 'recovered', label: '已恢复' }, { value: 'fixed', label: '已修复' },
{ value: 'no_action', label: '无需处理' }, { value: 'all', label: '全部状态' }
]} />
<span className="v2-sr-only" id="reconcile-severity-filter-label">筛选严重程度</span>
<Select aria-labelledby="reconcile-severity-filter-label" value={severity} onChange={(value) => setSeverity(String(value))} optionList={[
{ value: 'all', label: '全部等级' }, { value: 'critical', label: '严重' },
{ value: 'major', label: '重要' }, { value: 'minor', label: '一般' }
]} />
<span className="v2-sr-only" id="reconcile-rule-filter-label">筛选差异规则</span>
<Select aria-labelledby="reconcile-rule-filter-label" value={ruleCode} onChange={(value) => setRuleCode(String(value))} optionList={[
{ value: 'all', label: '全部规则' },
...(data?.byRule.map((item) => ({ value: item.name, label: `${ruleLabel(item.name)} · ${item.count}` })) ?? [])
]} />
</div>
{issues.isError ? <InlineError message={issues.error.message} onRetry={() => issues.refetch()} /> : null}
<div
className="v2-reconcile-table-wrap is-scroll-region"
role="region"
aria-label="差异队列,可上下滚动"
tabIndex={0}
>
{!mobileLayout ? <Table className="v2-reconcile-table" columns={columns} dataSource={issueRows} rowKey="id" pagination={false} scroll={{ x: 934 }} onRow={(item) => item ? detailTriggerRow({
className: selectedID === item.id ? 'is-selected' : '',
expanded: selectedID === item.id,
label: ` ${item.plate || item.title} `,
testId: `reconcile-row-${item.id}`,
onOpen: () => openDetail(item.id)
}) : ({})} /> : <div className="v2-reconcile-mobile-list">{issueRows.map((item) => <Card key={item.id} className={`v2-reconcile-mobile-card${selectedID === item.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}>
<Button theme="borderless" type="tertiary" className="v2-reconcile-mobile-action" aria-pressed={selectedID === item.id} aria-expanded={selectedID === item.id} aria-label={` ${item.plate || item.title} `} onClick={() => openDetail(item.id)}>
<span className="v2-reconcile-mobile-content">
<header><strong>{item.plate || '平台级差异'}</strong><span><ReconciliationSeverityTag severity={item.severity} /><ReconciliationStatusTag status={item.status} /></span></header>
<p>{item.title}</p>
<span className="v2-reconcile-mobile-meta"><span>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</span><PlatformTime className="v2-ops-time" value={item.lastSeenAt} sourceZone="utc" /></span>
<footer><span>{ruleLabel(item.ruleCode)} · 命中 {item.occurrenceCount.toLocaleString('zh-CN')} 次</span><span>证据与处置<IconChevronRight /></span></footer>
</span>
</Button>
</Card>)}</div>}
{issues.isPending ? <div className="v2-reconcile-loading" role="status"><Spin size="middle" tip="正在读取差异队列…" /></div> : null}
{!issues.isPending && !issueRows.length ? <Empty className="v2-reconcile-empty" title="当前筛选条件没有差异" description="调整状态、等级、规则或搜索条件后重试。" /> : null}
</div>
<footer className="v2-reconcile-pagination"><TablePagination page={currentPage} totalPages={totalPages} info={` ${(page?.total ?? 0).toLocaleString('zh-CN')} · ${activeCount} `} onPageChange={(next) => setOffset((next - 1) * pageSize)} /></footer>
</div>
</div>
</Card>
<WorkspaceSideSheet
className="v2-reconcile-detail-sidesheet"
variant="detail"
visible={Boolean(selectedID)}
placement={mobileLayout ? 'bottom' : 'right'}
width={mobileLayout ? undefined : 680}
height={mobileLayout ? 'min(88dvh, 780px)' : undefined}
ariaLabel="差异证据与处置"
closeLabel="关闭差异证据与处置"
title={selectedIssue?.title ?? '差异证据与处置'}
description={selectedIssue ? `${ruleLabel(selectedIssue.ruleCode)} · ` : '正在加载规则证据与处置履历'}
badge={selectedIssue ? `${severityLabel(selectedIssue.severity)} · ${statusLabel(selectedIssue.status)}` : '读取中'}
badgeColor={selectedIssue?.severity === 'critical' ? 'red' : selectedIssue?.severity === 'major' ? 'orange' : 'grey'}
summaryItems={selectedIssue ? [
{
label: '车辆',
value: selectedIssue.plate || '平台级差异',
detail: selectedIssue.vin || '非单车口径',
tone: 'primary'
},
{
label: '证据来源',
value: [selectedIssue.protocolA, selectedIssue.protocolB].filter(Boolean).join(' ↔ ') || '平台口径',
detail: ` ${selectedIssue.occurrenceCount.toLocaleString('zh-CN')} `,
tone: selectedIssue.severity === 'critical' ? 'danger' : 'warning'
},
{
label: '最近发现',
value: <PlatformTime className="v2-ops-time" value={selectedIssue.lastSeenAt} sourceZone="utc" />,
detail: <span>首次 <PlatformTime className="v2-ops-time" value={selectedIssue.firstSeenAt} sourceZone="utc" /></span>,
tone: 'neutral'
}
] : []}
onCancel={closeDetail}
>
{detailPanel}
</WorkspaceSideSheet>
<WorkspaceSideSheet
className="v2-reconcile-trend-sidesheet"
variant="detail"
visible={trendExpanded}
placement={mobileLayout ? 'bottom' : 'right'}
width={mobileLayout ? undefined : 520}
height={mobileLayout ? 'min(72dvh, 620px)' : undefined}
ariaLabel="30 天差异趋势"
closeLabel="关闭 30 天差异趋势"
title="30 天差异趋势"
description="查看自动对账的差异存量、新增与恢复走势"
badge="每日自动对账"
badgeColor="blue"
summaryItems={[
{
label: '活跃差异',
value: data?.active.toLocaleString('zh-CN') ?? '—',
detail: `${data?.pending.toLocaleString('zh-CN') ?? '—'} `,
tone: 'warning'
},
{
label: 'SLA 超时',
value: data?.overSla.toLocaleString('zh-CN') ?? '—',
detail: '超过 24 小时',
tone: data?.overSla ? 'danger' : 'success'
},
{
label: '已自动恢复',
value: data?.recovered.toLocaleString('zh-CN') ?? '—',
detail: '规则已不再命中',
tone: 'success'
}
]}
onCancel={() => setTrendExpanded(false)}
>
{trendExpanded ? <div id="v2-reconcile-trend"><ReconciliationTrend data={data} maxTrend={maxTrend} /></div> : null}
</WorkspaceSideSheet>
</>;
}