feat(platform): harden telemetry pipeline and unify Semi UI workspaces
This commit is contained in:
@@ -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>
|
||||
</>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user