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

@@ -273,8 +273,10 @@ test('renders mobile access vehicles as selectable Semi cards', async () => {
const governanceButton = screen.getByRole('button', { name: /接入治理/ });
fireEvent.click(governanceButton);
const governanceDialog = await screen.findByRole('dialog', { name: '接入治理配置' });
expect(document.querySelector('.v2-access-governance-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-access-governance-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(86dvh, 760px)' });
expect(document.querySelector('.v2-access-governance-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-config-sidesheet');
expect(document.querySelector('.v2-access-governance-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(88dvh, 780px)' });
expect(within(governanceDialog).getByText('接入治理工作台')).toBeInTheDocument();
expect(within(governanceDialog).getByRole('list', { name: '接入治理配置摘要' })).toBeInTheDocument();
fireEvent.click(within(governanceDialog).getByRole('button', { name: '关闭接入治理配置' }));
expect(governanceButton).toHaveAttribute('aria-expanded', 'false');
expect(document.body.style.overflow).not.toBe('hidden');

View File

@@ -1,5 +1,5 @@
import { IconChevronDown, IconChevronRight, IconClose, IconDownload, IconMore, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Collapse, Descriptions, Dropdown, Empty, Input, Select, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { Button, Card, CardGroup, Collapse, Descriptions, Dropdown, Empty, Input, Select, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, KeyboardEvent, memo, useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
@@ -15,6 +15,7 @@ import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { detailTriggerRow } from '../shared/detailTriggerRow';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
@@ -356,7 +357,6 @@ export default function AccessPage() {
}, [mobileLayout]);
const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN);
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
useSideSheetA11y(editable && governanceOpen, '.v2-access-governance-sidesheet', 'v2-access-governance', '接入治理配置', '关闭接入治理配置');
useSideSheetA11y(mobileFiltersOpen, '.v2-access-mobile-filter-sidesheet', 'v2-access-mobile-filters', '接入车辆筛选', '关闭接入车辆筛选');
const syncURL = (filters: Filters) => { const next = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { if (value) next.set(key, value); }); setSearchParams(next, { replace: true }); };
const apply = (next: Filters) => { setDraft(next); setCriteria(next); setOffset(0); setSelectedVIN(''); syncURL(next); };
@@ -505,28 +505,49 @@ export default function AccessPage() {
>
{selected ? <VehicleInspector key={selected.vin} row={selected} onClose={() => setSelectedVIN('')} sheet mobile={mobileLayout} /> : null}
</WorkspaceDetailSideSheet>
{editable ? <SideSheet
{editable ? <WorkspaceSideSheet
className="v2-access-governance-sidesheet"
variant="config"
visible={governanceOpen}
aria-label="接入治理配置"
ariaLabel="接入治理配置"
closeLabel="关闭接入治理配置"
placement={mobileLayout ? 'bottom' : 'right'}
width={mobileLayout ? undefined : 560}
height={mobileLayout ? 'min(86dvh, 760px)' : undefined}
title={<div className="v2-access-sheet-title"><strong></strong><span>线</span></div>}
height={mobileLayout ? 'min(88dvh, 780px)' : undefined}
title="接入治理工作台"
description="核对待绑定来源,统一维护协议在线判定阈值"
badge={(unresolvedQuery.data?.total ?? 0) > 0 ? `${unresolvedQuery.data?.total.toLocaleString('zh-CN')} 项待办` : '治理正常'}
badgeColor={(unresolvedQuery.data?.total ?? 0) > 0 ? 'orange' : 'green'}
summaryItems={[
{
label: '待绑定来源',
value: (unresolvedQuery.data?.total ?? 0).toLocaleString('zh-CN'),
detail: (unresolvedQuery.data?.total ?? 0) > 0 ? '需要核对权威 VIN' : '当前没有身份待办',
tone: (unresolvedQuery.data?.total ?? 0) > 0 ? 'warning' : 'success'
},
{
label: '阈值版本',
value: `v${thresholdQuery.data?.version ?? '—'}`,
detail: '在线与延迟统一口径',
tone: 'primary'
},
{
label: '主车辆口径',
value: (summary?.totalVehicles ?? 0).toLocaleString('zh-CN'),
detail: '仅统计权威主车辆',
tone: 'neutral'
}
]}
footerNote="阈值保存后即时生效;关闭工作台不会丢失已保存配置"
primaryAction={{ label: '完成', onClick: () => setGovernanceOpen(false) }}
onCancel={() => setGovernanceOpen(false)}
footer={<Button theme="solid" onClick={() => setGovernanceOpen(false)}></Button>}
>
{governanceOpen ? <div className="v2-access-governance">
<Card className="v2-access-governance-summary" bodyStyle={{ padding: 0 }}>
<div className={(unresolvedQuery.data?.total ?? 0) > 0 ? 'is-attention' : ''}><small></small><strong>{(unresolvedQuery.data?.total ?? 0).toLocaleString('zh-CN')}</strong><span>{(unresolvedQuery.data?.total ?? 0) > 0 ? '优先处理' : '暂无待办'}</span></div>
<div><small></small><strong>v{thresholdQuery.data?.version ?? '—'}</strong></div>
<div><small></small><strong>{(summary?.totalVehicles ?? 0).toLocaleString('zh-CN')}</strong></div>
</Card>
{unresolvedQuery.isError ? <InlineError message={unresolvedQuery.error instanceof Error ? unresolvedQuery.error.message : '待绑定身份读取失败'} onRetry={() => unresolvedQuery.refetch()} /> : null}
<IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} />
{thresholdQuery.isError ? <InlineError message={thresholdQuery.error instanceof Error ? thresholdQuery.error.message : '接入阈值读取失败'} onRetry={() => thresholdQuery.refetch()} /> : null}
<ThresholdSettings config={thresholdQuery.data} draft={thresholdDraft} editable saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} />
</div> : null}
</SideSheet> : null}
</WorkspaceSideSheet> : null}
</div>;
}

View File

@@ -92,14 +92,19 @@ test('renders reconciliation queue, loads evidence on demand and records review
expect(screen.getByRole('button', { name: '刷新结果' })).toHaveClass('v2-workspace-mobile-tool-button', 'is-muted');
expect(trendButton).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(trendButton);
expect(screen.getByText('近 30 天趋势').closest('.semi-card')).toHaveClass('v2-reconcile-trend');
expect(screen.getByText('近 30 天趋势').closest('.v2-workspace-panel-header')).toHaveClass('is-compact');
const trendDialog = await screen.findByRole('dialog', { name: '30 天差异趋势' });
expect(trendDialog.closest('.semi-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet', 'v2-reconcile-trend-sidesheet');
expect(within(trendDialog).getByText('趋势明细').closest('.semi-card')).toHaveClass('v2-reconcile-trend');
expect(within(trendDialog).getByText('趋势明细').closest('.v2-workspace-panel-header')).toHaveClass('is-compact');
expect(within(trendDialog).getByRole('list', { name: '30 天差异趋势摘要' })).toBeInTheDocument();
expect(trendButton).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(within(trendDialog).getByRole('button', { name: '关闭 30 天差异趋势' }));
expect(trendButton).toHaveAttribute('aria-expanded', 'false');
expect(document.querySelector('.v2-reconcile-table.semi-table-wrapper')).toBeInTheDocument();
expect(screen.getByLabelText('运维质量操作')).toHaveTextContent('上海时间');
expect(screen.getAllByText('2026-07-16 18:00:00').length).toBeGreaterThan(0);
expect(document.querySelector('.v2-platform-time.v2-ops-time')).toBeInTheDocument();
const issueTitles = await screen.findAllByText('多来源实时位置漂移');
expect((await screen.findAllByText('多来源实时位置漂移')).length).toBeGreaterThan(0);
expect(document.querySelector('.v2-reconcile-mobile-card.semi-card')).not.toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-table-wrap > table')).not.toBeInTheDocument();
const reconcileRow = screen.getByTestId('reconcile-row-issue-1');
@@ -108,18 +113,21 @@ test('renders reconciliation queue, loads evidence on demand and records review
fireEvent.keyDown(reconcileRow, { key: ' ' });
expect(await screen.findByText('规则证据')).toBeInTheDocument();
expect(reconcileRow).toHaveAttribute('aria-expanded', 'true');
const detailDialog = screen.getByRole('dialog', { name: '差异证据与处置' });
expect(detailDialog.closest('.semi-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet', 'v2-reconcile-detail-sidesheet');
expect(within(detailDialog).getByRole('list', { name: '差异证据与处置摘要' })).toBeInTheDocument();
expect(screen.getByText('规则证据').closest('.semi-card')).toHaveClass('v2-reconcile-evidence');
expect(screen.getByText('复核结论').closest('.semi-card')).toHaveClass('v2-reconcile-review');
expect(screen.getByText('处理履历').closest('.semi-card')).toHaveClass('v2-reconcile-actions');
expect(screen.getByText('规则证据').closest('.v2-workspace-panel-header')).toHaveClass('is-compact');
expect(document.querySelector('.v2-reconcile-detail-heading.v2-workspace-panel-header')).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-detail-heading.v2-workspace-panel-header')).not.toBeInTheDocument();
expect(screen.getByText('复核提示')).toBeInTheDocument();
expect(screen.getByText('坐标差距')).toBeInTheDocument();
expect(screen.getByText('distanceM')).toBeInTheDocument();
expect(screen.getByText('1,286 m')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭差异详情' }));
fireEvent.click(within(detailDialog).getByRole('button', { name: '关闭差异证据与处置' }));
expect(reconcileRow).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(issueTitles[0]);
fireEvent.click(reconcileRow);
expect(await screen.findByText('规则证据')).toBeInTheDocument();
fireEvent.click(screen.getByRole('radio', { name: '采信 GB32960' }));
fireEvent.change(screen.getByLabelText('说明(必填)'), { target: { value: '来源 A 原始报文可信' } });
@@ -152,10 +160,11 @@ test('renders only the compact mobile reconciliation surface and defers secondar
expect(await screen.findByRole('dialog', { name: '差异证据与处置' })).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-detail-sidesheet .v2-reconcile-detail.is-sheet')).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-reconcile-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(84dvh, 740px)' });
expect(await screen.findByRole('button', { name: '关闭差异详情' })).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-detail-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-reconcile-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(88dvh, 780px)' });
expect(await screen.findByRole('button', { name: '关闭差异证据与处置' })).toBeInTheDocument();
expect(mobileIssueAction).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: '关闭差异详情' }));
fireEvent.click(screen.getByRole('button', { name: '关闭差异证据与处置' }));
expect(mobileIssueAction).toHaveAttribute('aria-expanded', 'false');
const toolbar = document.querySelector('.v2-reconcile-toolbar');
@@ -166,14 +175,13 @@ test('renders only the compact mobile reconciliation surface and defers secondar
expect(screen.queryByText('近 30 天趋势')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '30 天趋势' }));
expect(await screen.findByRole('dialog', { name: '30 天差异趋势' })).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-trend-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-reconcile-trend-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(62dvh, 560px)' });
const trend = screen.getByText('近 30 天趋势').closest('.v2-reconcile-trend');
expect(document.querySelector('.v2-reconcile-trend-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-reconcile-trend-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(72dvh, 620px)' });
const trend = screen.getByText('趋势明细').closest('.v2-reconcile-trend');
expect(trend).toBeInTheDocument();
expect(screen.getByText(/最近运行/)).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-trend-toggle')).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: '收起趋势' }));
expect(screen.queryByText('近 30 天趋势')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭 30 天差异趋势' }));
expect(screen.queryByText('趋势明细')).not.toBeInTheDocument();
});
test('reconciles service identities with bound and identity-required vehicles', async () => {

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>
</>;
}

View File

@@ -237,6 +237,8 @@ describe('V2 production entry', () => {
expect(corePageSources.AccessPage).toContain('<Card className={`v2-access-inspector-v3');
expect(corePageSources.AccessPage).toContain('className="v2-access-detail-sidesheet"');
expect(corePageSources.AccessPage).toContain('className="v2-access-governance-sidesheet"');
expect(corePageSources.AccessPage).toContain('<WorkspaceSideSheet');
expect(corePageSources.AccessPage).toContain('variant="config"');
expect(corePageSources.AccessPage).toContain('className="v2-access-filter-card-v3"');
expect(corePageSources.AccessPage).not.toContain('<section className="v2-access-table-v3"');
expect(corePageSources.AccessPage).not.toContain('<aside className="v2-access-inspector-v3"');
@@ -479,8 +481,10 @@ describe('V2 production entry', () => {
expect(reconciliationSource).toContain('<Card className="v2-reconcile-kpi-card');
expect(reconciliationSource).toContain('v2-reconcile-trend');
expect(reconciliationSource).toContain('<Card className={`v2-reconcile-detail');
expect(reconciliationSource).toContain('<SideSheet');
expect(reconciliationSource).toContain('<WorkspaceSideSheet');
expect(reconciliationSource).toContain('variant="detail"');
expect(reconciliationSource).toContain('className="v2-reconcile-detail-sidesheet"');
expect(reconciliationSource).not.toContain('<SideSheet');
expect(reconciliationSource).toContain('<Card className="v2-reconcile-evidence"');
expect(reconciliationSource).toContain("from '../shared/WorkspacePanelHeader'");
expect(reconciliationSource).toContain('<WorkspacePanelHeader');

View File

@@ -21686,3 +21686,94 @@
padding: 10px;
}
}
/*
* Semi UI convergence: access governance and reconciliation evidence.
* Keep the queue/table stable while configuration, evidence and trends share
* the same identity, summary and scroll hierarchy.
*/
.v2-access-governance-sidesheet .v2-workspace-config-content {
padding: 10px;
}
.v2-access-governance-sidesheet .v2-access-governance {
align-content: start;
}
.v2-access-governance-sidesheet .v2-access-identity-queue-v3,
.v2-access-governance-sidesheet .v2-access-settings {
overflow: hidden;
border: 1px solid #dce5ef;
border-radius: 11px;
background: #fff;
box-shadow: 0 7px 22px rgba(31, 53, 80, .045);
}
.v2-reconcile-detail-sidesheet .v2-workspace-config-content {
padding: 0;
}
.v2-reconcile-detail-sidesheet .v2-workspace-config-summary > span:first-child strong,
.v2-reconcile-detail-sidesheet .v2-workspace-config-summary > span:nth-child(2) strong {
font-size: 13px;
}
.v2-reconcile-detail-sidesheet .v2-workspace-config-summary > span:last-child strong,
.v2-reconcile-detail-sidesheet .v2-workspace-config-summary > span:last-child em {
overflow: visible;
}
.v2-reconcile-detail-sidesheet .v2-reconcile-detail.is-sheet.semi-card {
min-height: 100%;
background: transparent;
}
.v2-reconcile-detail-sidesheet .v2-reconcile-detail.is-sheet > .semi-card-body {
background: transparent;
}
.v2-reconcile-detail-sidesheet .v2-reconcile-detail-body {
gap: 10px;
padding: 10px;
}
.v2-reconcile-trend-sidesheet .v2-workspace-config-content {
padding: 10px;
}
.v2-reconcile-trend-sidesheet .v2-workspace-config-content > #v2-reconcile-trend,
.v2-reconcile-trend-sidesheet .v2-reconcile-trend.semi-card {
min-height: 100%;
}
.v2-reconcile-trend-sidesheet .v2-reconcile-trend.semi-card {
border: 1px solid #dce5ef;
border-radius: 11px;
background: #fff;
box-shadow: 0 7px 22px rgba(31, 53, 80, .05);
}
@media (max-width: 680px) {
.v2-access-governance-sidesheet .v2-workspace-config-content,
.v2-reconcile-detail-sidesheet .v2-reconcile-detail-body,
.v2-reconcile-trend-sidesheet .v2-workspace-config-content {
padding: 8px;
}
.v2-reconcile-detail-sidesheet .v2-workspace-config-summary > span:first-child strong,
.v2-reconcile-detail-sidesheet .v2-workspace-config-summary > span:nth-child(2) strong {
font-size: 11px;
}
}
@media (max-width: 900px) and (max-height: 480px) and (orientation: landscape) {
.v2-access-governance-sidesheet .v2-access-governance {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) auto;
}
.v2-access-governance-sidesheet .v2-access-identity-queue-v3,
.v2-access-governance-sidesheet .v2-access-settings {
grid-column: 1;
}
}