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

View File

@@ -7824,3 +7824,389 @@
overflow: visible;
}
}
/* Semi UI operations queue: one fixed workbench with an independently scrolling evidence list. */
.v2-ops-workspace.is-reconciliation {
display: block;
overflow: hidden;
}
.v2-ops-workspace.is-reconciliation > .v2-reconcile-center.semi-card {
width: 100%;
height: 100%;
min-height: 0;
}
.v2-reconcile-center > .semi-card-body {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
}
.v2-reconcile-heading,
.v2-reconcile-overview,
.v2-reconcile-pagination {
flex: 0 0 auto;
}
.v2-reconcile-layout,
.v2-reconcile-layout.is-trend-open {
height: auto !important;
min-height: 0 !important;
flex: 1 1 0;
overflow: hidden;
}
.v2-reconcile-main {
min-height: 0;
}
.v2-reconcile-table-wrap.is-scroll-region {
min-height: 0;
flex: 1 1 0;
overflow: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.v2-reconcile-table-wrap.is-scroll-region:focus-visible {
outline: 2px solid rgba(18, 104, 243, .34);
outline-offset: -2px;
}
.v2-reconcile-pagination {
min-height: 48px;
}
.v2-reconcile-trend-sidesheet .semi-sidesheet-body {
overflow: auto;
background: #f6f8fb;
padding: 10px;
}
.v2-reconcile-trend-sidesheet .v2-reconcile-trend.semi-card {
overflow: hidden;
border: 1px solid #dce5ef;
border-radius: 12px;
background: #fff;
box-shadow: 0 8px 26px rgba(31, 53, 80, .07);
}
@media (max-width: 680px) {
.v2-ops-page.is-reconciliation {
height: 100%;
min-height: 0;
overflow: hidden;
padding-bottom: 7px;
}
.v2-ops-page.is-reconciliation > .v2-ops-command-bar,
.v2-ops-page.is-reconciliation > .v2-ops-tabs {
flex: 0 0 auto;
}
.v2-ops-page.is-reconciliation > .v2-ops-workspace.is-reconciliation {
display: block;
min-height: 0;
flex: 1 1 0;
overflow: hidden;
}
.v2-reconcile-heading.v2-workspace-panel-header {
min-height: 54px;
padding: 7px 8px;
}
.v2-reconcile-heading > .v2-workspace-panel-actions {
gap: 6px;
}
.v2-reconcile-heading > .v2-workspace-panel-actions > .semi-button {
min-height: 38px;
border-radius: 8px;
font-size: 10px;
}
.v2-reconcile-overview {
height: 68px;
grid-template-columns: minmax(0, 4fr) minmax(66px, 1fr);
gap: 0;
border-bottom: 1px solid #e3eaf2;
background: #fff;
padding: 0;
}
.v2-reconcile-overview > .v2-reconcile-kpis {
height: 67px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0;
background: #fff;
}
.v2-reconcile-overview .v2-reconcile-kpi-card.semi-card {
height: 67px;
border: 0;
border-radius: 0;
box-shadow: none;
}
.v2-reconcile-overview .v2-reconcile-kpi-card + .v2-reconcile-kpi-card {
border-left: 1px solid #e6ebf2;
}
.v2-reconcile-overview .v2-reconcile-kpi-card > .semi-card-body {
display: grid;
height: 67px;
min-height: 0;
align-content: center;
gap: 5px;
padding: 6px 4px !important;
text-align: center;
}
.v2-reconcile-overview .v2-reconcile-kpi-card.is-active {
background: #f2f7ff;
box-shadow: inset 0 -3px #1f6feb;
}
.v2-reconcile-overview .v2-reconcile-kpi-card small {
overflow: hidden;
color: #75849a;
font-size: 8px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-reconcile-overview .v2-reconcile-kpi-card strong {
margin: 0;
color: #263b52;
font-size: 17px;
line-height: 1;
}
.v2-reconcile-overview .v2-reconcile-kpi-card span {
display: none;
}
.v2-reconcile-sla.semi-card {
height: 67px;
border: 0;
border-left: 1px solid #e6ebf2;
border-radius: 0;
box-shadow: none;
}
.v2-reconcile-sla > .semi-card-body {
display: grid;
height: 67px;
min-height: 0;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto;
align-content: center;
gap: 5px;
padding: 6px 4px !important;
text-align: center;
}
.v2-reconcile-sla span {
justify-content: center;
}
.v2-reconcile-sla span small,
.v2-reconcile-sla p {
display: none;
}
.v2-reconcile-sla .semi-tag {
min-height: 20px;
padding-inline: 6px;
font-size: 8px;
}
.v2-reconcile-sla strong {
grid-column: 1;
grid-row: 2;
justify-self: center;
font-size: 17px;
}
.v2-reconcile-layout,
.v2-reconcile-layout.is-trend-open {
display: flex;
min-height: 0;
flex: 1 1 0;
flex-direction: column;
}
.v2-reconcile-main {
min-height: 0;
flex: 1 1 0;
overflow: hidden;
}
.v2-reconcile-main > .v2-mobile-filter-toggle {
width: calc(100% - 12px);
flex: 0 0 auto;
margin: 6px;
}
.v2-reconcile-toolbar {
flex: 0 0 auto;
}
.v2-reconcile-table-wrap.is-scroll-region {
min-height: 0;
flex: 1 1 0;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: auto;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
}
.v2-reconcile-table-wrap.is-scroll-region::-webkit-scrollbar {
width: 11px;
}
.v2-reconcile-table-wrap.is-scroll-region::-webkit-scrollbar-thumb {
border: 3px solid transparent;
border-radius: 999px;
background: #9cacc0;
background-clip: padding-box;
}
.v2-reconcile-mobile-list {
gap: 6px;
padding: 6px;
}
.v2-reconcile-mobile-card.semi-card {
border-radius: 9px;
contain-intrinsic-size: auto 118px;
}
.v2-reconcile-mobile-action.semi-button {
min-height: 116px;
}
.v2-reconcile-mobile-content {
gap: 5px;
padding: 8px 9px 7px;
}
.v2-reconcile-mobile-content > header {
min-width: 0;
gap: 6px;
}
.v2-reconcile-mobile-content > header > strong {
min-width: 0;
overflow: hidden;
color: #243a52;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-reconcile-mobile-content > header > span {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
}
.v2-reconcile-mobile-card :is(.v2-reconcile-severity, .v2-reconcile-status).semi-tag {
min-height: 20px;
border-radius: 5px;
padding-inline: 5px;
font-size: 8px;
}
.v2-reconcile-mobile-content > p {
overflow: hidden;
color: #43576e;
font-size: 10px;
font-weight: 650;
line-height: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-reconcile-mobile-meta {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #7b899a;
font-size: 8px;
line-height: 12px;
}
.v2-reconcile-mobile-meta > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-reconcile-mobile-meta > time {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
.v2-reconcile-mobile-content > footer {
min-height: 27px;
justify-content: space-between;
gap: 8px;
font-size: 9px;
}
.v2-reconcile-mobile-content > footer > span {
display: flex;
min-width: 0;
align-items: center;
gap: 3px;
}
.v2-reconcile-mobile-content > footer > span:first-child {
overflow: hidden;
color: #75859a;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-reconcile-mobile-content > footer > span:last-child {
flex: 0 0 auto;
color: var(--v2-blue);
}
.v2-reconcile-pagination {
min-height: 50px;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0;
padding: 5px 6px;
}
.v2-reconcile-pagination .v2-table-pagination-info {
display: none;
}
.v2-reconcile-pagination .v2-table-pagination-controls {
width: 100%;
justify-content: center;
}
.v2-reconcile-pagination .v2-table-pagination-button.semi-button,
.v2-reconcile-pagination .v2-table-pagination-current {
height: 34px;
}
.v2-reconcile-trend-sidesheet .semi-sidesheet-inner {
width: 100% !important;
}
}