refine Semi UI operations workspace

This commit is contained in:
lingniu
2026-07-18 04:43:06 +08:00
parent 866dfdaf5f
commit 800d956159
4 changed files with 444 additions and 25 deletions

View File

@@ -63,6 +63,7 @@ test('renders reconciliation queue, loads evidence on demand and records review
expect(screen.getByLabelText('运维质量操作')).toHaveClass('v2-workspace-command-bar', 'v2-ops-command-bar');
expect(screen.getByRole('tab', { name: '差异处置', selected: true })).toHaveClass('semi-button');
expect(screen.getByRole('tabpanel', { name: '差异处置' })).toBeInTheDocument();
expect(document.querySelector('.v2-ops-page')).toHaveClass('is-reconciliation');
expect(screen.queryByText('先选择一辆车')).not.toBeInTheDocument();
expect(screen.getByText('数据差异中心').closest('.semi-card')).toHaveClass('v2-reconcile-center');
expect(screen.getByText('数据差异中心').closest('.v2-workspace-panel-header')).toHaveClass('v2-reconcile-heading');
@@ -118,6 +119,7 @@ test('renders only the compact mobile reconciliation surface and defers secondar
expect(await screen.findByText('多来源实时位置漂移')).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-mobile-card.semi-card')).toBeInTheDocument();
expect(screen.getByRole('region', { name: '差异队列,可上下滚动' })).toHaveAttribute('tabindex', '0');
expect(document.querySelector('.v2-reconcile-table.semi-table-wrapper')).not.toBeInTheDocument();
expect(mocks.reconciliationIssues).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 }), expect.any(AbortSignal));
const mobileIssueAction = screen.getByRole('button', { name: '查看 粤A00001 差异证据' });
@@ -137,6 +139,7 @@ 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();
const trend = screen.getByText('近 30 天趋势').closest('.v2-reconcile-trend');
expect(trend).toBeInTheDocument();
expect(screen.getByText(/最近运行/)).toBeInTheDocument();

View File

@@ -339,7 +339,7 @@ export default function OperationsPage() {
const capacityIssueCount = data?.capacityFindings.length ?? 0;
const healthIssueCount = linkIssueCount + runtimeIssueCount + capacityIssueCount + Number((data?.kafkaLag ?? 0) > 0);
const overallStatus = !data ? 'unknown' : healthIssueCount > 0 ? 'warning' : 'ok';
return <div className="v2-ops-page">
return <div className={`v2-ops-page is-${workspace}`}>
<WorkspaceCommandBar
className="v2-ops-command-bar"
ariaLabel="运维质量操作"

View File

@@ -3,7 +3,7 @@ import { Button, Card, Empty, Input, RadioGroup, Select, SideSheet, Spin, Table,
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 type { ReconciliationIssue, ReconciliationSummary } from '../../api/types';
import { InlineError } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { TablePagination } from '../shared/TablePagination';
@@ -77,6 +77,34 @@ function evidenceValue(value: unknown) {
return JSON.stringify(value, null, 2);
}
function ReconciliationTrend({ data, maxTrend, onClose }: {
data?: ReconciliationSummary;
maxTrend: number;
onClose: () => void;
}) {
return <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={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}
<footer><span><i className="is-active" />存量</span><span><i className="is-new" />新增</span><span><i className="is-recovered" />恢复</span></footer>
</Card>;
}
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);
@@ -148,6 +176,7 @@ export default function ReconciliationCenter() {
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 天差异趋势');
useEffect(() => setOffset(0), [deferredKeyword, mobileLayout, ruleCode, severity, status]);
const summary = useQuery({
@@ -266,7 +295,12 @@ export default function ReconciliationCenter() {
]} />
</div>
{issues.isError ? <InlineError message={issues.error.message} onRetry={() => issues.refetch()} /> : null}
<div className="v2-reconcile-table-wrap">
<div
className="v2-reconcile-table-wrap is-scroll-region"
role="region"
aria-label="差异队列,可上下滚动"
tabIndex={0}
>
{!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,
@@ -275,7 +309,12 @@ export default function ReconciliationCenter() {
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>
<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>
<span className="v2-reconcile-mobile-meta"><span>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</span><time>{fmt(item.lastSeenAt)}</time></span>
<footer><span>{ruleLabel(item.ruleCode)} · 命中 {item.occurrenceCount.toLocaleString('zh-CN')} 次</span><span>证据与处置<IconChevronRight /></span></footer>
</span>
</Button>
</Card>)}</div>}
{issues.isPending ? <div className="v2-reconcile-loading" role="status"><Spin size="middle" tip="正在读取差异队列…" /></div> : null}
@@ -283,27 +322,7 @@ 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 ? <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></>
</Card></div> : null}
{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>
@@ -317,5 +336,16 @@ export default function ReconciliationCenter() {
>
{mobileLayout ? detailPanel : null}
</SideSheet>
<SideSheet
className="v2-reconcile-trend-sidesheet"
visible={mobileLayout && trendExpanded}
width="100%"
aria-label="30 天差异趋势"
title="30 天差异趋势"
footer={null}
onCancel={() => setTrendExpanded(false)}
>
{mobileLayout && trendExpanded ? <div id="v2-reconcile-trend"><ReconciliationTrend data={data} maxTrend={maxTrend} onClose={() => setTrendExpanded(false)} /></div> : null}
</SideSheet>
</>;
}