unify Semi UI workspace command hierarchy

This commit is contained in:
lingniu
2026-07-18 03:36:18 +08:00
parent e39140c408
commit 3cebfe1982
12 changed files with 372 additions and 79 deletions

View File

@@ -50,6 +50,8 @@ test('removes old access rows immediately when the vehicle filter scope changes'
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access?keyword=OLDVIN']}><AccessPage /></MemoryRouter></QueryClientProvider>);
expect(await screen.findByText('旧接入车牌')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '真实来源与接入健康', level: 5 })).toBeInTheDocument();
expect(screen.getByLabelText('接入管理操作')).toHaveClass('v2-workspace-command-bar', 'v2-access-command-bar');
for (const className of ['v2-access-filter-card-v3', 'v2-access-kpis-card-v3', 'v2-access-table-v3']) {
expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument();
}

View File

@@ -8,8 +8,8 @@ import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdC
import { accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MetricActionButton } from '../shared/MetricActionButton';
import { PageHeader } from '../shared/PageHeader';
import { TablePagination } from '../shared/TablePagination';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { detailTriggerRow } from '../shared/detailTriggerRow';
@@ -201,8 +201,10 @@ export default function AccessPage() {
const refresh = () => Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch(), ...(editable ? [unresolvedQuery.refetch(), thresholdQuery.refetch()] : [])]);
return <div className="v2-access-page v2-access-page-v3">
<PageHeader
title="车辆接入管理"
<WorkspaceCommandBar
className="v2-access-command-bar"
ariaLabel="接入管理操作"
title="真实来源与接入健康"
description="核对车辆真实存在的数据来源、在线健康和待维护资料,不对尚未接入的业务协议作推断。"
status={summary ? `${summary.totalVehicles.toLocaleString('zh-CN')} 辆主车辆` : '正在读取车辆'}
meta={<Typography.Text type="tertiary"> {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</Typography.Text>}

View File

@@ -59,6 +59,8 @@ test('renders reconciliation queue, loads evidence on demand and records review
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
expect(await screen.findByText('数据差异中心')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '运行与数据质量', level: 5 })).toBeInTheDocument();
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(screen.queryByText('先选择一辆车')).not.toBeInTheDocument();

View File

@@ -5,9 +5,9 @@ import { FormEvent, useDeferredValue, useEffect, useState } from 'react';
import { api } from '../../api/client';
import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types';
import { InlineError } from '../shared/AsyncState';
import { PageHeader } from '../shared/PageHeader';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
@@ -340,8 +340,10 @@ export default function OperationsPage() {
const healthIssueCount = linkIssueCount + runtimeIssueCount + capacityIssueCount + Number((data?.kafkaLag ?? 0) > 0);
const overallStatus = !data ? 'unknown' : healthIssueCount > 0 ? 'warning' : 'ok';
return <div className="v2-ops-page">
<PageHeader
title="运维质量"
<WorkspaceCommandBar
className="v2-ops-command-bar"
ariaLabel="运维质量操作"
title="运行与数据质量"
description="先定位单车多来源问题,再核对服务和协议全局健康;所有结论来自服务端证据。"
status={data?.runtime.dataMode === 'production' ? '生产模式' : '状态待确认'}
statusColor={data?.runtime.dataMode === 'production' ? 'green' : 'orange'}

View File

@@ -9,6 +9,7 @@ import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { PanelEmpty, PanelLoading } from '../shared/AsyncState';
@@ -244,17 +245,15 @@ export default function UsersPage() {
};
return <div className="v2-user-admin">
<div className="v2-user-command-bar" aria-label="账号管理操作">
<div className="v2-user-command-copy">
<strong>访</strong>
<span> 30 </span>
</div>
<div className="v2-user-command-meta">
<Tag color="blue" type="light" size="small">{customers.length} </Tag>
<Typography.Text type="tertiary"> · </Typography.Text>
<Button theme="solid" icon={<IconPlus />} aria-label="新建客户账号" onClick={startCreate}></Button>
</div>
</div>
<WorkspaceCommandBar
className="v2-user-command-bar"
ariaLabel="账号管理操作"
title="客户访问治理"
description="菜单与车辆按最小权限开放,变更最多 30 秒生效"
status={`${customers.length} 个客户账号`}
meta={<Typography.Text type="tertiary"> · </Typography.Text>}
actions={<Button theme="solid" icon={<IconPlus />} aria-label="新建客户账号" onClick={startCreate}></Button>}
/>
<WorkspaceFilterPanel
className="v2-user-filter-panel"
title="账号范围"

View File

@@ -151,7 +151,8 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
expect(screen.getByRole('heading', { name: '车辆查询', level: 2 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '车辆快速定位', level: 5 })).toBeInTheDocument();
expect(screen.getByLabelText('查车操作')).toHaveClass('v2-workspace-command-bar', 'v2-vehicle-command-bar');
expect(screen.getByRole('heading', { name: '车辆定位', level: 5 }).closest('.semi-card')).toHaveClass('v2-vehicle-query-panel', 'v2-workspace-filter-panel');
const input = screen.getByRole('textbox', { name: '搜索车辆' });
expect(input).toHaveAttribute('aria-expanded', 'false');

View File

@@ -17,11 +17,11 @@ import { isValidAMapCoordinate } from '../../integrations/amap';
import { FleetMap } from '../map/FleetMap';
import { InlineError, PageLoading } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { PageHeader } from '../shared/PageHeader';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { monitorReturnFromParams, withMonitorReturn } from '../routing/monitorContext';
@@ -105,9 +105,11 @@ function VehicleSearch() {
};
const vehicleScope = candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权` : '授权范围';
return <section className={`v2-vehicle-search-page${syncOpen ? ' has-sync-panel' : ''}${candidatesOpen ? ' has-candidates' : ''}`}>
<PageHeader
title="车辆查询"
description="通过车牌、VIN 或终端手机号定位车辆,进入统一数字档案"
<WorkspaceCommandBar
className="v2-vehicle-command-bar"
ariaLabel="查车操作"
title="车辆快速定位"
description="车牌、VIN 或手机号快速查车"
status={candidates.isPending ? '正在读取授权范围' : vehicleScope}
statusColor={candidates.isError ? 'red' : 'blue'}
actions={canAdminister(session) ? <Button theme="light" onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起主档同步' : '批量同步主档'}</Button> : null}
@@ -115,7 +117,7 @@ function VehicleSearch() {
<WorkspaceFilterPanel
className="v2-vehicle-query-panel"
title="车辆定位"
description="车牌优先VIN 辅助,支持终端手机号精确定位"
description="车牌优先VIN 辅助"
mobileSummary={deferredKeyword ? `正在查找 ${deferredKeyword}` : candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权车辆` : '正在读取授权范围'}
expanded={!filtersCollapsed}
status={deferredKeyword ? candidates.isFetching ? '正在查找' : `${options.length} 辆匹配` : vehicleScope}

View File

@@ -55,14 +55,14 @@ describe('V2 production entry', () => {
expect(source).toContain("from '@douyinfe/semi-ui'");
expect(source).toContain('<Button');
}
for (const name of ['VehiclePage', 'AccessPage', 'OperationsPage']) {
expect(corePageSources[name]).toContain("from '../shared/PageHeader'");
expect(corePageSources[name]).toContain('<PageHeader');
}
for (const name of ['AlertsPage', 'HistoryPage', 'StatisticsPage', 'UsersPage']) {
for (const name of ['VehiclePage', 'AccessPage', 'OperationsPage', 'AlertsPage', 'HistoryPage', 'StatisticsPage', 'UsersPage']) {
expect(corePageSources[name]).not.toContain("from '../shared/PageHeader'");
expect(corePageSources[name]).not.toContain('<PageHeader');
}
for (const name of ['VehiclePage', 'AccessPage', 'OperationsPage', 'UsersPage']) {
expect(corePageSources[name]).toContain("from '../shared/WorkspaceCommandBar'");
expect(corePageSources[name]).toContain('<WorkspaceCommandBar');
}
expect(corePageSources.VehiclePage).toContain('<Select');
expect(corePageSources.VehiclePage).toContain('<Card className="v2-identity-band v2-record-card v2-live-card v2-live-overview v2-vehicle-command-card"');
expect(corePageSources.VehiclePage).toContain('className="v2-live-grid" role="list" aria-label="车辆实时指标"');

View File

@@ -1,33 +0,0 @@
import { Tag, Typography } from '@douyinfe/semi-ui';
import type { ReactNode } from 'react';
const { Title, Text } = Typography;
export function PageHeader({
title,
description,
status,
statusColor = 'blue',
meta,
actions
}: {
title: string;
description: string;
status?: string;
statusColor?: React.ComponentProps<typeof Tag>['color'];
meta?: ReactNode;
actions?: ReactNode;
}) {
return (
<header className="v2-page-heading">
<div className="v2-page-heading-copy">
<div>
<Title heading={2}>{title}</Title>
{status ? <Tag color={statusColor} size="small">{status}</Tag> : null}
</div>
<Text type="secondary">{description}</Text>
</div>
{meta || actions ? <div className="v2-page-heading-aside">{meta}{actions}</div> : null}
</header>
);
}

View File

@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react';
import { Button, Typography } from '@douyinfe/semi-ui';
import { expect, test } from 'vitest';
import { WorkspaceCommandBar } from './WorkspaceCommandBar';
test('renders a compact Semi workspace command hierarchy', () => {
const view = render(<WorkspaceCommandBar
className="custom-command"
ariaLabel="测试工作区操作"
title="测试工作区"
description="说明当前任务和数据范围"
status="12 条"
meta={<Typography.Text type="tertiary"></Typography.Text>}
actions={<Button theme="solid"></Button>}
/>);
expect(screen.getByRole('heading', { name: '测试工作区', level: 5 })).toBeInTheDocument();
expect(screen.getByLabelText('测试工作区操作')).toHaveClass('v2-workspace-command-bar', 'custom-command');
expect(screen.getByText('12 条').closest('.semi-tag')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '执行' })).toHaveClass('semi-button');
expect(view.container.querySelector('.v2-workspace-command-copy')).toHaveTextContent('说明当前任务和数据范围');
});

View File

@@ -0,0 +1,40 @@
import { Tag, Typography } from '@douyinfe/semi-ui';
import type { ReactNode } from 'react';
const { Text, Title } = Typography;
export function WorkspaceCommandBar({
title,
description,
status,
statusColor = 'blue',
meta,
actions,
className = '',
ariaLabel
}: {
title: ReactNode;
description: ReactNode;
status?: string;
statusColor?: React.ComponentProps<typeof Tag>['color'];
meta?: ReactNode;
actions?: ReactNode;
className?: string;
ariaLabel?: string;
}) {
const rootClassName = ['v2-workspace-command-bar', className].filter(Boolean).join(' ');
return (
<header className={rootClassName} aria-label={ariaLabel}>
<div className="v2-workspace-command-copy">
<Title heading={5}>{title}</Title>
<Text type="tertiary">{description}</Text>
</div>
{status || meta || actions ? <div className="v2-workspace-command-actions">
{status ? <Tag color={statusColor} type="light" size="small">{status}</Tag> : null}
{meta ? <span className="v2-workspace-command-meta">{meta}</span> : null}
{actions}
</div> : null}
</header>
);
}

View File

@@ -1877,6 +1877,7 @@
}
}
@media (max-width: 680px) {
.v2-assigned-vins {
max-height: min(360px, 42dvh);
@@ -6521,7 +6522,7 @@
padding: 12px 14px 14px;
}
.v2-user-command-bar {
.v2-workspace-command-bar {
display: flex;
width: 100%;
max-width: 1480px;
@@ -6538,35 +6539,37 @@
box-shadow: 0 6px 20px rgba(31, 50, 76, .035);
}
.v2-user-command-copy {
.v2-workspace-command-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.v2-user-command-copy strong {
.v2-workspace-command-copy .semi-typography {
color: #263b53;
font-size: 14px;
font-weight: 700;
letter-spacing: -.012em;
}
.v2-user-command-copy span {
.v2-workspace-command-copy > .semi-typography:last-child {
overflow: hidden;
color: #7d8b9d;
font-size: 10px;
font-weight: 400;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-user-command-meta {
.v2-workspace-command-actions {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
}
.v2-user-command-meta > .semi-tag {
.v2-workspace-command-actions > .semi-tag {
min-height: 24px;
flex: 0 0 auto;
border-radius: 999px;
@@ -6575,7 +6578,13 @@
font-weight: 650;
}
.v2-user-command-meta > .semi-typography {
.v2-workspace-command-meta {
display: flex;
min-width: 0;
align-items: center;
}
.v2-workspace-command-meta > .semi-typography {
overflow: hidden;
max-width: 220px;
font-size: 10px;
@@ -6583,7 +6592,7 @@
white-space: nowrap;
}
.v2-user-command-meta > .semi-button {
.v2-workspace-command-actions > .semi-button {
min-height: 36px;
flex: 0 0 auto;
border-radius: 8px;
@@ -6903,7 +6912,7 @@
}
@media (max-width: 980px) and (min-width: 681px) {
.v2-user-command-meta > .semi-typography {
.v2-workspace-command-meta > .semi-typography {
display: none;
}
@@ -6929,35 +6938,50 @@
padding: 7px 7px 10px;
}
.v2-user-command-bar {
.v2-workspace-command-bar {
min-height: 48px;
gap: 8px;
border-radius: 9px;
padding: 5px 6px 5px 10px;
}
.v2-user-command-copy strong {
.v2-workspace-command-copy > .semi-typography:first-child {
font-size: 12px;
}
.v2-user-command-copy span,
.v2-user-command-meta > .semi-tag,
.v2-user-command-meta > .semi-typography {
.v2-workspace-command-copy > .semi-typography:last-child,
.v2-workspace-command-actions > .semi-tag,
.v2-workspace-command-meta {
display: none;
}
.v2-user-command-meta > .semi-button {
.v2-workspace-command-actions {
gap: 5px;
}
.v2-workspace-command-actions > .semi-button {
min-height: 38px;
padding-inline: 9px;
font-size: 10px;
}
.v2-user-command-bar .v2-workspace-command-actions > .semi-button,
.v2-access-command-bar .v2-workspace-command-actions > .semi-button,
.v2-ops-command-bar .v2-workspace-command-actions > .semi-button {
width: 40px;
min-width: 40px;
min-height: 40px;
padding: 0;
}
.v2-user-command-meta > .semi-button .semi-button-content {
.v2-user-command-bar .v2-workspace-command-actions > .semi-button .semi-button-content,
.v2-access-command-bar .v2-workspace-command-actions > .semi-button .semi-button-content,
.v2-ops-command-bar .v2-workspace-command-actions > .semi-button .semi-button-content {
font-size: 0;
}
.v2-user-command-meta > .semi-button .semi-icon {
.v2-user-command-bar .v2-workspace-command-actions > .semi-button .semi-icon,
.v2-access-command-bar .v2-workspace-command-actions > .semi-button .semi-icon,
.v2-ops-command-bar .v2-workspace-command-actions > .semi-button .semi-icon {
margin-right: 0;
font-size: 16px;
}
@@ -7158,3 +7182,233 @@
grid-template-columns: minmax(0, 1fr);
}
}
/* Final cascade layer for the shared command hierarchy. */
.v2-workspace-command-bar {
box-sizing: border-box;
height: 52px;
min-height: 52px;
padding-block: 5px;
}
.v2-workspace-command-copy {
gap: 0;
}
.v2-workspace-command-copy > .semi-typography:first-child {
margin: 0;
line-height: 18px;
}
.v2-workspace-command-copy > .semi-typography:last-child {
margin: 0;
line-height: 14px;
}
.v2-vehicle-query-panel > .semi-card-body > .v2-workspace-filter-heading {
display: none;
}
.v2-vehicle-query-panel .v2-vehicle-search-form {
display: flex;
height: 54px;
min-height: 54px;
align-items: center;
gap: 8px;
border-top: 0;
padding: 6px 8px;
}
.v2-vehicle-query-panel .v2-vehicle-search-form > .v2-vehicle-search-picker {
width: auto;
min-height: 40px;
flex: 1 1 0;
}
.v2-vehicle-query-panel .v2-vehicle-search-form > .semi-button {
height: 40px;
min-height: 40px;
flex: 0 0 auto;
}
.v2-ops-tabs {
box-sizing: border-box;
height: 44px;
min-height: 44px;
flex: 0 0 44px;
overflow: hidden;
padding: 3px;
}
.v2-ops-tabs > .semi-button {
height: 36px;
min-height: 36px;
}
@media (max-width: 680px) {
.v2-workspace-command-bar {
height: 48px;
min-height: 48px;
}
.v2-vehicle-query-panel > .semi-card-body > .v2-workspace-filter-heading {
display: flex;
}
.v2-vehicle-query-panel .v2-vehicle-search-form {
height: auto;
min-height: 0;
padding: 8px;
}
}
.v2-vehicle-search-page > .v2-workspace-command-bar {
width: min(1280px, 100%);
}
.v2-access-page-v3 {
width: 100%;
height: 100%;
min-height: 0;
gap: 8px;
overflow: hidden;
padding: 12px 14px 14px;
}
.v2-access-page-v3 > .v2-workspace-command-bar,
.v2-ops-command-bar {
max-width: none;
}
.v2-access-filter-card-v3 > .semi-card-body {
display: block;
}
.v2-access-filter-card-v3 > .semi-card-body > .v2-workspace-filter-heading {
display: none;
}
.v2-access-filter-v3 {
min-height: 54px;
grid-template-columns: minmax(220px, 1.35fr) repeat(3, minmax(128px, .7fr)) auto auto;
align-items: center;
gap: 8px;
padding: 7px 9px;
}
.v2-access-filter-v3 > label {
display: contents;
}
.v2-access-filter-v3 > label > span {
display: none;
}
.v2-access-filter-v3 label > :is(.semi-input-wrapper, .semi-select),
.v2-access-filter-v3 > .semi-button {
width: 100%;
height: 38px;
min-height: 38px;
align-self: center;
border-radius: 8px;
}
.v2-access-kpis-card-v3.semi-card,
.v2-access-kpis-v3 {
min-height: 60px;
}
.v2-access-kpis-v3 .v2-metric-action {
min-height: 58px;
padding: 8px 14px;
}
.v2-access-kpis-v3 strong {
font-size: 20px;
}
.v2-access-workspace-v3 {
min-height: 0;
flex: 1 1 0;
}
.v2-access-table-v3.semi-card,
.v2-access-table-v3 > .semi-card-body {
height: 100%;
min-height: 0;
}
.v2-ops-page {
width: 100%;
height: 100%;
min-height: 0;
gap: 8px;
overflow: hidden;
padding: 12px 14px 14px;
}
.v2-ops-workspace {
min-height: 0;
flex: 1 1 0;
overflow: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
@media (max-width: 1120px) and (min-width: 681px) {
.v2-access-filter-v3 {
grid-template-columns: minmax(200px, 1fr) repeat(3, minmax(110px, .65fr)) auto auto;
}
.v2-access-command-bar .v2-workspace-command-meta {
display: none;
}
}
@media (max-width: 680px) {
.v2-access-page-v3 {
height: auto;
min-height: 100%;
gap: 7px;
overflow: auto;
padding: 7px 7px 14px;
}
.v2-access-filter-card-v3 > .semi-card-body > .v2-workspace-filter-heading {
display: flex;
}
.v2-access-filter-v3 {
min-height: 0;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
padding: 8px;
}
.v2-access-filter-v3 > label {
display: grid;
}
.v2-access-filter-v3 > label > span {
display: block;
}
.v2-access-workspace-v3 {
min-height: 540px;
flex: none;
}
.v2-ops-page {
height: auto;
min-height: 100%;
gap: 7px;
overflow: auto;
padding: 7px 7px 16px;
}
.v2-ops-workspace {
min-height: 0;
flex: none;
overflow: visible;
}
}