refactor: unify guided workspace empty states

This commit is contained in:
lingniu
2026-07-19 12:49:22 +08:00
parent 4e4f383988
commit dffe559160
7 changed files with 367 additions and 14 deletions

View File

@@ -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();

View File

@@ -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
? <PanelLoading className="v2-history-loading" title="正在加载当前筛选范围的历史数据" description="按车辆、时间和协议整理可追溯证据。" />
: !resultRows.length
? <PanelEmpty className="v2-history-empty"
title={keywords.length ? '当前条件没有历史记录' : '选择车辆开始查询'}
description={keywords.length ? '调整车辆、时间或数据来源后重试。' : '支持车牌或 VIN最多可同时查询 5 台车辆。'}
action={mobileLayout ? <Button className="v2-history-empty-action" theme="solid" icon={<IconSearch />} aria-label={keywords.length ? '调整查询范围' : '选择车辆并查询'} onClick={() => setFiltersCollapsed(false)}>{keywords.length ? '调整查询范围' : '选择车辆并查询'}</Button> : null}
/>
? keywords.length
? <PanelEmpty
className="v2-history-empty"
title="当前条件没有历史记录"
description="调整车辆、时间或数据来源后重试。"
action={<Button className="v2-history-empty-action" theme="light" icon={<IconSearch />} aria-label="调整查询范围" onClick={() => setFiltersCollapsed(false)}></Button>}
/>
: <WorkspaceEmptyGuide
className="v2-history-empty"
eyebrow="从一个明确的车辆范围开始"
icon={<IconSearch />}
title="选择车辆,开始核验历史证据"
description="一次最多查询 5 台车辆,结果会保留协议来源、质量说明与 RAW 证据。"
steps={[
{ icon: <IconSearch />, title: '选择车辆', description: '支持车牌、VIN 与批量粘贴' },
{ icon: <IconCalendar />, title: '确认时间', description: '按上海时区精确到分钟' },
{ icon: <IconMapPin />, title: '核验数据', description: '位置、RAW 与日里程统一检索' }
]}
action={<Button className="v2-history-empty-action" theme="solid" icon={<IconSearch />} aria-label="选择车辆并查询" onClick={() => setFiltersCollapsed(false)}></Button>}
/>
: null}
</div>
{keywords.length ? <footer><TablePagination page={page} totalPages={totalPages} info={`${(result?.total ?? 0).toLocaleString('zh-CN')}`} onPageChange={(next) => 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 条/页' }]} /></footer> : null}

View File

@@ -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() {
</div>
<p title={addressSettling ? undefined : addressQuery.data?.formattedAddress}>{playing ? '播放中,暂停地址解析' : addressSettling ? '位置已变化,等待地址解析…' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || `${current?.longitude.toFixed(6)}, ${current?.latitude.toFixed(6)}`}</p>
</Card>
</> : <Card className="v2-track-empty-state" bodyStyle={{ padding: 0 }}><Empty image={<IconMapPin />} title="先选择车辆,再开始轨迹回放" description="地图会显示完整路径、停留点、事件点和播放位置。"><Button theme="solid" icon={<IconSearch />} onClick={() => setRailCollapsed(false)}></Button></Empty></Card>}
</> : <Card className="v2-track-empty-state" bodyStyle={{ padding: 0 }}><WorkspaceEmptyGuide
eyebrow="轨迹回放"
icon={<IconMapPin />}
title="先选择车辆,再开始轨迹回放"
description="地图会呈现完整路径、停留点、事件点与逐帧播放位置。"
steps={[
{ icon: <IconSearch />, title: '选择车辆', description: '车牌或 VIN 精确定位' },
{ icon: <IconMapPin />, title: '确认来源', description: '自动选择最佳定位来源' },
{ icon: <IconPlay />, title: '播放核验', description: '逐点查看速度、里程与事件' }
]}
action={<Button theme="solid" icon={<IconSearch />} onClick={() => setRailCollapsed(false)}></Button>}
/></Card>}
{query.isFetching ? <div className="v2-track-loading"><span className="v2-spinner" /></div> : null}
{query.isError ? <div className="v2-track-error"><InlineError message={query.error instanceof Error ? query.error.message : '轨迹查询失败'} onRetry={() => query.refetch()} /></div> : null}

View File

@@ -183,7 +183,8 @@ describe('V2 production entry', () => {
expect(corePageSources.HistoryPage).toContain('<Descriptions className="v2-evidence-values"');
expect(corePageSources.HistoryPage).toContain('<Card className={`v2-export-job-card');
expect(corePageSources.HistoryPage).toContain('<Progress className="v2-export-job-progress"');
expect(corePageSources.HistoryPage).toContain('<PanelEmpty className="v2-history-empty"');
expect(corePageSources.HistoryPage).toContain('<PanelEmpty');
expect(corePageSources.HistoryPage).toContain('<WorkspaceEmptyGuide');
expect(corePageSources.HistoryPage).toContain('<PanelLoading className="v2-history-loading"');
expect(corePageSources.HistoryPage).not.toContain('row ? <><dl>');
expect(corePageSources.HistoryPage).not.toContain('job) => <article');
@@ -412,7 +413,8 @@ describe('V2 production entry', () => {
expect(corePageSources.TrackPage).toContain('<List className="v2-track-evidence-list v2-track-stop-list"');
expect(corePageSources.TrackPage).toContain('<List className="v2-track-evidence-list v2-track-event-list"');
expect(corePageSources.TrackPage).toContain('<List.Item className="v2-track-evidence-item"');
expect(corePageSources.TrackPage).toContain('<Empty image={<IconMapPin />} title="先选择车辆,再开始轨迹回放"');
expect(corePageSources.TrackPage).toContain('<WorkspaceEmptyGuide');
expect(corePageSources.TrackPage).toContain('title="先选择车辆,再开始轨迹回放"');
expect(corePageSources.TrackPage).toContain("from '../shared/WorkspaceSideSheet'");
expect(corePageSources.TrackPage).toContain('<WorkspaceSideSheet');
expect(corePageSources.TrackPage).toContain('variant="editor"');

View File

@@ -1,6 +1,6 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { EmptyState, InlineError, PageLoading, PanelEmpty, PanelLoading } from './AsyncState';
import { EmptyState, InlineError, PageLoading, PanelEmpty, PanelLoading, WorkspaceEmptyGuide } from './AsyncState';
afterEach(cleanup);
@@ -29,6 +29,27 @@ test('keeps empty states consistent while allowing a clear next action', () => {
expect(action).toHaveBeenCalledTimes(1);
});
test('renders a guided Semi empty workspace with clear ordered actions', () => {
const action = vi.fn();
const view = render(<WorkspaceEmptyGuide
eyebrow="历史证据"
title="选择车辆开始查询"
description="按范围读取可追溯数据。"
steps={[
{ title: '选择车辆', description: '车牌或 VIN' },
{ title: '确认时间', description: '上海时区' },
{ title: '核验数据', description: '位置与里程' }
]}
action={<button onClick={action}></button>}
/>);
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(<InlineError message="服务暂时不可用" onRetry={retry} />);

View File

@@ -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 <StateSurface className={className} kind="empty" scope="panel" title={title} description={description} action={action} />;
}
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 <section className={classes} role="status" aria-live="polite">
{eyebrow ? <Typography.Text className="v2-workspace-empty-guide-eyebrow" type="tertiary">{eyebrow}</Typography.Text> : null}
<Empty
className="v2-workspace-empty-guide-message"
image={icon ? <span className="v2-workspace-empty-guide-icon">{icon}</span> : undefined}
title={title}
description={description}
/>
<CardGroup className="v2-workspace-empty-guide-steps" type="grid" spacing={0}>
{steps.map((step, index) => <Card key={`${step.title}-${index}`} className="v2-workspace-empty-guide-step" bodyStyle={{ padding: 0 }}>
<span className="v2-workspace-empty-guide-step-index">{step.icon ?? index + 1}</span>
<span><Typography.Text strong>{step.title}</Typography.Text><Typography.Text type="tertiary" size="small">{step.description}</Typography.Text></span>
</Card>)}
</CardGroup>
{action || secondaryAction ? <div className="v2-workspace-empty-guide-actions">{action}{secondaryAction}</div> : null}
</section>;
}
export function InlineError({ message, onRetry }: { message: string; onRetry?: () => void }) {
return <StateSurface
className="v2-inline-state"

View File

@@ -23363,3 +23363,262 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/*
* Guided Semi UI empty workspaces.
* Empty result canvases should explain the task path instead of leaving a
* large, visually inert panel. History and track playback share this contract.
*/
.v2-workspace-empty-guide {
display: flex;
width: min(760px, calc(100% - 40px));
min-width: 0;
align-items: center;
justify-content: center;
flex-direction: column;
margin: auto;
padding: 26px;
text-align: center;
}
.v2-workspace-empty-guide-eyebrow.semi-typography {
margin-bottom: 9px;
color: #6f7f92;
font-size: 10px;
font-weight: 700;
letter-spacing: .08em;
}
.v2-workspace-empty-guide-message.semi-empty {
min-height: 0;
padding: 0;
}
.v2-workspace-empty-guide-message .semi-empty-image {
display: grid;
width: auto;
height: auto;
place-items: center;
margin-bottom: 13px;
}
.v2-workspace-empty-guide-icon {
display: grid;
width: 48px;
height: 48px;
place-items: center;
border: 1px solid #cfe0fa;
border-radius: 14px;
background: #edf5ff;
color: var(--v2-blue);
box-shadow: 0 8px 20px rgba(18, 104, 223, .1);
}
.v2-workspace-empty-guide-icon .semi-icon {
font-size: 22px;
}
.v2-workspace-empty-guide-message .semi-empty-title {
margin-top: 0;
color: #25384f;
font-size: 18px;
font-weight: 750;
letter-spacing: -.01em;
}
.v2-workspace-empty-guide-message .semi-empty-description {
max-width: 560px;
margin-top: 7px;
color: #748398;
font-size: 12px;
line-height: 1.65;
}
.v2-workspace-empty-guide-steps.semi-card-group {
display: grid;
width: 100%;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 21px;
}
.v2-workspace-empty-guide-step.semi-card {
min-width: 0;
overflow: hidden;
border: 1px solid #e0e7f0;
border-radius: 10px;
background: #fff;
box-shadow: 0 3px 12px rgba(31, 48, 70, .04);
}
.v2-workspace-empty-guide-step > .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;
}
}