feat: unify governance evidence workbenches

This commit is contained in:
lingniu
2026-07-19 06:51:23 +08:00
parent f2fe5404d9
commit 3b5a66b687
6 changed files with 249 additions and 79 deletions

View File

@@ -1,5 +1,5 @@
import { IconAlertTriangle, IconChevronDown, IconChevronRight, IconChevronUp, IconClose, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { Button, Card, Empty, Input, RadioGroup, Select, SideSheet, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { Button, Card, Empty, Input, RadioGroup, Select, 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';
@@ -9,10 +9,10 @@ import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { PlatformTime } from '../shared/PlatformTime';
import { TablePagination } from '../shared/TablePagination';
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';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
const DESKTOP_PAGE_SIZE = 50;
const MOBILE_PAGE_SIZE = 20;
@@ -171,27 +171,16 @@ function reviewGuidance(issue: ReconciliationIssue) {
}[issue.ruleCode] ?? '先核对原始证据、影响对象和时间范围,再选择结论并留下可追溯说明。';
}
function ReconciliationTrend({ data, maxTrend, onClose }: {
function ReconciliationTrend({ data, maxTrend }: {
data?: ReconciliationSummary;
maxTrend: number;
onClose: () => void;
}) {
return <Card className="v2-reconcile-trend" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
variant="compact"
title="近 30 天趋势"
title="趋势明细"
description="最近 14 次自动对账运行的存量、新增与恢复"
meta={<span> <PlatformTime className="v2-ops-time" value={data?.lastRunAt} sourceZone="utc" /></span>}
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={onClose}
></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}
@@ -221,19 +210,19 @@ function ReconciliationDetail({ issue, onClose, sheet = false }: { issue: Reconc
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
{!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={sheet ? undefined : <Button theme="borderless" type="tertiary" icon={<IconClose />} onClick={onClose} aria-label="关闭差异详情" />}
/>
actions={<Button theme="borderless" type="tertiary" icon={<IconClose />} onClick={onClose} aria-label="关闭差异详情" />}
/> : null}
<div className="v2-reconcile-detail-body">
<section className="v2-reconcile-identity">
{!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>
</section> : null}
<section className="v2-reconcile-decision-guide">
<IconAlertTriangle />
<div><strong>复核提示</strong><p>{reviewGuidance(issue)}</p></div>
@@ -279,8 +268,14 @@ export default function ReconciliationCenter() {
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', '差异证据与处置', '关闭差异详情');
useSideSheetA11y(mobileLayout && trendExpanded, '.v2-reconcile-trend-sidesheet', 'v2-reconcile-trend-sheet', '30 天差异趋势', '关闭 30 天差异趋势');
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({
@@ -320,11 +315,11 @@ export default function ReconciliationCenter() {
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>
? <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${mobileLayout ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}><InlineError message={detail.error.message} onRetry={() => detail.refetch()} /></Card>
? <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={mobileLayout} />
? <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> },
@@ -349,7 +344,7 @@ export default function ReconciliationCenter() {
aria-label="30 天趋势"
aria-expanded={trendExpanded}
aria-controls="v2-reconcile-trend"
onClick={() => setTrendExpanded((value) => !value)}
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>
</>}
@@ -363,7 +358,7 @@ export default function ReconciliationCenter() {
<Card className="v2-reconcile-kpi-card" bodyStyle={{ padding: 0 }}><small>已自动恢复</small><strong>{data?.recovered.toLocaleString('zh-CN') ?? '—'}</strong><span>规则已不再命中</span></Card>
</div>
</div>
<div className={`v2-reconcile-layout${trendExpanded ? ' is-trend-open' : ''}`}>
<div className="v2-reconcile-layout">
<div className="v2-reconcile-main">
<MobileFilterToggle
title="筛选差异"
@@ -408,9 +403,9 @@ export default function ReconciliationCenter() {
expanded: selectedID === item.id,
label: ` ${item.plate || item.title} `,
testId: `reconcile-row-${item.id}`,
onOpen: () => setSelectedID(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={() => setSelectedID(item.id)}>
<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>
@@ -424,32 +419,81 @@ export default function ReconciliationCenter() {
</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>
{trendExpanded && !mobileLayout ? <div id="v2-reconcile-trend" className="v2-reconcile-trend-slot"><ReconciliationTrend data={data} maxTrend={maxTrend} onClose={() => setTrendExpanded(false)} /></div> : null}
{!mobileLayout ? detailPanel : null}
</div>
</Card>
<SideSheet
<WorkspaceSideSheet
className="v2-reconcile-detail-sidesheet"
visible={mobileLayout && Boolean(selectedID)}
placement="bottom"
height="min(84dvh, 740px)"
aria-label="差异证据与处置"
title={<div className="v2-reconcile-sheet-title"><strong>差异证据与处置</strong><span>{selectedIssue ? `${selectedIssue.plate || '平台级差异'} · ${ruleLabel(selectedIssue.ruleCode)}` : '加载规则证据与处置履历'}</span></div>}
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}
>
{mobileLayout ? detailPanel : null}
</SideSheet>
<SideSheet
{detailPanel}
</WorkspaceSideSheet>
<WorkspaceSideSheet
className="v2-reconcile-trend-sidesheet"
visible={mobileLayout && trendExpanded}
placement="bottom"
height="min(62dvh, 560px)"
aria-label="30 天差异趋势"
variant="detail"
visible={trendExpanded}
placement={mobileLayout ? 'bottom' : 'right'}
width={mobileLayout ? undefined : 520}
height={mobileLayout ? 'min(72dvh, 620px)' : undefined}
ariaLabel="30 天差异趋势"
closeLabel="关闭 30 天差异趋势"
title="30 天差异趋势"
footer={null}
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)}
>
{mobileLayout && trendExpanded ? <div id="v2-reconcile-trend"><ReconciliationTrend data={data} maxTrend={maxTrend} onClose={() => setTrendExpanded(false)} /></div> : null}
</SideSheet>
{trendExpanded ? <div id="v2-reconcile-trend"><ReconciliationTrend data={data} maxTrend={maxTrend} /></div> : null}
</WorkspaceSideSheet>
</>;
}