feat(platform): harden telemetry pipeline and unify Semi UI workspaces

This commit is contained in:
lingniu
2026-07-18 00:26:36 +08:00
parent 65b4e4f055
commit 159c80b0ae
136 changed files with 21616 additions and 1785 deletions

View File

@@ -1,12 +1,20 @@
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { 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';
import { api } from '../../api/client';
import type { ReconciliationIssue } from '../../api/types';
import { InlineError } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { TablePagination } from '../shared/TablePagination';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { detailTriggerRow } from '../shared/detailTriggerRow';
import { QUERY_MEMORY } from '../queryPolicy';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
const PAGE_SIZE = 50;
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: '待处理' },
@@ -31,6 +39,16 @@ 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: '重复车牌',
@@ -59,7 +77,7 @@ function evidenceValue(value: unknown) {
return JSON.stringify(value, null, 2);
}
function ReconciliationDetail({ issue, onClose }: { issue: ReconciliationIssue; onClose: () => void }) {
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 ?? '');
@@ -78,11 +96,14 @@ function ReconciliationDetail({ issue, onClose }: { issue: ReconciliationIssue;
}
});
const requiresNote = status !== 'pending';
return <aside className="v2-reconcile-detail" aria-label="差异证据与处置">
<header>
<div><span className={`is-${issue.severity}`}>{severityLabel(issue.severity)}</span><strong>{issue.title}</strong><small>{ruleLabel(issue.ruleCode)}</small></div>
<button type="button" onClick={onClose} aria-label="关闭差异详情">×</button>
</header>
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} />}
actions={sheet ? undefined : <Button theme="borderless" type="tertiary" icon={<IconClose />} onClick={onClose} aria-label="关闭差异详情" />}
/>
<div className="v2-reconcile-detail-body">
<section className="v2-reconcile-identity">
<div><small></small><strong>{issue.plate || '未登记车牌'}</strong><span>{issue.vin || '非单车差异'}</span></div>
@@ -90,26 +111,32 @@ function ReconciliationDetail({ issue, onClose }: { issue: ReconciliationIssue;
<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-evidence">
<header><strong></strong><span></span></header>
<Card className="v2-reconcile-evidence" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
variant="compact"
title="规则证据"
description="保留来源值、时间与影响对象;未知来源不自动判对错"
/>
<dl>{Object.entries(issue.evidence ?? {}).map(([key, value]) => <div key={key}><dt>{key}</dt><dd><pre>{evidenceValue(value)}</pre></dd></div>)}</dl>
</section>
<section className="v2-reconcile-review">
<header><strong></strong><span> v{issue.version}</span></header>
<label><span></span><select value={status} onChange={(event) => setStatus(event.target.value)}>{reviewStatuses.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>
<label><span>{requiresNote ? '(必填)' : '(可选)'}</span><textarea value={note} maxLength={500} onChange={(event) => setNote(event.target.value)} placeholder="记录核对来源、原始值、责任人或修复结果" /></label>
<button type="button" disabled={save.isPending || (requiresNote && !note.trim())} onClick={() => save.mutate()}>{save.isPending ? '正在保存…' : '保存复核结论'}</button>
</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>
<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}
</section>
<section className="v2-reconcile-actions">
<header><strong></strong><span>{issue.actions?.length ?? 0} </span></header>
</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} · {fmt(action.createdAt)}</span></div></li>)}</ol> : <p></p>}
</section>
</Card>
</div>
</aside>;
</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');
@@ -117,7 +144,11 @@ export default function ReconciliationCenter() {
const [ruleCode, setRuleCode] = useState('all');
const [offset, setOffset] = useState(0);
const [selectedID, setSelectedID] = useState('');
useEffect(() => setOffset(0), [deferredKeyword, ruleCode, severity, status]);
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const [trendExpanded, setTrendExpanded] = useState(false);
const closeDetail = () => setSelectedID('');
useSideSheetA11y(mobileLayout && Boolean(selectedID), '.v2-reconcile-detail-sidesheet', 'v2-reconcile-detail-sheet', '差异证据与处置', '关闭差异详情');
useEffect(() => setOffset(0), [deferredKeyword, mobileLayout, ruleCode, severity, status]);
const summary = useQuery({
queryKey: ['reconciliation-summary', 30],
@@ -126,9 +157,9 @@ export default function ReconciliationCenter() {
gcTime: QUERY_MEMORY.summaryGcTime
});
const issues = useQuery({
queryKey: ['reconciliation-issues', deferredKeyword, ruleCode, severity, status, offset],
queryKey: ['reconciliation-issues', deferredKeyword, ruleCode, severity, status, pageSize, offset],
queryFn: ({ signal }) => api.reconciliationIssues({
keyword: deferredKeyword, ruleCode, severity, status, limit: PAGE_SIZE, offset
keyword: deferredKeyword, ruleCode, severity, status, limit: pageSize, offset
}, signal),
staleTime: 20_000,
gcTime: QUERY_MEMORY.highVolumeGcTime
@@ -149,60 +180,142 @@ export default function ReconciliationCenter() {
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)}${activeFilterCount ? ` · 另 ${activeFilterCount}` : ''}`;
const selectedIssue = detail.data ?? issueRows.find((item) => item.id === selectedID);
const detailPanel = selectedID && detail.isPending
? <Card className={`v2-reconcile-detail${mobileLayout ? ' 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${mobileLayout ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}><InlineError message={detail.error.message} onRetry={() => detail.refetch()} /></Card>
: detail.data
? <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: '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: '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} /> }
];
return <section className="v2-reconcile-center">
<header className="v2-reconcile-heading">
<div><small></small><strong></strong><span></span></div>
<button type="button" onClick={refresh} disabled={summary.isFetching || issues.isFetching}><IconRefresh /></button>
</header>
return <><Card className="v2-reconcile-center" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
className="v2-reconcile-heading"
title="数据差异中心"
description="自动发现、相同问题去重、恢复后闭环;复核人员只确认证据,不凭空修改原始数据。"
meta="每日自动对账"
actions={<>
<Button
className="v2-reconcile-trend-open"
theme="borderless"
type="tertiary"
icon={trendExpanded ? <IconChevronUp /> : <IconChevronDown />}
aria-label="30 天趋势"
aria-expanded={trendExpanded}
aria-controls="v2-reconcile-trend"
onClick={() => setTrendExpanded((value) => !value)}
>30 </Button>
<Button theme="light" icon={<IconRefresh />} loading={summary.isFetching || issues.isFetching} onClick={refresh}></Button>
</>}
/>
{summary.isError ? <InlineError message={summary.error.message} onRetry={() => summary.refetch()} /> : null}
<div className="v2-reconcile-kpis">
<article className="is-active"><small></small><strong>{data?.active.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article><small></small><strong>{data?.pending.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article><small></small><strong>{data?.confirmed.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article><small></small><strong>{data?.recovered.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article className={data?.overSla ? 'is-overdue' : ''}><small> 24 </small><strong>{data?.overSla.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<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" 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>
</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>
<strong>{data?.overSla.toLocaleString('zh-CN') ?? '—'}</strong>
<p>{data?.overSla ? '优先复核高等级差异' : '当前没有超时差异'}</p>
</Card>
</div>
<div className="v2-reconcile-layout">
<div className={`v2-reconcile-layout${trendExpanded ? ' is-trend-open' : ''}`}>
<div className="v2-reconcile-main">
<div className="v2-reconcile-toolbar">
<label className="v2-reconcile-search"><IconSearch /><input aria-label="搜索差异车辆或规则" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="车牌、VIN、标题或说明" /></label>
<select aria-label="筛选差异状态" value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="active"></option><option value="pending"></option><option value="confirmed_source_a"> A</option><option value="confirmed_source_b"> B</option><option value="recovered"></option><option value="fixed"></option><option value="no_action"></option><option value="all"></option>
</select>
<select aria-label="筛选严重程度" value={severity} onChange={(event) => setSeverity(event.target.value)}>
<option value="all"></option><option value="critical"></option><option value="major"></option><option value="minor"></option>
</select>
<select aria-label="筛选差异规则" value={ruleCode} onChange={(event) => setRuleCode(event.target.value)}>
<option value="all"></option>{data?.byRule.map((item) => <option key={item.name} value={item.name}>{ruleLabel(item.name)} · {item.count}</option>)}
</select>
<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">
<table className="v2-reconcile-table"><thead><tr><th></th><th> / </th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>{page?.items.map((item) => <tr key={item.id} role="button" tabIndex={0} className={selectedID === item.id ? 'is-selected' : ''} onClick={() => setSelectedID(item.id)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedID(item.id); }}>
<td><span className={`v2-reconcile-severity is-${item.severity}`}>{severityLabel(item.severity)}</span></td>
<td><strong>{item.title}</strong><span>{ruleLabel(item.ruleCode)} · {item.occurrenceCount.toLocaleString('zh-CN')} </span></td>
<td><strong>{item.plate || '非单车差异'}</strong><span>{item.vin || '平台级口径'}</span></td>
<td><strong>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</strong><span>{item.category}</span></td>
<td><strong>{fmt(item.lastSeenAt)}</strong><span> {fmt(item.firstSeenAt)}</span></td>
<td><span className={`v2-reconcile-status is-${item.status}`}>{statusLabel(item.status)}</span></td>
</tr>)}</tbody>
</table>
{issues.isPending ? <p className="v2-reconcile-empty"></p> : null}
{!issues.isPending && !page?.items.length ? <p className="v2-reconcile-empty"></p> : null}
{!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: () => setSelectedID(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={() => setSelectedID(item.id)}>
<span className="v2-reconcile-mobile-content"><header><span><ReconciliationSeverityTag severity={item.severity} /><ReconciliationStatusTag status={item.status} /></span><small>{fmt(item.lastSeenAt)}</small></header><strong>{item.title}</strong><p>{ruleLabel(item.ruleCode)} · {item.occurrenceCount.toLocaleString('zh-CN')} </p><dl><div><dt></dt><dd>{item.plate || '非单车差异'}</dd></div><div><dt></dt><dd>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</dd></div></dl><footer><IconChevronRight /></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"><span> {(page?.total ?? 0).toLocaleString('zh-CN')} · {activeCount} </span><div><button type="button" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}></button><button type="button" disabled={offset + PAGE_SIZE >= (page?.total ?? 0)} onClick={() => setOffset(offset + PAGE_SIZE)}></button></div></footer>
<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>
<aside className="v2-reconcile-trend">
<header><strong> 30 </strong><span> {fmt(data?.lastRunAt)}</span></header>
<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>
{trendExpanded ? <div id="v2-reconcile-trend" className="v2-reconcile-trend-slot"><Card className="v2-reconcile-trend" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
variant="compact"
title="近 30 天趋势"
meta={`最近运行 ${fmt(data?.lastRunAt)}`}
actions={<Button
className="v2-reconcile-trend-toggle"
theme="borderless"
type="tertiary"
size="small"
icon={<IconChevronUp />}
aria-label="收起趋势"
aria-expanded
aria-controls="v2-reconcile-trend"
onClick={() => setTrendExpanded(false)}
></Button>}
/>
<><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>
</aside>
{selectedID && detail.isPending ? <aside className="v2-reconcile-detail"><div className="v2-reconcile-empty">正在读取证据…</div></aside> : null}
{selectedID && detail.isError ? <aside className="v2-reconcile-detail"><InlineError message={detail.error.message} onRetry={() => detail.refetch()} /></aside> : null}
{detail.data ? <ReconciliationDetail issue={detail.data} onClose={() => setSelectedID('')} /> : null}
<footer><span><i className="is-active" />存量</span><span><i className="is-new" />新增</span><span><i className="is-recovered" />恢复</span></footer></>
</Card></div> : null}
{!mobileLayout ? detailPanel : null}
</div>
</section>;
</Card>
<SideSheet
className="v2-reconcile-detail-sidesheet"
visible={mobileLayout && Boolean(selectedID)}
width="100%"
aria-label="差异证据与处置"
title={<div className="v2-reconcile-sheet-title"><strong>差异证据与处置</strong><span>{selectedIssue ? `${selectedIssue.plate || '平台级差异'} · ${ruleLabel(selectedIssue.ruleCode)}` : '加载规则证据与处置履历'}</span></div>}
onCancel={closeDetail}
>
{mobileLayout ? detailPanel : null}
</SideSheet>
</>;
}