diff --git a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx index 5778acc3..4d980bb6 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx @@ -205,7 +205,7 @@ test('uses one focused Semi bottom SideSheet for the complete mobile history que mocks.historyExports.mockResolvedValue([]); renderPage('/history'); - await screen.findByText('选择车辆开始查询'); + await screen.findByText('选择车辆,开始核验历史证据'); const trigger = screen.getByRole('button', { name: '修改查询范围:请选择车辆' }); expect(trigger).toHaveAttribute('aria-expanded', 'false'); expect(screen.queryByRole('button', { name: '展开趋势' })).not.toBeInTheDocument(); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx index 450193fa..ffa8711b 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; -import { IconChevronRight, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons'; +import { IconCalendar, IconChevronRight, IconClose, IconDownload, IconMapPin, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons'; import { Button, Card, CardGroup, Checkbox, Descriptions, Dropdown, Empty, Input, Progress, Select, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui'; import { FormEvent, KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; @@ -8,7 +8,7 @@ import type { HistoryDataResponse, HistoryDataRow, HistoryExportJob, HistoryExpo import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, historyExportPollInterval, parseHistoryKeywords } from '../domain/history'; import { downloadBlob } from '../domain/download'; import { formatShanghaiDateTime } from '../domain/formatters'; -import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState'; +import { InlineError, PanelEmpty, PanelLoading, WorkspaceEmptyGuide } from '../shared/AsyncState'; import { MonitorReturnBar } from '../shared/MonitorReturnBar'; import { MobileFilterToggle } from '../shared/MobileFilterToggle'; import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet'; @@ -593,11 +593,26 @@ export default function HistoryPage() { {dataQuery.isPending && keywords.length ? : !resultRows.length - ? } aria-label={keywords.length ? '调整查询范围' : '选择车辆并查询'} onClick={() => setFiltersCollapsed(false)}>{keywords.length ? '调整查询范围' : '选择车辆并查询'} : null} - /> + ? keywords.length + ? } aria-label="调整查询范围" onClick={() => setFiltersCollapsed(false)}>调整查询范围} + /> + : } + title="选择车辆,开始核验历史证据" + description="一次最多查询 5 台车辆,结果会保留协议来源、质量说明与 RAW 证据。" + steps={[ + { icon: , title: '选择车辆', description: '支持车牌、VIN 与批量粘贴' }, + { icon: , title: '确认时间', description: '按上海时区精确到分钟' }, + { icon: , title: '核验数据', description: '位置、RAW 与日里程统一检索' } + ]} + action={} + /> : null} {keywords.length ?
setOffset((next - 1) * limit)} pageSize={mobileLayout ? undefined : limit} onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={mobileLayout ? undefined : [{ value: 20, label: '20 条/页' }, { value: 50, label: '50 条/页' }, { value: 100, label: '100 条/页' }]} />
: null} diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx index b3ee6ba0..0ce7d5f2 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx @@ -11,7 +11,7 @@ import type { TrackPlaybackEvent, TrackPlaybackResponse, VehicleRow } from '../. import { protocolDisplayLabel } from '../domain/protocols'; import { buildTrackRailSelection, downloadTrackCsv, formatDuration, sampledEventIndex, TRACK_ADDRESS_SETTLE_MS, trackAddressCoordinate, trackPlaybackInterval, trackPointMileageAvailable, type TrackAddressCoordinate } from '../domain/track'; import { TrackMap } from '../map/TrackMap'; -import { InlineError } from '../shared/AsyncState'; +import { InlineError, WorkspaceEmptyGuide } from '../shared/AsyncState'; import { MonitorReturnBar } from '../shared/MonitorReturnBar'; import { ProtocolTag } from '../shared/ProtocolTag'; import { SegmentedTabs } from '../shared/SegmentedTabs'; @@ -513,7 +513,18 @@ export default function TrackPage() {

{playing ? '播放中,暂停地址解析' : addressSettling ? '位置已变化,等待地址解析…' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || `${current?.longitude.toFixed(6)}, ${current?.latitude.toFixed(6)}`}

- : } title="先选择车辆,再开始轨迹回放" description="地图会显示完整路径、停留点、事件点和播放位置。">} + : } + title="先选择车辆,再开始轨迹回放" + description="地图会呈现完整路径、停留点、事件点与逐帧播放位置。" + steps={[ + { icon: , title: '选择车辆', description: '车牌或 VIN 精确定位' }, + { icon: , title: '确认来源', description: '自动选择最佳定位来源' }, + { icon: , title: '播放核验', description: '逐点查看速度、里程与事件' } + ]} + action={} + />} {query.isFetching ?
正在读取并加工历史轨迹
: null} {query.isError ?
query.refetch()} />
: null} 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 30e084c2..f925d785 100644 --- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts @@ -183,7 +183,8 @@ describe('V2 production entry', () => { expect(corePageSources.HistoryPage).toContain('
'); expect(corePageSources.HistoryPage).not.toContain('job) => { expect(corePageSources.TrackPage).toContain('} title="先选择车辆,再开始轨迹回放"'); + expect(corePageSources.TrackPage).toContain(' { expect(action).toHaveBeenCalledTimes(1); }); +test('renders a guided Semi empty workspace with clear ordered actions', () => { + const action = vi.fn(); + const view = render(开始查询} + />); + + expect(view.container.querySelector('.v2-workspace-empty-guide')).toBeInTheDocument(); + expect(view.container.querySelectorAll('.v2-workspace-empty-guide-step.semi-card')).toHaveLength(3); + expect(screen.getByText('历史证据')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '开始查询' })); + expect(action).toHaveBeenCalledTimes(1); +}); + test('announces recoverable errors and exposes a Semi retry action', () => { const retry = vi.fn(); render(); diff --git a/vehicle-data-platform/apps/web/src/v2/shared/AsyncState.tsx b/vehicle-data-platform/apps/web/src/v2/shared/AsyncState.tsx index e8a64d13..4bc7f3fe 100644 --- a/vehicle-data-platform/apps/web/src/v2/shared/AsyncState.tsx +++ b/vehicle-data-platform/apps/web/src/v2/shared/AsyncState.tsx @@ -1,5 +1,5 @@ import { IconAlertTriangle, IconRefresh } from '@douyinfe/semi-icons'; -import { Button, Empty, Spin, Typography } from '@douyinfe/semi-ui'; +import { Button, Card, CardGroup, Empty, Spin, Typography } from '@douyinfe/semi-ui'; import type { ReactNode } from 'react'; type StateScope = 'page' | 'panel' | 'inline'; @@ -75,6 +75,51 @@ export function PanelEmpty({ return ; } +export type WorkspaceEmptyGuideStep = { + title: string; + description: string; + icon?: ReactNode; +}; + +export function WorkspaceEmptyGuide({ + title, + description, + steps, + icon, + eyebrow, + action, + secondaryAction, + className = '' +}: { + title: string; + description: string; + steps: WorkspaceEmptyGuideStep[]; + icon?: ReactNode; + eyebrow?: string; + action?: ReactNode; + secondaryAction?: ReactNode; + className?: string; +}) { + const classes = ['v2-state-surface', 'is-empty', 'is-panel', 'v2-workspace-empty-guide', className].filter(Boolean).join(' '); + + return
+ {eyebrow ? {eyebrow} : null} + {icon} : undefined} + title={title} + description={description} + /> + + {steps.map((step, index) => + {step.icon ?? index + 1} + {step.title}{step.description} + )} + + {action || secondaryAction ?
{action}{secondaryAction}
: null} +
; +} + export function InlineError({ message, onRetry }: { message: string; onRetry?: () => void }) { return .semi-card-body { + display: grid; + min-height: 72px; + grid-template-columns: 32px minmax(0, 1fr); + align-items: center; + gap: 10px; + padding: 10px 12px !important; + text-align: left; +} + +.v2-workspace-empty-guide-step-index { + display: grid; + width: 30px; + height: 30px; + place-items: center; + border-radius: 9px; + background: #f0f5fc; + color: #3976cc; + font-size: 13px; + font-weight: 800; +} + +.v2-workspace-empty-guide-step-index .semi-icon { + font-size: 15px; +} + +.v2-workspace-empty-guide-step > .semi-card-body > span:last-child { + display: grid; + min-width: 0; + gap: 3px; +} + +.v2-workspace-empty-guide-step .semi-typography { + overflow: hidden; + text-overflow: ellipsis; +} + +.v2-workspace-empty-guide-step .semi-typography:first-child { + color: #33485f; + font-size: 12px; +} + +.v2-workspace-empty-guide-step .semi-typography:last-child { + color: #8290a2; + font-size: 9px; + line-height: 1.45; +} + +.v2-workspace-empty-guide-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 18px; +} + +.v2-workspace-empty-guide-actions > .semi-button { + min-height: 38px; + border-radius: 8px; + padding-inline: 18px; + font-weight: 700; +} + +.v2-history-empty.v2-workspace-empty-guide { + position: sticky; + left: 0; + min-height: 100%; +} + +.v2-track-empty-state.semi-card { + width: min(680px, calc(100% - 44px)); +} + +.v2-track-empty-state > .semi-card-body > .v2-workspace-empty-guide { + width: 100%; + padding: 22px; +} + +.v2-track-empty-state .v2-workspace-empty-guide-message .semi-empty-description { + max-width: 480px; +} + +.v2-track-empty-state .v2-workspace-empty-guide strong { + margin-top: 0; +} + +@media (max-width: 680px) { + .v2-workspace-empty-guide { + width: calc(100% - 20px); + padding: 20px 10px; + } + + .v2-workspace-empty-guide-eyebrow.semi-typography { + margin-bottom: 7px; + font-size: 9px; + } + + .v2-workspace-empty-guide-icon { + width: 44px; + height: 44px; + border-radius: 12px; + } + + .v2-workspace-empty-guide-message .semi-empty-title { + font-size: 15px; + } + + .v2-workspace-empty-guide-message .semi-empty-description { + max-width: 310px; + font-size: 10px; + } + + .v2-workspace-empty-guide-steps.semi-card-group { + gap: 6px; + margin-top: 16px; + } + + .v2-workspace-empty-guide-step > .semi-card-body { + min-height: 92px; + grid-template-columns: 1fr; + align-content: start; + justify-items: center; + gap: 7px; + padding: 9px 6px !important; + text-align: center; + } + + .v2-workspace-empty-guide-step-index { + width: 28px; + height: 28px; + } + + .v2-workspace-empty-guide-step .semi-typography:first-child { + font-size: 10px; + } + + .v2-workspace-empty-guide-step .semi-typography:last-child { + display: -webkit-box; + font-size: 8px; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + .v2-workspace-empty-guide-actions { + width: 100%; + margin-top: 15px; + } + + .v2-workspace-empty-guide-actions > .semi-button { + min-width: 168px; + min-height: 42px; + } + + .v2-history-empty.v2-workspace-empty-guide { + min-height: 430px; + } + + .v2-track-empty-state.semi-card { + width: calc(100% - 24px); + transform: translate(-50%, calc(-50% - 44px)); + } + + .v2-track-empty-state > .semi-card-body > .v2-workspace-empty-guide { + padding: 17px 9px; + } + + .v2-track-empty-state .v2-workspace-empty-guide-message .semi-empty-description { + max-width: 300px; + } +}