diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx index ee1f12f3..bd6155f8 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -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'; diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx index 316efda7..ff55e184 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx @@ -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 = {total.toLocaleString('zh-CN')} 条; return 通知列表{totalTag} : '站内通知'} description="仅站内通道具备真实送达与已读状态" meta={mobileLayout ? undefined : totalTag} actions={editable ? : 只读} />{notifications.isError ? notifications.refetch()} /> : null}{read.isError ? : null} -
{notifications.isPending ?
: items.length ? {items.map((item) => {item.title}} headerExtraContent={{item.read ? '已读' : '未读'}} headerLine bodyStyle={{ padding: 0 }}> +
{notifications.isPending ? : items.length ? {items.map((item) => {item.title}} headerExtraContent={{item.read ? '已读' : '未读'}} headerLine bodyStyle={{ padding: 0 }}>

{item.content}

{editable && !item.read ? : null}
)}
: !notifications.isError ? } title="暂无站内通知" description="告警触发或状态变更后,通知会在这里形成可追溯记录。" /> : null}
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx index 97ca439c..987a8b92 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx @@ -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(); + + 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], diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx index 89897bd4..fd03a282 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx @@ -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) => }, { title: '速度', dataIndex: 'speedKmh', width: 120, render: (_value: number, row: VehicleRealtimeRow) => <>{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? km/h : null} }, @@ -196,8 +196,8 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, mo description={mobile ? '实时数据 · 地址按需解析' : '覆盖全部授权车辆;缺失值显示“—”,地址按需解析'} meta={`${total.toLocaleString('zh-CN')} 辆${mobile ? '' : '车辆'}`} /> - {!mobile ?
{loading ?
: null}{!loading && !rows.length ? } title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null} : null} - {mobile ?
{rows.map((row) => )}{loading ?
: null}{!loading && !rows.length ? } title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}
: null} + {!mobile ?
{loading ? : null}{!loading && !error && !rows.length ? } title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null} : null} + {mobile ?
{rows.map((row) => )}{loading ? : null}{!loading && !error && !rows.length ? } title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}
: null}
; } @@ -530,7 +530,7 @@ export default function MonitorPage() {
1 ? batchStatusTitle : undefined}>{searchTerms.length > 1 ? batchStatus : deferredKeyword ? `正在筛选“${deferredKeyword}”` : '按最新上报排序'}
{vehicles.isLoading || batchSearchPending ?
{batchSearchPending ? `正在查找 ${searchTerms.length} 辆车` : '加载车辆'}
: null} - {!vehicles.isLoading && !batchSearchPending && visibleRows.length === 0 ? : null} + {!vehicles.isLoading && !vehicles.isError && !batchSearchPending && visibleRows.length === 0 ? : null} {visibleRows.map((vehicle) => )}
{searchTerms.length > 1 && !batchSearchPending ? batchStatus : `当前载入 ${visibleRows.length} / ${vehicles.data?.total ?? visibleRows.length} 辆`}
@@ -545,7 +545,7 @@ export default function MonitorPage() { initialViewport={initialContext.hasViewport ? initialContext.viewport : undefined} /> {selected && detailOpen ? ( -
正在加载车辆详情
}> + }> ) : null} - : setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />} + : setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx index 37d36381..cb1196ef 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx @@ -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 ? diagnostic.refetch()} /> : null} {!selected ? } title="先选择一辆车" description="将展示所有协议和同协议多终端、推荐原因、上报周期与可审计策略。" /> : null} - {selected && diagnostic.isPending ?
正在读取来源证据只查询当前车辆,不会扫描整车队。
: null} + {selected && diagnostic.isPending ? : null} {data ? <>
} title="协议来源证据不可用" description="运行证据仍可查看;恢复来源证据后再判断协议覆盖。" - /> :
} + /> : }
0 ? 'warning' : 'ok'}>{!data ? '读取中' : linkIssueCount > 0 ? `${linkIssueCount} 条异常` : '链路正常'}} />
{sortedLinks.length ? sortedLinks.map((item) => { const label = opsLinkLabel(item.name); return
{label}{label !== item.name ? {item.name} : null}

{item.detail || '无补充信息'}

; diff --git a/vehicle-data-platform/apps/web/src/v2/pages/ReconciliationCenter.tsx b/vehicle-data-platform/apps/web/src/v2/pages/ReconciliationCenter.tsx index ee5ddbb4..0def3537 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/ReconciliationCenter.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/ReconciliationCenter.tsx @@ -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 - ?
+ ? : selectedID && detail.isError ? detail.refetch()} /> : detail.data @@ -445,7 +445,7 @@ export default function ReconciliationCenter() { )}
} - {issues.isPending ?
: null} + {issues.isPending ? : null} {!issues.isPending && !issueRows.length ? }>; if (query.isPending) return ; if (query.isError) return
query.refetch()} />
; - if (!query.data.lookupResolved) return } title="未找到车辆" description={`没有匹配“${vin}”的车牌、VIN 或终端记录。`}>; + if (!query.data.lookupResolved) return } + title="未找到车辆" + description={`没有匹配“${vin}”的车牌、VIN 或终端记录。`} + action={} + />; return { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />; } diff --git a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts index adaba251..95cdeaed 100644 --- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts @@ -109,7 +109,9 @@ describe('V2 production entry', () => { expect(corePageSources.VehiclePage).not.toContain('
} title="未找到车辆"'); + expect(corePageSources.VehiclePage).toContain(' { expect(corePageSources.MonitorPage).not.toContain('
'); expect(sourceEvidenceSource).toContain("import { Button, Card, Input, Spin, Tag } from '@douyinfe/semi-ui'"); expect(sourceEvidenceSource).toContain(' { expect(corePageSources.AlertsPage).toContain(' { expect(corePageSources.OperationsPage).not.toContain('
生产数据模式
'); expect(corePageSources.OperationsPage).toContain(' { expect(reconciliationSource).toContain(' .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; + } +} diff --git a/vehicle-data-platform/design-qa.md b/vehicle-data-platform/design-qa.md index d4fce251..8cb1f58a 100644 --- a/vehicle-data-platform/design-qa.md +++ b/vehicle-data-platform/design-qa.md @@ -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 生产端历史数据与账号管理工作台
车辆