feat(web): unify async workspace states

This commit is contained in:
lingniu
2026-07-20 04:45:51 +08:00
parent 04ec00d4be
commit 952f886459
11 changed files with 197 additions and 25 deletions

View File

@@ -1,5 +1,5 @@
import { IconChevronDown, IconChevronRight, IconClose, IconConnectionPoint1, IconDownload, IconMore, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Collapse, Descriptions, Dropdown, Empty, Input, Select, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { Button, Card, CardGroup, Collapse, Descriptions, Dropdown, Input, Select, 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';

View File

@@ -1,5 +1,5 @@
import { IconAlarm, IconBell, IconChevronRight, IconClose, IconFilter, IconPlus, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Collapse, Descriptions, Input, Select, Spin, Table, Tag, TextArea, Timeline, Typography } from '@douyinfe/semi-ui';
import { Button, Card, CardGroup, Collapse, Descriptions, Input, Select, Table, Tag, TextArea, Timeline, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, memo, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
@@ -599,7 +599,7 @@ function NotificationsWorkspace({ editable }: { editable: boolean }) {
}, [limit, notifications.isPending, offset, total, totalPages]);
const totalTag = <Tag className="v2-alert-notification-total" color="blue" type="light" size="small">{total.toLocaleString('zh-CN')} </Tag>;
return <Card className="v2-alert-notifications" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader className="v2-alert-notification-heading" title={mobileLayout ? <span className="v2-alert-notification-mobile-title">{totalTag}</span> : '站内通知'} description="仅站内通道具备真实送达与已读状态" meta={mobileLayout ? undefined : totalTag} actions={editable ? <Button theme="light" type="primary" disabled={read.isPending || !items.some((item) => !item.read)} onClick={() => read.mutate(items.filter((item) => !item.read).map((item) => item.id))}>{read.isPending ? '正在更新' : mobileLayout ? '本页已读' : '本页全部已读'}</Button> : <span className="v2-role-badge"></span>} />{notifications.isError ? <InlineError message={notifications.error?.message ?? '站内通知读取失败'} onRetry={() => notifications.refetch()} /> : null}{read.isError ? <InlineError message={read.error instanceof Error ? read.error.message : '通知状态更新失败'} /> : null}
<div className="v2-alert-notification-list">{notifications.isPending ? <div className="v2-alert-notification-loading"><Spin size="middle" tip="正在读取站内通知" /></div> : items.length ? <CardGroup className="v2-alert-notification-cards" type="grid" spacing={0}>{items.map((item) => <Card className={`v2-alert-notification-card${item.read ? ' is-read' : ' is-unread'}`} key={item.id} title={<span className="v2-alert-notification-title"><SeverityTag severity={item.severity} /><span title={item.title}>{item.title}</span></span>} headerExtraContent={<Tag color={item.read ? 'grey' : 'orange'} type="light" size="small">{item.read ? '已读' : '未读'}</Tag>} headerLine bodyStyle={{ padding: 0 }}>
<div className="v2-alert-notification-list">{notifications.isPending ? <PanelLoading className="v2-alert-notification-loading" title="正在读取站内通知" description="送达和已读状态就绪后会自动显示。" /> : items.length ? <CardGroup className="v2-alert-notification-cards" type="grid" spacing={0}>{items.map((item) => <Card className={`v2-alert-notification-card${item.read ? ' is-read' : ' is-unread'}`} key={item.id} title={<span className="v2-alert-notification-title"><SeverityTag severity={item.severity} /><span title={item.title}>{item.title}</span></span>} headerExtraContent={<Tag color={item.read ? 'grey' : 'orange'} type="light" size="small">{item.read ? '已读' : '未读'}</Tag>} headerLine bodyStyle={{ padding: 0 }}>
<p className="v2-alert-notification-content" title={item.content}>{item.content}</p>
<footer><Typography.Text type="tertiary"><AlertTime value={item.createdAt} /></Typography.Text>{editable && !item.read ? <Button theme="borderless" disabled={read.isPending} onClick={() => read.mutate([item.id])}>{read.isPending ? '更新中' : '标为已读'}</Button> : null}</footer>
</Card>)}</CardGroup> : !notifications.isError ? <PanelEmpty className="v2-alert-notification-empty" compact tone="primary" icon={<IconBell />} title="暂无站内通知" description="告警触发或状态变更后,通知会在这里形成可追溯记录。" /> : null}</div>

View File

@@ -337,6 +337,19 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
view.unmount();
});
test('shows one recovery state instead of stacking an empty state below a failed list query', async () => {
vi.spyOn(api, 'vehicleRealtime').mockRejectedValue(new Error('当前账号无权查看该车辆'));
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
fireEvent.click(screen.getByRole('tab', { name: /列表/ }));
expect(await screen.findByText('当前账号无权查看该车辆')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '重试' })).toBeInTheDocument();
expect(screen.queryByText('暂无符合条件的车辆')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-monitor-table-scroll')).toHaveClass('is-error');
});
test('keeps authorized vehicles without realtime coordinates in the list without geocoding them', async () => {
const row = {
...vehicles[0],

View File

@@ -9,7 +9,7 @@ import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { Page, VehicleRealtimeRow } from '../../api/types';
import { FleetMap } from '../map/FleetMap';
import { EmptyState, InlineError, PanelEmpty } from '../shared/AsyncState';
import { EmptyState, InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { TablePagination } from '../shared/TablePagination';
import { ProtocolTag } from '../shared/ProtocolTag';
@@ -179,7 +179,7 @@ const MonitorMobileVehicleCard = memo(function MonitorMobileVehicleCard({ row, o
&& before.latitude === after.latitude;
});
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, mobile, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; mobile: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, error, mobile, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; error: boolean; mobile: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
const columns = useMemo(() => [
{ title: '车辆', dataIndex: 'plate', width: 210, className: 'v2-monitor-table-vehicle', render: (_value: string, row: VehicleRealtimeRow) => <Button className="v2-monitor-vehicle-action" theme="borderless" type="tertiary" title="在地图中定位" onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></Button> },
{ title: '速度', dataIndex: 'speedKmh', width: 120, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value">{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}</strong>{hasRealtimeSpeed(row) ? <small className="v2-monitor-live-unit">km/h</small> : null}</> },
@@ -196,8 +196,8 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, mo
description={mobile ? '实时数据 · 地址按需解析' : '覆盖全部授权车辆;缺失值显示“—”,地址按需解析'}
meta={`${total.toLocaleString('zh-CN')}${mobile ? '' : '车辆'}`}
/>
{!mobile ? <div className="v2-monitor-table-scroll"><Table className="v2-monitor-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} empty={null} />{loading ? <div className="v2-monitor-table-loading" role="status"><Spin size="small" tip="正在更新车辆实时数据" /></div> : null}{!loading && !rows.length ? <PanelEmpty className="v2-monitor-table-empty" tone="primary" icon={<IconSearch />} title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
{mobile ? <div className="v2-monitor-mobile-cards">{rows.map((row) => <MonitorMobileVehicleCard row={row} onSelect={onSelect} key={row.vin} />)}{loading ? <div className="v2-monitor-table-loading" role="status"><Spin size="small" tip="正在更新车辆实时数据…" /></div> : null}{!loading && !rows.length ? <PanelEmpty className="v2-monitor-table-empty" tone="primary" icon={<IconSearch />} title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
{!mobile ? <div className={`v2-monitor-table-scroll${error ? ' is-error' : ''}`}><Table className="v2-monitor-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} empty={null} />{loading ? <PanelLoading className="v2-monitor-table-loading" compact title="正在更新车辆实时数据" description={rows.length ? '保留当前列表,完成后平滑替换。' : '首批实时车辆返回后会自动显示。'} /> : null}{!loading && !error && !rows.length ? <PanelEmpty className="v2-monitor-table-empty" tone="primary" icon={<IconSearch />} title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
{mobile ? <div className="v2-monitor-mobile-cards">{rows.map((row) => <MonitorMobileVehicleCard row={row} onSelect={onSelect} key={row.vin} />)}{loading ? <PanelLoading className="v2-monitor-table-loading" compact title="正在更新车辆实时数据" description={rows.length ? '当前车辆卡片会保留到新数据就绪。' : '首批实时车辆返回后会自动显示。'} /> : null}{!loading && !error && !rows.length ? <PanelEmpty className="v2-monitor-table-empty" tone="primary" icon={<IconSearch />} title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
<footer><TablePagination page={page} totalPages={totalPages} info={`${total.toLocaleString('zh-CN')} 辆车辆`} onPageChange={onPage} pageSize={limit} pageSizeLabel="每页车辆数" onPageSizeChange={onLimit} pageSizeOptions={[{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
</Card>;
}
@@ -530,7 +530,7 @@ export default function MonitorPage() {
<div className={`v2-rail-search${batchMatch.missing.length && !batchSearchPending ? ' has-missing' : ''}`} title={searchTerms.length > 1 ? batchStatusTitle : undefined}><IconSearch /><span>{searchTerms.length > 1 ? batchStatus : deferredKeyword ? `正在筛选“${deferredKeyword}` : '按最新上报排序'}</span></div>
<div className="v2-vehicle-scroll">
{vehicles.isLoading || batchSearchPending ? <div className="v2-list-loading"><span className="v2-spinner" />{batchSearchPending ? `正在查找 ${searchTerms.length} 辆车` : '加载车辆'}</div> : null}
{!vehicles.isLoading && !batchSearchPending && visibleRows.length === 0 ? <EmptyState /> : null}
{!vehicles.isLoading && !vehicles.isError && !batchSearchPending && visibleRows.length === 0 ? <EmptyState /> : null}
{visibleRows.map((vehicle) => <VehicleRow key={vehicle.vin} vehicle={vehicle} selected={vehicle.vin === selectedVin} onSelect={selectVehicle} />)}
</div>
<footer>{searchTerms.length > 1 && !batchSearchPending ? batchStatus : `当前载入 ${visibleRows.length} / ${vehicles.data?.total ?? visibleRows.length}`}</footer>
@@ -545,7 +545,7 @@ export default function MonitorPage() {
initialViewport={initialContext.hasViewport ? initialContext.viewport : undefined}
/>
{selected && detailOpen ? (
<Suspense fallback={<Card className="v2-vehicle-detail v2-detail-loading-card" bodyStyle={{ padding: 0 }}><div role="status"><Spin size="small" /></div></Card>}>
<Suspense fallback={<Card className="v2-vehicle-detail v2-detail-loading-card" bodyStyle={{ padding: 0 }}><PanelLoading compact title="正在加载车辆详情" description="车辆状态和最新上报即将就绪。" /></Card>}>
<VehicleDetailCard
vehicle={selected}
monitorReturn={monitorReturn}
@@ -563,7 +563,7 @@ export default function MonitorPage() {
</Button>
</Card>
) : null}
</section> : <MonitorVehicleTable rows={visibleListRows} total={visibleListTotal} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil(visibleListTotal / listLimit))} limit={listLimit} loading={filterTransitionPending || realtimeListQuery.isFetching} mobile={mobileLayout} onSelect={selectVehicle} onPage={(page) => setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
</section> : <MonitorVehicleTable rows={visibleListRows} total={visibleListTotal} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil(visibleListTotal / listLimit))} limit={listLimit} loading={filterTransitionPending || realtimeListQuery.isFetching} error={realtimeListQuery.isError} mobile={mobileLayout} onSelect={selectVehicle} onPage={(page) => setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
<Card className="v2-event-strip" bodyStyle={{ padding: 0 }} aria-label="实时数据状态">
<div className="v2-event-sync">

View File

@@ -1,11 +1,11 @@
import { IconAlertTriangle, IconPulse, IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Checkbox, Descriptions, Input, Progress, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { Button, Card, CardGroup, Checkbox, Descriptions, Input, Progress, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { LinkHealth, SourceReadinessRow, VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types';
import { InlineError, PanelEmpty } from '../shared/AsyncState';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { PlatformTime } from '../shared/PlatformTime';
@@ -409,7 +409,7 @@ function SourceDiagnosticWorkspace() {
/>
{diagnostic.isError ? <InlineError message={diagnostic.error.message} onRetry={() => diagnostic.refetch()} /> : null}
{!selected ? <PanelEmpty className="v2-source-empty" tone="primary" icon={<IconSearch />} title="先选择一辆车" description="将展示所有协议和同协议多终端、推荐原因、上报周期与可审计策略。" /> : null}
{selected && diagnostic.isPending ? <div className="v2-source-loading" role="status"><Spin size="large" /><strong></strong><span></span></div> : null}
{selected && diagnostic.isPending ? <PanelLoading className="v2-source-loading" title="正在读取来源证据" description="只查询当前车辆,不会扫描整车队。" /> : null}
{data ? <>
<section className="v2-source-summary-region" role="group" aria-label="车辆来源诊断概览">
<WorkspaceMetricRail
@@ -600,7 +600,7 @@ export default function OperationsPage() {
icon={<IconAlertTriangle />}
title="协议来源证据不可用"
description="运行证据仍可查看;恢复来源证据后再判断协议覆盖。"
/> : <div className="v2-ops-source-loading" role="status"><Spin size="middle" tip="正在读取协议来源就绪度" /></div>}</Card>
/> : <PanelLoading className="v2-ops-source-loading" title="正在读取协议来源就绪度" description="覆盖率与处置建议就绪后会自动更新。" />}</Card>
<div className="v2-ops-grid"><Card className="v2-ops-panel v2-ops-links" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="数据链路" description="按链路查看当前状态与服务端诊断证据" meta={<HealthTag status={!data ? 'unknown' : linkIssueCount > 0 ? 'warning' : 'ok'}>{!data ? '读取中' : linkIssueCount > 0 ? `${linkIssueCount} 条异常` : '链路正常'}</HealthTag>} /><div className="v2-ops-link-list" role="list">{sortedLinks.length ? sortedLinks.map((item) => {
const label = opsLinkLabel(item.name);
return <article className={`v2-ops-link-card is-${item.status}`} key={item.name} role="listitem"><span className="v2-ops-link-status"><i /><span><strong>{label}</strong>{label !== item.name ? <small>{item.name}</small> : null}</span></span><p>{item.detail || '无补充信息'}</p><HealthTag status={item.status} /></article>;

View File

@@ -1,10 +1,10 @@
import { IconAlertTriangle, IconChevronDown, IconChevronRight, IconChevronUp, IconClose, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { Button, Card, Input, RadioGroup, Select, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { Button, Card, Input, RadioGroup, Select, 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';
import type { ReconciliationIssue, ReconciliationSummary } from '../../api/types';
import { InlineError, PanelEmpty } from '../shared/AsyncState';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { PlatformTime } from '../shared/PlatformTime';
import { TablePagination } from '../shared/TablePagination';
@@ -323,7 +323,7 @@ export default function ReconciliationCenter() {
const filterSummary = `${status === 'active' ? '活跃差异' : statusLabel(status)} · ${(page?.total ?? 0).toLocaleString('zh-CN')} ${activeFilterCount ? ` · 另 ${activeFilterCount}` : ''}`;
const selectedIssue = detail.data ?? issueRows.find((item) => item.id === selectedID);
const detailPanel = selectedID && detail.isPending
? <Card className="v2-reconcile-detail 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 }}><PanelLoading className="v2-reconcile-detail-loading" title="正在读取差异证据" description="原始证据和处理履历就绪后会自动显示。" /></Card>
: selectedID && detail.isError
? <Card className="v2-reconcile-detail is-sheet" bodyStyle={{ padding: 0 }}><InlineError message={detail.error.message} onRetry={() => detail.refetch()} /></Card>
: detail.data
@@ -445,7 +445,7 @@ export default function ReconciliationCenter() {
</span>
</Button>
</Card>)}</div>}
{issues.isPending ? <div className="v2-reconcile-loading" role="status"><Spin size="middle" tip="正在读取差异队列" /></div> : null}
{issues.isPending ? <PanelLoading className="v2-reconcile-loading" compact title="正在读取差异队列" description="筛选范围已保留,结果就绪后会自动显示。" /> : null}
{!issues.isPending && !issueRows.length ? <PanelEmpty
className="v2-reconcile-empty"
tone="success"

View File

@@ -1,5 +1,5 @@
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconInfoCircle, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, ButtonGroup, Card, DatePicker, Empty, Input, Progress, Spin, Switch, Table, Tag } from '@douyinfe/semi-ui';
import { Button, ButtonGroup, Card, DatePicker, Input, Progress, Spin, Switch, Table, Tag } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
import { type CSSProperties, FormEvent, type KeyboardEvent, memo, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';

View File

@@ -3,7 +3,7 @@ import {
IconAlarm, IconBox, IconCalendar, IconClock, IconCopy,
IconChevronRight, IconMapPin, IconSearch, IconTickCircle
} from '@douyinfe/semi-icons';
import { Button, Card, Descriptions, Empty, Input, List, Select, Table, Tag } from '@douyinfe/semi-ui';
import { Button, Card, Descriptions, Input, List, Select, Table, Tag } from '@douyinfe/semi-ui';
import { FormEvent, lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
@@ -644,6 +644,13 @@ export default function VehiclePage() {
if (!vin) return <Suspense fallback={<PageLoading />}><VehicleSearch /></Suspense>;
if (query.isPending) return <PageLoading />;
if (query.isError) return <div className="v2-page-error"><InlineError message={query.error instanceof Error ? query.error.message : '车辆档案加载失败'} onRetry={() => query.refetch()} /></div>;
if (!query.data.lookupResolved) return <Card className="v2-not-found" bodyStyle={{ padding: 0 }}><Empty image={<IconSearch size="extra-large" />} title="未找到车辆" description={`没有匹配“${vin}”的车牌、VIN 或终端记录。`}><Link to="/vehicles"><Button theme="solid" icon={<IconSearch />}></Button></Link></Empty></Card>;
if (!query.data.lookupResolved) return <Card className="v2-not-found" bodyStyle={{ padding: 0 }}><PanelEmpty
className="v2-vehicle-not-found-state"
tone="primary"
icon={<IconSearch />}
title="未找到车辆"
description={`没有匹配“${vin}”的车牌、VIN 或终端记录。`}
action={<Link to="/vehicles"><Button theme="solid" icon={<IconSearch />} aria-label="重新查询车辆"></Button></Link>}
/></Card>;
return <VehicleRecord detail={query.data} liveRealtime={realtime.data?.items[0]} telemetry={telemetry.data} telemetryPending={telemetry.isPending} telemetryError={telemetry.isError ? (telemetry.error instanceof Error ? telemetry.error.message : '请求失败') : undefined} monitorReturn={monitorReturn} onUpdated={() => { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />;
}

View File

@@ -109,7 +109,9 @@ describe('V2 production entry', () => {
expect(corePageSources.VehiclePage).not.toContain('<section className="v2-identity-band"');
expect(corePageSources.VehiclePage).toContain('<SegmentedTabs');
expect(corePageSources.VehiclePage).toContain('<Descriptions className="v2-record-descriptions"');
expect(corePageSources.VehiclePage).toContain('<Empty image={<IconSearch size="extra-large" />} title="未找到车辆"');
expect(corePageSources.VehiclePage).toContain('<PanelEmpty');
expect(corePageSources.VehiclePage).toContain('className="v2-vehicle-not-found-state"');
expect(corePageSources.VehiclePage).not.toContain('<Empty image={<IconSearch');
expect(corePageSources.VehiclePage).not.toContain('<dl className="v2-record-list"');
expect(corePageSources.VehiclePage).not.toContain('<section className="v2-not-found"');
expect(corePageSources.VehiclePage).toContain('v2-telemetry-protocols');
@@ -154,7 +156,8 @@ describe('V2 production entry', () => {
expect(corePageSources.MonitorPage).not.toContain('<section className="v2-event-strip"');
expect(corePageSources.MonitorPage).toContain('<Table className="v2-monitor-table"');
expect(corePageSources.MonitorPage).toContain('<PanelEmpty className="v2-monitor-table-empty"');
expect(corePageSources.MonitorPage).toContain('<Spin size="small" tip="正在更新车辆实时数据…"');
expect(corePageSources.MonitorPage).toContain('<PanelLoading className="v2-monitor-table-loading"');
expect(corePageSources.MonitorPage).toContain('title="正在加载车辆详情"');
expect(corePageSources.MonitorPage).not.toContain('<table><thead><tr><th>车辆</th>');
expect(sourceEvidenceSource).toContain("import { Button, Card, Input, Spin, Tag } from '@douyinfe/semi-ui'");
expect(sourceEvidenceSource).toContain('<Card className={`v2-source-evidence');
@@ -261,6 +264,8 @@ describe('V2 production entry', () => {
expect(corePageSources.AlertsPage).toContain('<PanelEmpty className="v2-alert-notification-empty"');
expect(corePageSources.AlertsPage).toContain('<PanelEmpty className="v2-alert-empty"');
expect(corePageSources.AlertsPage).toContain('<PanelLoading className="v2-alert-loading"');
expect(corePageSources.AlertsPage).toContain('<PanelLoading className="v2-alert-notification-loading"');
expect(corePageSources.AlertsPage).not.toContain('<Spin');
expect(corePageSources.AlertsPage).not.toContain('<article className={item.read');
expect(corePageSources.AlertsPage).not.toContain('<section className="v2-alert-rule-list"');
expect(corePageSources.AlertsPage).not.toContain('<form className="v2-alert-rule-editor"');
@@ -387,7 +392,9 @@ describe('V2 production entry', () => {
expect(corePageSources.OperationsPage).not.toContain('<article key={source.protocol}');
expect(corePageSources.OperationsPage).not.toContain('<dl><div><dt>生产数据模式</dt>');
expect(corePageSources.OperationsPage).toContain('<PanelEmpty className="v2-source-empty"');
expect(corePageSources.OperationsPage).toContain('<Spin size="large"');
expect(corePageSources.OperationsPage).toContain('<PanelLoading className="v2-source-loading"');
expect(corePageSources.OperationsPage).toContain('<PanelLoading className="v2-ops-source-loading"');
expect(corePageSources.OperationsPage).not.toContain('<Spin');
expect(corePageSources.OperationsPage).toContain('className="v2-source-summary-rail"');
expect(corePageSources.OperationsPage).toContain('ariaLabel="车辆来源诊断概览"');
expect(corePageSources.OperationsPage).not.toContain('<CardGroup className="v2-source-summary"');
@@ -411,7 +418,9 @@ describe('V2 production entry', () => {
expect(reconciliationSource).toContain('<Card key={item.id} className={`v2-reconcile-mobile-card');
expect(reconciliationSource).toContain('<PanelEmpty');
expect(reconciliationSource).toContain('className="v2-reconcile-empty"');
expect(reconciliationSource).toContain('<Spin size="middle" tip="正在读取差异队列…"');
expect(reconciliationSource).toContain('<PanelLoading className="v2-reconcile-loading"');
expect(reconciliationSource).toContain('<PanelLoading className="v2-reconcile-detail-loading"');
expect(reconciliationSource).not.toContain('<Spin');
expect(reconciliationSource).not.toContain('<table className="v2-reconcile-table"');
expect(detailTriggerRowSource).toContain("'aria-expanded': expanded");
expect(detailTriggerRowSource).toContain("event.key !== 'Enter' && event.key !== ' '");

View File

@@ -27611,3 +27611,103 @@
min-height: 164px;
}
}
/*
* Complete the shared Semi UI async-state migration across live monitoring,
* operations, reconciliation, alerts and the vehicle lookup boundary.
* Each state keeps the geometry of its host panel, so refreshes do not cause
* the surrounding workspace to jump.
*/
.v2-monitor-table-loading.v2-state-surface {
position: sticky;
z-index: 2;
bottom: 0;
min-height: 82px;
border: 0;
border-radius: 0;
background: linear-gradient(180deg, rgba(247, 250, 254, .96), #fff);
backdrop-filter: blur(8px);
}
.v2-monitor-table-loading.v2-state-surface .v2-state-message {
justify-content: center;
}
.v2-monitor-table-scroll.is-error .semi-table-placeholder {
visibility: hidden;
}
.v2-detail-loading-card .v2-state-surface {
min-height: 176px;
border: 0;
border-radius: 0;
}
.v2-source-loading.v2-state-surface {
min-height: 230px;
border: 0;
border-radius: 0;
background: linear-gradient(180deg, #f7faff, #fff);
}
.v2-ops-source-loading.v2-state-surface {
min-height: 250px;
border: 0;
border-radius: 0;
background: linear-gradient(180deg, #f7faff, #fff);
}
.v2-reconcile-loading.v2-state-surface {
min-height: 148px;
border: 0;
border-radius: 0;
}
.v2-reconcile-detail-loading.v2-state-surface {
min-height: 320px;
border: 0;
border-radius: 0;
}
.v2-alert-notification-loading.v2-state-surface {
min-height: 210px;
border: 0;
border-radius: 0;
background: linear-gradient(180deg, #f7faff, #fff);
}
.v2-not-found.semi-card > .semi-card-body > .v2-vehicle-not-found-state.v2-state-surface {
width: 100%;
min-height: 390px;
border: 0;
border-radius: 14px;
}
.v2-not-found .v2-vehicle-not-found-state > .v2-state-empty.semi-empty {
min-height: 0;
border: 0;
background: transparent;
}
@media (max-width: 680px) {
.v2-monitor-table-loading.v2-state-surface {
min-height: 76px;
}
.v2-source-loading.v2-state-surface,
.v2-ops-source-loading.v2-state-surface {
min-height: 188px;
}
.v2-reconcile-detail-loading.v2-state-surface {
min-height: 240px;
}
.v2-alert-notification-loading.v2-state-surface {
min-height: 176px;
}
.v2-not-found.semi-card > .semi-card-body > .v2-vehicle-not-found-state.v2-state-surface {
min-height: 320px;
}
}

View File

@@ -250,6 +250,49 @@
final result: passed
## 跨工作台异步状态 Semi UI Design QA
- source visual truth: ECS 生产端监控、车辆、告警、运维与质量对账工作台
- implementation screenshots:
- monitor first error pass: `/Users/lingniu/.codex/audits/2026-07-20-semi-mileage-refinement/02-monitor-single-error-after.jpg`
- monitor single error polished: `/Users/lingniu/.codex/audits/2026-07-20-semi-mileage-refinement/03-monitor-single-error-polished.jpg`
- monitor recovered list: `/Users/lingniu/.codex/audits/2026-07-20-semi-mileage-refinement/04-monitor-recovered-after.jpg`
- viewport and state:
- desktop `1280 × 720`
- 客户权限、列表模式、未授权搜索错误与清筛恢复状态
### Fidelity Ledger
| Surface | Required fidelity | Implementation evidence | Result |
| --- | --- | --- | --- |
| Loading language | 监控、告警、来源诊断、协议就绪度和差异队列不应继续维护各自的裸 Spin。 | 五个工作流统一迁移到共享 `PanelLoading`,保留各自任务说明与稳定面板高度。 | Improved. |
| State priority | 请求失败时不能同时显示错误和空结果,用户只能看到一个恢复方向。 | 生产复现未授权搜索后,错误状态优先;“暂无符合条件的车辆”和表格默认“暂无数据”均不再叠加。 | Passed. |
| Recovery continuity | 清除错误筛选后应恢复原实时列表,不丢失列表模式与授权范围。 | 点击“清空”后恢复 44 辆授权车辆、实时列和分页,错误状态消失。 | Passed. |
| Layout stability | 轮询或详情懒加载不能让列表、详情侧栏和卡片高度跳变。 | 状态表面按宿主面板设置稳定最小高度;监控刷新继续保留当前行,完成后平滑替换。 | Improved. |
| Semantic reuse | 未找到车辆应与其他页面的可恢复空结果使用同一 Semi UI 结构。 | 单车未找到状态改为共享 `PanelEmpty`,保留搜索图标、原因说明和唯一“重新查询”动作。 | Passed by component regression coverage. |
| Cross-page cleanup | 已迁移页面不能继续携带未使用的 Empty、Spin 依赖。 | Access、Statistics 清理残留导入Alerts、Operations、Reconciliation 移除页面级裸 Spin。 | Passed. |
| Production readiness | 迁移必须通过全量回归、构建门禁、原子发布和登录态验收。 | 372 项测试、Web build gate、资源烟测、生产交互与控制台检查完成版本 `semi-state-priority-polish-20260720043854`。 | Passed. |
### Interaction And Accessibility Checks
- 所有共享加载状态继续使用 `role="status"``aria-live="polite"`
- 请求失败继续使用 `role="alert"`,只保留一个“重试”按钮;
- 监控搜索错误时不再同时公布空结果状态;
- 清除筛选后实时列表恢复,列表模式、分页和每页数量保持不变;
- 单车未找到状态使用可访问名称“重新查询车辆”;
- 本轮视觉证据覆盖客户可访问的监控主流程;管理员专属告警、运维与对账状态由组件测试、源码门禁和生产构建覆盖,不冒充已完成管理员视觉认证。
### Comparison History
#### Pass 1 — fixed
- 首次生产验收发现未授权车牌搜索同时显示权限错误、空结果和表格默认空占位。
- 第一轮先抑制业务空结果;第二轮继续隐藏错误范围下的 Semi 表格默认“暂无数据”,保留表头帮助理解当前列结构。
- 清除筛选后 44 辆授权车辆恢复,实时指标、协议、坐标和分页均正常。
- 控制台没有新增错误或警告,未发现页面裁切、状态覆盖或不可达恢复动作。
final result: passed
## 历史证据与账号治理次级状态 Semi UI Design QA
- source visual truth: ECS 生产端历史数据与账号管理工作台