feat: unify governance evidence workbenches
This commit is contained in:
@@ -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>
|
||||
</>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user