feat(web): unify Semi UI workspace states

This commit is contained in:
lingniu
2026-07-18 00:59:11 +08:00
parent 159c80b0ae
commit 1cd92715a5
13 changed files with 639 additions and 46 deletions

View File

@@ -6,7 +6,7 @@ import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page } from '../../api/types';
import { accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access';
import { InlineError } from '../shared/AsyncState';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MetricActionButton } from '../shared/MetricActionButton';
import { PageHeader } from '../shared/PageHeader';
@@ -239,8 +239,8 @@ export default function AccessPage() {
{mobileLayout
? <div className="v2-access-mobile-list">{rows.map((row) => <Card key={row.vin} className={`v2-access-mobile-card${selected?.vin === row.vin ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" aria-pressed={selected?.vin === row.vin} aria-expanded={selected?.vin === row.vin} aria-label={`查看 ${row.plate || row.vin} 接入详情`} className="v2-access-mobile-action" onClick={() => setSelectedVIN(row.vin)}><span className="v2-access-mobile-card-content"><header><span><strong>{row.plate || '未绑定车牌'}</strong><small>{row.vin}</small></span><ConnectionState row={row} /></header><p>{row.oem || '品牌未维护'} · {row.model || row.company || '车型未维护'}</p><span className="v2-access-mobile-protocols">{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} />)}</span><footer><IconChevronRight /></footer></span></Button></Card>)}</div>
: <AccessVehicleTable rows={rows} selectedVIN={selectedVIN} onSelect={setSelectedVIN} />}
{vehiclesQuery.isFetching ? <div className="v2-access-loading" role="status"><Spin size="middle" tip="正在更新车辆接入状态…" /></div> : null}
{!vehiclesQuery.isFetching && !rows.length ? <Empty className="v2-access-empty" title="没有匹配车辆" description="调整车牌、协议或接入状态筛选后重试。" /> : null}
{vehiclesQuery.isFetching ? <PanelLoading className="v2-access-loading" title="正在更新车辆接入状态…" description="当前列表返回后会自动替换。" compact={Boolean(rows.length)} /> : null}
{!vehiclesQuery.isFetching && !rows.length ? <PanelEmpty className="v2-access-empty" title="没有匹配车辆" description="调整车牌、协议或接入状态筛选后重试。" /> : null}
</div>
<footer><TablePagination page={page} totalPages={totalPages} info={`${(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆`} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={limit} pageSizeLabel="每页车辆数" onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={[{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
</Card>

View File

@@ -6,7 +6,7 @@ import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { AlertEvent, AlertNotification, AlertQuery, AlertRule, AlertRuleInput, AlertStatus, MetricDefinition, Page } from '../../api/types';
import { actionLabels, alertValue, canAct, formatAlertTime, operatorLabels, ruleCondition, severityLabels, statusLabels, thresholdText } from '../domain/alert';
import { InlineError } from '../shared/AsyncState';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MetricActionButton } from '../shared/MetricActionButton';
import { PageHeader } from '../shared/PageHeader';
@@ -215,8 +215,8 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
{mobileLayout
? <div className="v2-alert-mobile-list">{rows.map((event) => <Card key={event.id} className={`v2-alert-mobile-card${selectedID === event.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-alert-mobile-action" aria-pressed={selectedID === event.id} aria-expanded={selectedID === event.id} aria-label={`查看 ${event.plate || event.vin} ${event.ruleName} 告警详情`} onClick={() => setSelection({ scope: eventScope, id: event.id })}><span className="v2-alert-mobile-card-content"><header><strong>{event.plate || '未绑定车牌'}</strong><span><SeverityTag severity={event.severity} /><StatusTag status={event.status} /></span></header><p>{event.ruleName}</p><dl><div><dt></dt><dd>{formatAlertTime(event.triggeredAt)}</dd></div><div><dt></dt><dd>{event.protocol || '—'}</dd></div><div><dt></dt><dd>{alertValue(event)}</dd></div><div><dt></dt><dd>{thresholdText(event)}</dd></div></dl><footer><IconChevronRight /></footer></span></Button></Card>)}</div>
: <AlertEventTable rows={rows} selectedID={selectedID} onSelect={(id) => setSelection({ scope: eventScope, id })} />}
{events.isFetching ? <div className="v2-alert-loading" role="status"><Spin size="middle" tip="正在更新事件…" /></div> : null}
{!events.isFetching && !rows.length ? <Empty className="v2-alert-empty" title="当前筛选条件没有告警事件" description="调整车辆、严重程度、状态或时间范围后重试。" /> : null}
{events.isFetching ? <PanelLoading className="v2-alert-loading" title="正在更新事件…" description="告警列表返回后会自动更新。" compact={Boolean(rows.length)} /> : null}
{!events.isFetching && !rows.length ? <PanelEmpty className="v2-alert-empty" title="当前筛选条件没有告警事件" description="调整车辆、严重程度、状态或时间范围后重试。" /> : null}
</div>
<footer><TablePagination page={page} totalPages={totalPages} info={`${(events.data?.total ?? 0).toLocaleString('zh-CN')}`} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={limit} onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={[{ value: 20, label: '20 条/页' }, { value: 50, label: '50 条/页' }]} /></footer>
</Card>

View File

@@ -74,6 +74,27 @@ test('preserves the exact monitor return after changing history filters', async
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
});
test('renders the shared empty state when the API returns nullable empty collections', async () => {
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
mocks.historyExports.mockResolvedValue([]);
mocks.historyData.mockResolvedValue({
category: 'location',
columns: null,
rows: null,
summary: null,
total: 0,
limit: 50,
offset: 0,
asOf: '2026-07-18 00:53:00'
} as unknown as HistoryDataResponse);
const view = renderPage('/history?keywords=LTESTNOEXIST00001&dateFrom=2026-07-18T00%3A00&dateTo=2026-07-18T00%3A53');
expect(await screen.findByText('当前条件没有历史记录')).toBeInTheDocument();
expect(view.container.querySelector('.v2-history-empty.v2-state-surface')).toBeInTheDocument();
expect(screen.queryByText('当前模块暂时无法显示')).not.toBeInTheDocument();
});
test('clears stale rows, charts, and evidence as soon as the history scope changes', async () => {
let resolveNewData!: (value: HistoryDataResponse) => void;
let resolveNewSeries!: (value: HistorySeriesResponse) => void;

View File

@@ -7,7 +7,7 @@ import { api } from '../../api/client';
import type { HistoryDataResponse, HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types';
import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, historyExportPollInterval, parseHistoryKeywords } from '../domain/history';
import { downloadBlob } from '../domain/download';
import { InlineError } from '../shared/AsyncState';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { PageHeader } from '../shared/PageHeader';
@@ -275,6 +275,8 @@ export default function HistoryPage() {
gcTime: QUERY_MEMORY.highVolumeGcTime
});
const result = dataQuery.data;
const resultRows = result?.rows ?? [];
const resultSummary = result?.summary;
const allMetrics = result?.columns ?? catalogQuery.data?.metrics.filter((metric) => metric.category === criteria.category) ?? [];
const visibleKeys = visibleByCategory[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key);
const visibleMetrics = allMetrics.filter((metric) => visibleKeys.includes(metric.key));
@@ -292,7 +294,7 @@ export default function HistoryPage() {
const selectedRow = scopedSelectedRowRef?.id === ''
? undefined
: scopedSelectedRowRef
? result?.rows.find((row) => row.id === scopedSelectedRowRef.id)
? resultRows.find((row) => row.id === scopedSelectedRowRef.id)
: undefined;
const selectRow = useCallback((row: HistoryDataRow) => {
setSelectedRowRef({ scope: dataScope, id: row.id });
@@ -325,7 +327,7 @@ export default function HistoryPage() {
const setVisibleMetrics = (keys: string[]) => setVisibleByCategory((current) => ({ ...current, [criteria.category]: keys }));
const totalPages = Math.max(1, Math.ceil((result?.total ?? 0) / limit));
const page = Math.floor(offset / limit) + 1;
const summarySources = result?.summary.sources.join('、') || '—';
const summarySources = resultSummary?.sources?.join('、') || '—';
return <div className="v2-history-page">
<MonitorReturnBar />
@@ -342,7 +344,7 @@ export default function HistoryPage() {
<fieldset className="v2-history-range"><legend></legend><div><Input aria-label="开始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => setDraft((current) => ({ ...current, dateFrom: value }))} /><span></span><Input aria-label="结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => setDraft((current) => ({ ...current, dateTo: value }))} /></div></fieldset>
<label><span id="history-category-label"></span><Select aria-labelledby="history-category-label" value={draft.category} onChange={(value) => setDraft((current) => ({ ...current, category: String(value) }))} optionList={(catalogQuery.data?.categories ?? [{ key: 'location', label: '位置数据' }, { key: 'raw', label: '原始报文' }, { key: 'mileage', label: '日里程' }]).map((item) => ({ value: item.key, label: item.label }))} /></label>
<label><span id="history-protocol-label"></span><Select aria-labelledby="history-protocol-label" value={draft.protocol} onChange={(value) => setDraft((current) => ({ ...current, protocol: String(value) }))} optionList={[{ value: '', label: '全部来源' }, { value: 'GB32960', label: 'GB32960' }, { value: 'JT808', label: 'JT808' }, { value: 'YUTONG_MQTT', label: 'YUTONG_MQTT' }]} /></label>
<div className="v2-history-toolbar-actions"><Button className="v2-primary-button" theme="solid" htmlType="submit" disabled={!parseHistoryKeywords(draft.keywords).length}></Button><Button className="v2-secondary-button" theme="light" onClick={reset}></Button>{exportAllowed ? <CreateExportButton disabled={!result?.rows.length} request={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} /> : <Tag className="v2-history-permission-tag" color="grey" type="light" size="large"></Tag>}</div>
<div className="v2-history-toolbar-actions"><Button className="v2-primary-button" theme="solid" htmlType="submit" disabled={!parseHistoryKeywords(draft.keywords).length}></Button><Button className="v2-secondary-button" theme="light" onClick={reset}></Button>{exportAllowed ? <CreateExportButton disabled={!resultRows.length} request={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} /> : <Tag className="v2-history-permission-tag" color="grey" type="light" size="large"></Tag>}</div>
</form>
</Card>
<Card className="v2-history-metrics-card" bodyStyle={{ padding: 0 }}>
@@ -358,16 +360,20 @@ export default function HistoryPage() {
{dataQuery.isError ? <InlineError message={dataQuery.error instanceof Error ? dataQuery.error.message : '历史查询失败'} onRetry={() => dataQuery.refetch()} /> : null}
<div className={`v2-history-workspace${selectedRow && !mobileLayout ? ' is-inspector-open' : ''}`}>
<div className={`v2-history-main${trendExpanded ? ' has-expanded-trend' : ''}`}>
<Card className="v2-history-summary" bodyStyle={{ padding: 0 }}><div><small></small><strong>{result?.total.toLocaleString('zh-CN') ?? '—'}</strong></div><div><small></small><strong>{result?.summary.vehicleCount ?? '—'}</strong></div><div><small></small><strong className="is-source" title={summarySources}>{summarySources}</strong></div><div><small></small><strong>{result ? `${result.summary.queryDurationMs} ms` : '—'}</strong></div></Card>
<Card className="v2-history-summary" bodyStyle={{ padding: 0 }}><div><small></small><strong>{result?.total?.toLocaleString('zh-CN') ?? '—'}</strong></div><div><small></small><strong>{resultSummary?.vehicleCount ?? '—'}</strong></div><div><small></small><strong className="is-source" title={summarySources}>{summarySources}</strong></div><div><small></small><strong>{resultSummary ? `${resultSummary.queryDurationMs} ms` : '—'}</strong></div></Card>
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} hasMetrics={Boolean(seriesMetricKey)} expanded={trendExpanded} onToggle={() => setTrendExpanded((value) => !value)} />
<Card className={`v2-history-table-card is-${density}`} bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader title="数据明细" actions={<><Button theme="borderless" aria-label="列设置" aria-haspopup="dialog" aria-expanded={columnSettingsOpen} aria-controls="v2-history-column-settings" icon={<IconSetting />} onClick={() => setColumnSettingsOpen((value) => !value)}></Button>{exportAllowed ? <Button theme="borderless" aria-label="查看导出任务" aria-haspopup="dialog" aria-expanded={exportJobsOpen} aria-controls="v2-history-export-jobs" icon={<IconDownload />} onClick={() => setExportJobsOpen(true)}></Button> : null}{!mobileLayout ? <Select aria-label="表格密度" value={density} onChange={(value) => setDensity(String(value) as typeof density)} optionList={[{ value: 'compact', label: '紧凑' }, { value: 'comfortable', label: '舒适' }]} /> : null}<Button theme="borderless" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据" icon={<IconRefresh />} /></>} />
<ColumnVisibilityPanel visible={columnSettingsOpen} metrics={allMetrics} visibleKeys={visibleKeys} onToggle={toggleMetric} onShowAll={() => setVisibleMetrics(allMetrics.map((metric) => metric.key))} onReset={() => setVisibleMetrics(allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key))} onClose={() => setColumnSettingsOpen(false)} />
<div className="v2-history-table-scroll">
{mobileLayout
? <div className="v2-history-mobile-list">{result?.rows.map((row) => <Card key={row.id} className={`v2-history-mobile-card${selectedRow?.id === row.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-history-mobile-action" aria-pressed={selectedRow?.id === row.id} aria-expanded={selectedRow?.id === row.id} aria-label={`查看 ${row.plate || row.vin} ${row.deviceTime} 数据详情`} onClick={() => selectRow(row)}><span className="v2-history-mobile-card-content"><header><span><strong>{row.plate || '未绑定车牌'}</strong><small>{row.vin}</small></span><span className={`v2-quality is-${row.quality}`}><i />{historyQualityLabel(row.quality)}</span></header><p><time>{row.deviceTime}</time><b>{row.protocol}</b></p><small className="v2-history-mobile-quality">{row.qualityReason || '平台未返回质量说明'}</small><dl>{visibleMetrics.slice(0, 4).map((metric) => <div key={metric.key}><dt>{metric.label}</dt><dd>{formatHistoryValue(row.values[metric.key], metric)}</dd></div>)}</dl><footer><IconChevronRight /></footer></span></Button></Card>)}</div>
: <HistoryDataTable rows={result?.rows ?? []} metrics={visibleMetrics} selectedRowID={selectedRow?.id} onSelect={selectRow} />}
{dataQuery.isPending && keywords.length ? <div className="v2-history-loading" role="status"><Spin size="middle" tip="正在加载当前筛选范围的历史数据…" /></div> : !result?.rows.length ? <Empty className="v2-history-empty" title={keywords.length ? '当前条件没有历史记录' : '等待查询历史数据'} description={keywords.length ? '调整车辆、时间或数据来源后重试。' : '输入车牌或 VIN最多可同时查询 5 台车辆。'} /> : null}
? <div className="v2-history-mobile-list">{resultRows.map((row) => <Card key={row.id} className={`v2-history-mobile-card${selectedRow?.id === row.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-history-mobile-action" aria-pressed={selectedRow?.id === row.id} aria-expanded={selectedRow?.id === row.id} aria-label={`查看 ${row.plate || row.vin} ${row.deviceTime} 数据详情`} onClick={() => selectRow(row)}><span className="v2-history-mobile-card-content"><header><span><strong>{row.plate || '未绑定车牌'}</strong><small>{row.vin}</small></span><span className={`v2-quality is-${row.quality}`}><i />{historyQualityLabel(row.quality)}</span></header><p><time>{row.deviceTime}</time><b>{row.protocol}</b></p><small className="v2-history-mobile-quality">{row.qualityReason || '平台未返回质量说明'}</small><dl>{visibleMetrics.slice(0, 4).map((metric) => <div key={metric.key}><dt>{metric.label}</dt><dd>{formatHistoryValue(row.values[metric.key], metric)}</dd></div>)}</dl><footer><IconChevronRight /></footer></span></Button></Card>)}</div>
: <HistoryDataTable rows={resultRows} metrics={visibleMetrics} selectedRowID={selectedRow?.id} onSelect={selectRow} />}
{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 台车辆。'} />
: null}
</div>
<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>
</Card>

View File

@@ -7,7 +7,7 @@ import { api } from '../../api/client';
import type { DailyMileageRow, MileageStatistics, Page, VehicleRow } from '../../api/types';
import { createMileageExportStream, type MileageExportStream } from '../domain/mileageExport';
import { formatZhNumber } from '../domain/formatters';
import { InlineError } from '../shared/AsyncState';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { TablePagination } from '../shared/TablePagination';
@@ -512,8 +512,8 @@ export default function StatisticsPage() {
<span><strong>{exportProgress.label}</strong><small>{exportPercent == null ? '处理中' : `${exportPercent}%`}</small></span>
<i className={exportPercent == null ? 'is-indeterminate' : ''}><b style={exportPercent == null ? undefined : { width: `${exportPercent}%` }} /></i>
</div> : null}
{resultsLoading ? <div className="v2-mileage-loading" role="status" aria-live="polite"><Spin size="middle" tip="正在查询里程" /><small></small></div> : displayVehicles.length ? <MileageTable rows={matrixRows} dates={dates} scrollRef={tableScrollRef} /> : null}
{!resultsLoading && !displayVehicles.length ? <Empty className="v2-mileage-empty" title="当前没有可展示的车辆" description="选择车牌或调整车辆授权范围后重试。" /> : null}
{resultsLoading ? <PanelLoading className="v2-mileage-loading" title="正在查询里程" description="新筛选范围返回前不会展示上一范围的数据。" /> : displayVehicles.length ? <MileageTable rows={matrixRows} dates={dates} scrollRef={tableScrollRef} /> : null}
{!resultsLoading && !displayVehicles.length ? <PanelEmpty className="v2-mileage-empty" title="当前没有可展示的车辆" description="选择车牌或调整车辆授权范围后重试。" /> : null}
<footer>{!hasVehicles && totalVehicles ? <TablePagination page={page} totalPages={totalPages} info={`${totalVehicles.toLocaleString('zh-CN')} 辆 · 每页 ${PAGE_SIZE}${exportFeedback ? ` · ${exportFeedback}` : ''}`} disabled={fleetVehicles.isFetching} onPageChange={setPage} /> : <span className="v2-table-pagination-info"> {totalVehicles.toLocaleString('zh-CN')} {exportFeedback ? ` · ${exportFeedback}` : ''}</span>}</footer>
</Card>
<footer className="v2-mileage-evidence"><span>{statistics.data?.asOf || '—'}</span><span>{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' ')}</span><span> 1 · </span></footer>

View File

@@ -11,6 +11,7 @@ import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { PanelEmpty, PanelLoading } from '../shared/AsyncState';
const customerMenus = [
{ key: 'monitor', label: '全局监控', description: '查看授权车辆的实时位置与状态' },
@@ -258,7 +259,7 @@ export default function UsersPage() {
<span><small></small><strong>{customers.length - enabledCustomers}</strong></span>
<span><small></small><strong>{grantedVehicles}</strong></span>
</div>
{users.isPending ? <div className="v2-user-list-loading" role="status"><Spin size="middle" tip="正在加载客户账号" /></div> : customers.length === 0 ? <Empty className="v2-user-list-empty" title="还没有客户账号" description="创建客户账号后,可在这里配置菜单和车辆范围。"><Button theme="solid" onClick={startCreate}></Button></Empty> : <>
{users.isPending ? <PanelLoading className="v2-user-list-loading" title="正在加载客户账号" description="账号目录和授权摘要就绪后会自动显示。" /> : customers.length === 0 ? <PanelEmpty className="v2-user-list-empty" title="还没有客户账号" description="创建客户账号后,可在这里配置菜单和车辆范围。" action={<Button theme="solid" onClick={startCreate}></Button>} /> : <>
<Input className="v2-user-list-search" aria-label="搜索客户账号" prefix={<IconSearch />} showClear value={customerKeyword} onChange={setCustomerKeyword} placeholder="搜索名称、账号或客户标识" />
<List
className="v2-customer-list"

View File

@@ -155,11 +155,15 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as
expect(input).toHaveAttribute('aria-expanded', 'false');
expect(input).toHaveAttribute('aria-controls', 'v2-vehicle-search-options');
expect(view.container.querySelector('.v2-vehicle-search-options')).not.toBeInTheDocument();
expect(vehicles).not.toHaveBeenCalled();
await waitFor(() => expect(vehicles).toHaveBeenCalledTimes(1));
expect(screen.getByRole('heading', { name: '最近上报车辆', level: 5 })).toBeInTheDocument();
expect(await screen.findByText('当前显示 1 辆')).toBeInTheDocument();
expect(screen.getAllByRole('listitem', { name: `打开 ${initialRealtime.plate} 车辆档案` })).toHaveLength(1);
fireEvent.focus(input);
expect(input).toHaveAttribute('aria-expanded', 'true');
fireEvent.change(input, { target: { value: initialRealtime.plate } });
await waitFor(() => expect(vehicles).toHaveBeenCalled());
await waitFor(() => expect(vehicles).toHaveBeenCalledTimes(2));
expect(screen.getByRole('heading', { name: '匹配车辆快捷入口', level: 5 })).toBeInTheDocument();
const option = await screen.findByRole('option', { name: `${initialRealtime.plate} ${initialRealtime.vin} JT808 选择` });
expect(view.container.querySelector('#v2-vehicle-search-options.v2-vehicle-candidate-list')).toBeInTheDocument();
expect(view.container.querySelector('#v2-vehicle-search-options')).toHaveAttribute('aria-busy', 'false');

View File

@@ -43,6 +43,12 @@ function resetWorkspaceScroll() {
content.scrollTo?.({ top: 0, left: 0, behavior: 'auto' });
}
function vehicleLastSeenLabel(value?: string) {
if (!value) return '暂无上报时间';
const normalized = value.replace('T', ' ');
return normalized.length >= 16 ? `${normalized.slice(5, 16)} 更新` : normalized;
}
function VehicleSearch() {
const navigate = useNavigate();
const { session } = usePlatformSession();
@@ -60,11 +66,11 @@ function VehicleSearch() {
const candidates = useQuery({
queryKey: ['vehicle-search-options', candidateParams.toString()],
queryFn: ({ signal }) => api.vehicles(candidateParams, signal),
enabled: candidatesOpen,
staleTime: 30_000,
gcTime: QUERY_MEMORY.optionGcTime
});
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
const quickVehicles = useMemo(() => options.slice(0, mobileLayout ? 5 : 6), [mobileLayout, options]);
useEffect(() => () => window.clearTimeout(closeTimerRef.current), []);
const openCandidates = () => {
window.clearTimeout(closeTimerRef.current);
@@ -136,6 +142,41 @@ function VehicleSearch() {
</div>
{canAdminister(session) ? <Button className="v2-profile-sync-open" theme="borderless" onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起批量同步' : '批量同步主档'}</Button> : null}
</Card>
<Card className="v2-vehicle-recent-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title={deferredKeyword ? '匹配车辆快捷入口' : '最近上报车辆'}
description={deferredKeyword ? '按当前搜索条件展示,可直接进入车辆数字档案' : '按照最新上报时间排列,无需搜索即可继续查看'}
meta={candidates.data ? `当前显示 ${formatZhNumber(quickVehicles.length)}` : '授权范围'}
/>
{candidates.isFetching && !candidates.data
? <div className="v2-vehicle-recent-state" role="status"><span className="v2-spinner" /><span></span></div>
: candidates.isError
? <div className="v2-vehicle-recent-state is-error" role="alert"><span>{candidates.error instanceof Error ? candidates.error.message : '授权车辆加载失败'}</span><Button size="small" theme="light" onClick={() => candidates.refetch()}></Button></div>
: quickVehicles.length
? <div className="v2-vehicle-recent-grid" role="list" aria-label={deferredKeyword ? '匹配车辆' : '最近上报车辆'}>
{quickVehicles.map((vehicle) => <Button
key={vehicle.vin}
className="v2-vehicle-recent-item"
theme="borderless"
type="tertiary"
role="listitem"
aria-label={`打开 ${vehicle.plate || '未绑定车牌'} 车辆档案`}
onClick={() => openVehicle(vehicle.vin)}
>
<span className="v2-vehicle-recent-identity">
<strong>{vehicle.plate || '未绑定车牌'}</strong>
<small>{vehicle.vin}</small>
</span>
<span className="v2-vehicle-recent-status">
<Tag color={vehicle.online ? 'green' : 'grey'} type="light" size="small">{vehicle.online ? '在线' : '离线'}</Tag>
<small>{vehicleLastSeenLabel(vehicle.lastSeen)}</small>
</span>
<span className="v2-vehicle-recent-protocols">{vehicle.protocols.slice(0, 2).map((protocol) => <Tag key={protocol} color="blue" type="light" size="small">{protocol}</Tag>)}</span>
<IconChevronRight className="v2-vehicle-recent-arrow" />
</Button>)}
</div>
: <Empty className="v2-vehicle-recent-empty" title={deferredKeyword ? '没有匹配车辆' : '暂无授权车辆'} description={deferredKeyword ? '调整车牌、VIN 或手机号后重试。' : '管理员分配车辆权限后会显示在这里。'} />}
</Card>
{syncOpen ? <Suspense fallback={<Card className="v2-profile-sync-panel v2-profile-sync-loading" bodyStyle={{ padding: 0 }}><span role="status"><span className="v2-spinner" /></span></Card>}><VehicleProfileSyncPanel onClose={() => setSyncOpen(false)} /></Suspense> : null}
</section>;
}

View File

@@ -128,8 +128,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('<Empty className="v2-history-empty"');
expect(corePageSources.HistoryPage).toContain('<Spin size="middle" tip="正在加载当前筛选范围的历史数据…"');
expect(corePageSources.HistoryPage).toContain('<PanelEmpty className="v2-history-empty"');
expect(corePageSources.HistoryPage).toContain('<PanelLoading className="v2-history-loading"');
expect(corePageSources.HistoryPage).not.toContain('row ? <><dl>');
expect(corePageSources.HistoryPage).not.toContain('job) => <article');
expect(corePageSources.HistoryPage).not.toContain('<section className="v2-history-trend"');
@@ -174,8 +174,8 @@ describe('V2 production entry', () => {
expect(corePageSources.AlertsPage).toContain('<Card className={`v2-alert-notification-card');
expect(corePageSources.AlertsPage).toContain('<Descriptions className="v2-alert-channel-descriptions"');
expect(corePageSources.AlertsPage).toContain('<Empty className="v2-alert-notification-empty"');
expect(corePageSources.AlertsPage).toContain('<Empty className="v2-alert-empty"');
expect(corePageSources.AlertsPage).toContain('<Spin size="middle" tip="正在更新事件…"');
expect(corePageSources.AlertsPage).toContain('<PanelEmpty className="v2-alert-empty"');
expect(corePageSources.AlertsPage).toContain('<PanelLoading className="v2-alert-loading"');
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"');
@@ -196,8 +196,8 @@ describe('V2 production entry', () => {
expect(corePageSources.AccessPage).toContain('<Descriptions className="v2-access-inspector-summary"');
expect(corePageSources.AccessPage).toContain('<Descriptions className="v2-access-protocol-descriptions"');
expect(corePageSources.AccessPage).toContain('<CardGroup className="v2-access-protocol-details"');
expect(corePageSources.AccessPage).toContain('<Empty className="v2-access-empty"');
expect(corePageSources.AccessPage).toContain('<Spin size="middle" tip="正在更新车辆接入状态…"');
expect(corePageSources.AccessPage).toContain('<PanelEmpty className="v2-access-empty"');
expect(corePageSources.AccessPage).toContain('<PanelLoading className="v2-access-loading"');
expect(corePageSources.AccessPage).toContain('className="v2-access-semi-table"');
expect(corePageSources.AccessPage).toContain('detailTriggerRow({');
expect(corePageSources.AccessPage).not.toContain('<table><thead><tr><th>车辆</th>');
@@ -226,7 +226,7 @@ describe('V2 production entry', () => {
expect(corePageSources.UsersPage).toContain('<Card className="v2-user-editor-section v2-user-identity-section"');
expect(corePageSources.UsersPage).toContain('<Card className="v2-user-editor-section v2-user-menu-section"');
expect(corePageSources.UsersPage).toContain('<Card className="v2-user-editor-section v2-user-vehicle-section"');
expect(corePageSources.UsersPage).toContain('<Empty className="v2-user-list-empty"');
expect(corePageSources.UsersPage).toContain('<PanelEmpty className="v2-user-list-empty"');
expect(corePageSources.UsersPage).not.toContain('className="v2-user-editor-empty"');
expect(corePageSources.UsersPage).toContain('<Empty className="v2-user-vehicle-empty"');
expect(corePageSources.UsersPage).not.toContain('<section>');
@@ -325,8 +325,8 @@ describe('V2 production entry', () => {
expect(corePageSources.StatisticsPage).toContain('<Table className="v2-mileage-table"');
expect(v2Styles).toContain('.v2-mileage-table.semi-table-wrapper :is(.semi-table-row-head,.semi-table-row-cell).is-plate');
expect(v2Styles).toContain('.v2-mileage-table.semi-table-wrapper :is(.semi-table-row-head,.semi-table-row-cell).is-total');
expect(corePageSources.StatisticsPage).toContain('<Spin size="middle" tip="正在查询里程"');
expect(corePageSources.StatisticsPage).toContain('<Empty className="v2-mileage-empty"');
expect(corePageSources.StatisticsPage).toContain('<PanelLoading className="v2-mileage-loading"');
expect(corePageSources.StatisticsPage).toContain('<PanelEmpty className="v2-mileage-empty"');
expect(corePageSources.StatisticsPage).not.toContain('<table className="v2-mileage-table"');
expect(corePageSources.StatisticsPage).not.toContain('<input');
expect(corePageSources.MonitorPage).toContain('<Input');

View File

@@ -0,0 +1,42 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { EmptyState, InlineError, PageLoading, PanelEmpty, PanelLoading } from './AsyncState';
afterEach(cleanup);
test('uses one semantic loading language for page and panel scopes', () => {
const view = render(<>
<PageLoading label="正在加载轨迹回放" />
<PanelLoading title="正在查询里程" description="结果返回后会自动显示。" />
</>);
expect(screen.getAllByRole('status')).toHaveLength(2);
expect(screen.getByText('正在加载轨迹回放')).toBeInTheDocument();
expect(screen.getByText('正在查询里程')).toBeInTheDocument();
expect(view.container.querySelector('.v2-state-surface.is-page.is-loading')).toBeInTheDocument();
expect(view.container.querySelector('.v2-state-surface.is-panel.is-loading')).toBeInTheDocument();
});
test('keeps empty states consistent while allowing a clear next action', () => {
const action = vi.fn();
const view = render(<>
<PanelEmpty title="还没有客户账号" description="创建后可分配菜单和车辆。" action={<button onClick={action}></button>} />
<EmptyState title="暂无符合条件的车辆" />
</>);
expect(view.container.querySelectorAll('.v2-state-empty.semi-empty')).toHaveLength(2);
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} />);
expect(screen.getByRole('alert')).toHaveTextContent('数据暂时无法加载');
expect(screen.getByRole('alert')).toHaveTextContent('服务暂时不可用');
const button = screen.getByRole('button', { name: '重试' });
expect(button).toHaveClass('semi-button');
fireEvent.click(button);
expect(retry).toHaveBeenCalledTimes(1);
});

View File

@@ -1,23 +1,88 @@
import { IconAlertTriangle, IconRefresh } from '@douyinfe/semi-icons';
import { Button, Empty, Spin, Typography } from '@douyinfe/semi-ui';
import type { ReactNode } from 'react';
export function PageLoading({ label = '正在加载车辆数据' }: { label?: string }) {
return <section className="v2-page-state" role="status" aria-live="polite">
<header><Spin size="large" /><div><Typography.Text strong>{label}</Typography.Text><Typography.Text type="tertiary" size="small"></Typography.Text></div></header>
<div className="v2-page-skeleton"><i /><i /><i /><i /></div>
type StateScope = 'page' | 'panel' | 'inline';
type StateKind = 'loading' | 'empty' | 'error';
function stateRole(kind: StateKind) {
return kind === 'error' ? 'alert' : 'status';
}
function StateSurface({
kind,
scope,
title,
description,
className = '',
action,
compact = false
}: {
kind: StateKind;
scope: StateScope;
title: string;
description?: string;
className?: string;
action?: ReactNode;
compact?: boolean;
}) {
const classes = ['v2-state-surface', `is-${kind}`, `is-${scope}`, compact ? 'is-compact' : '', className].filter(Boolean).join(' ');
return <section className={classes} role={stateRole(kind)} aria-live={kind === 'error' ? 'assertive' : 'polite'}>
{kind === 'empty'
? <Empty className={`v2-state-empty${className ? ` ${className}` : ''}`} title={title} description={description}>{action}</Empty>
: <div className="v2-state-message">
<span className="v2-state-icon">{kind === 'loading' ? <Spin size={scope === 'page' ? 'large' : 'middle'} /> : <IconAlertTriangle />}</span>
<div className="v2-state-copy"><Typography.Text strong>{title}</Typography.Text>{description ? <Typography.Text type={kind === 'error' ? 'danger' : 'tertiary'} size="small">{description}</Typography.Text> : null}</div>
{action ? <div className="v2-state-action">{action}</div> : null}
</div>}
{kind === 'loading' ? <div className="v2-state-skeleton" aria-hidden="true"><i /><i /><i /></div> : null}
</section>;
}
export function PageLoading({ label = '正在加载车辆数据' }: { label?: string }) {
return <StateSurface className="v2-page-state" kind="loading" scope="page" title={label} description="导航与筛选状态已保留,页面就绪后会自动显示。" />;
}
export function PanelLoading({
title = '正在读取数据',
description = '当前结果返回后会自动更新。',
className = '',
compact = false
}: {
title?: string;
description?: string;
className?: string;
compact?: boolean;
}) {
return <StateSurface className={className} kind="loading" scope="panel" title={title} description={description} compact={compact} />;
}
export function PanelEmpty({
title,
description,
className = '',
action
}: {
title: string;
description?: string;
className?: string;
action?: ReactNode;
}) {
return <StateSurface className={className} kind="empty" scope="panel" title={title} description={description} action={action} />;
}
export function InlineError({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<div className="v2-inline-state is-error" role="alert">
<IconAlertTriangle />
<span>{message}</span>
{onRetry ? <Button theme="light" icon={<IconRefresh />} onClick={onRetry}></Button> : null}
</div>
);
return <StateSurface
className="v2-inline-state"
kind="error"
scope="inline"
title="数据暂时无法加载"
description={message}
action={onRetry ? <Button theme="light" type="danger" icon={<IconRefresh />} aria-label="重试" onClick={onRetry}></Button> : null}
/>;
}
export function EmptyState({ title = '暂无符合条件的车辆' }: { title?: string }) {
return <div className="v2-inline-state"><Empty title={title} /></div>;
return <StateSurface className="v2-inline-state" kind="empty" scope="inline" title={title} />;
}

View File

@@ -10087,12 +10087,14 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-vehicle-search-page {
align-content: start;
place-items: start center;
gap: 14px;
overflow: auto;
background: var(--v2-bg);
padding: clamp(32px, 6vh, 64px) var(--v2-page-gutter) 32px;
padding: clamp(24px, 4vh, 40px) var(--v2-page-gutter) 32px;
}
.v2-vehicle-search-card.semi-card {
width: min(900px, 100%);
.v2-vehicle-search-card.semi-card,
.v2-vehicle-recent-card.semi-card {
width: min(1040px, 100%);
border-color: var(--v2-surface-border);
border-radius: 14px;
box-shadow: var(--v2-surface-shadow);
@@ -10201,6 +10203,105 @@ button, a { -webkit-tap-highlight-color: transparent; }
width: max-content;
margin: 16px 0 0 auto;
}
.v2-vehicle-recent-card > .semi-card-body {
overflow: hidden;
border-radius: inherit;
}
.v2-vehicle-recent-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 12px;
}
.v2-vehicle-recent-item.semi-button {
display: block;
width: 100%;
min-width: 0;
height: auto;
min-height: 82px;
border: 1px solid #e3eaf2;
border-radius: 10px;
background: #fbfcfe;
padding: 11px 34px 10px 12px;
color: #26374d;
text-align: left;
transition: border-color .16s ease, background-color .16s ease, box-shadow .16s ease, transform .16s ease;
}
.v2-vehicle-recent-item.semi-button:hover,
.v2-vehicle-recent-item.semi-button:focus-visible {
border-color: #b8d1fb;
background: #f5f9ff;
box-shadow: 0 6px 18px rgba(33, 102, 217, .08);
transform: translateY(-1px);
}
.v2-vehicle-recent-item .semi-button-content {
position: relative;
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto auto;
align-items: center;
gap: 8px;
}
.v2-vehicle-recent-identity {
display: grid;
min-width: 0;
gap: 3px;
}
.v2-vehicle-recent-identity strong {
overflow: hidden;
color: #182438;
font-size: 14px;
font-weight: 720;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-vehicle-recent-identity small,
.v2-vehicle-recent-status small {
overflow: hidden;
color: #8391a4;
font-size: 9px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-vehicle-recent-status {
display: grid;
justify-items: end;
gap: 4px;
}
.v2-vehicle-recent-protocols {
display: flex;
min-width: 0;
gap: 4px;
}
.v2-vehicle-recent-protocols .semi-tag,
.v2-vehicle-recent-status .semi-tag {
margin: 0;
}
.v2-vehicle-recent-arrow {
position: absolute;
top: 50%;
right: -24px;
color: #9aabba;
transform: translateY(-50%);
}
.v2-vehicle-recent-state {
display: flex;
min-height: 128px;
align-items: center;
justify-content: center;
gap: 9px;
color: #718096;
font-size: 12px;
}
.v2-vehicle-recent-state.is-error {
flex-direction: column;
color: var(--semi-color-danger);
}
.v2-vehicle-recent-empty.semi-empty {
padding: 22px 16px 28px;
}
@media (max-width: 680px) {
.v2-vehicle-search-page {
@@ -10212,7 +10313,8 @@ button, a { -webkit-tap-highlight-color: transparent; }
width: auto;
flex: 1 1 100%;
}
.v2-vehicle-search-card.semi-card {
.v2-vehicle-search-card.semi-card,
.v2-vehicle-recent-card.semi-card {
width: 100%;
min-width: 0;
border-radius: 12px;
@@ -10290,6 +10392,34 @@ button, a { -webkit-tap-highlight-color: transparent; }
justify-content: center;
margin-top: 12px;
}
.v2-vehicle-recent-grid {
grid-template-columns: 1fr;
gap: 0;
padding: 4px 10px 8px;
}
.v2-vehicle-recent-item.semi-button {
min-height: 62px;
border: 0;
border-bottom: 1px solid #edf1f6;
border-radius: 0;
background: transparent;
padding: 8px 28px 8px 2px;
}
.v2-vehicle-recent-item.semi-button:last-child {
border-bottom: 0;
}
.v2-vehicle-recent-item.semi-button:hover,
.v2-vehicle-recent-item.semi-button:focus-visible {
background: #f5f9ff;
box-shadow: none;
transform: none;
}
.v2-vehicle-recent-identity strong {
font-size: 13px;
}
.v2-vehicle-recent-arrow {
right: -20px;
}
}
/* Semi UI history hierarchy: keep evidence rows primary and aggregate trends explicitly on demand. */

View File

@@ -2358,3 +2358,286 @@
font-size: 10px;
}
}
/* Unified Semi UI state language: page, panel and inline recovery surfaces. */
.v2-state-surface {
box-sizing: border-box;
width: 100%;
min-width: 0;
color: #607086;
}
.v2-state-surface.is-page {
display: grid;
min-height: 100%;
align-content: center;
justify-items: center;
gap: 24px;
background:
radial-gradient(circle at 50% 35%, rgba(18, 104, 243, .06), transparent 32%),
var(--v2-bg);
padding: clamp(24px, 5vw, 54px);
}
.v2-state-surface.is-panel {
position: relative;
inset: auto;
display: grid;
min-height: 164px;
align-content: center;
justify-items: center;
gap: 18px;
border: 0;
background: linear-gradient(180deg, rgba(249, 251, 254, .72), rgba(255, 255, 255, .98));
padding: 22px;
}
.v2-state-surface.is-inline {
display: flex;
min-height: 52px;
align-items: center;
border: 1px solid #dfe7f1;
border-radius: 10px;
background: rgba(255, 255, 255, .96);
padding: 9px 12px;
}
.v2-state-surface.is-error.is-inline {
border-color: #f3c7c4;
background: #fff8f7;
color: #b42318;
}
.v2-state-message {
display: flex;
min-width: 0;
align-items: center;
justify-content: center;
gap: 12px;
}
.v2-state-icon {
display: grid;
width: 38px;
height: 38px;
flex: 0 0 auto;
place-items: center;
border-radius: 11px;
background: #edf4ff;
color: var(--v2-blue);
}
.v2-state-surface.is-error .v2-state-icon {
background: #fff0ef;
color: #d92d20;
}
.v2-state-copy {
display: grid;
min-width: 0;
gap: 4px;
}
.v2-state-copy > .semi-typography {
display: block;
margin: 0;
}
.v2-state-copy > .semi-typography-primary {
color: #293a50;
font-size: 13px;
font-weight: 700;
}
.v2-state-copy > .semi-typography-tertiary,
.v2-state-copy > .semi-typography-danger {
overflow: hidden;
color: #7e8c9f;
font-size: 10px;
line-height: 1.55;
text-overflow: ellipsis;
}
.v2-state-copy > .semi-typography-danger {
color: #a63a32;
}
.v2-state-action {
display: flex;
flex: 0 0 auto;
margin-left: 10px;
}
.v2-state-action > .semi-button {
min-height: 34px;
border-radius: 8px;
font-weight: 650;
}
.v2-state-skeleton {
display: grid;
width: min(780px, 100%);
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.v2-state-skeleton > i {
min-height: 58px;
border: 1px solid #eaf0f6;
border-radius: 10px;
background: linear-gradient(100deg, #f3f6fa 24%, #eaf1f8 38%, #f3f6fa 54%);
background-size: 300% 100%;
animation: v2-skeleton 1.4s ease infinite;
}
.v2-state-surface.is-page .v2-state-skeleton {
width: min(960px, 100%);
}
.v2-state-surface.is-page .v2-state-skeleton > i {
min-height: 92px;
}
.v2-state-surface.is-page .v2-state-skeleton > i:last-child {
min-height: 240px;
grid-column: 1 / -1;
}
.v2-state-surface.is-panel.is-compact {
position: sticky;
z-index: 3;
top: 0;
display: flex;
width: 100%;
min-height: 42px;
justify-content: center;
border-bottom: 1px solid #dfe8f2;
background: rgba(248, 251, 255, .94);
padding: 5px 12px;
backdrop-filter: blur(8px);
}
.v2-state-surface.is-panel.is-compact .v2-state-icon {
width: 28px;
height: 28px;
border-radius: 8px;
}
.v2-state-surface.is-panel.is-compact .v2-state-copy {
display: flex;
align-items: baseline;
gap: 7px;
}
.v2-state-surface.is-panel.is-compact .v2-state-skeleton {
display: none;
}
.v2-state-empty.semi-empty {
box-sizing: border-box;
width: 100%;
min-height: 140px;
justify-content: center;
padding: 20px;
}
.v2-state-empty .semi-empty-image {
height: 54px;
margin-bottom: 10px;
opacity: .8;
}
.v2-state-empty .semi-empty-title {
color: #33465d;
font-size: 13px;
font-weight: 700;
}
.v2-state-empty .semi-empty-description {
max-width: 440px;
margin-top: 6px;
color: #8491a3;
font-size: 10px;
line-height: 1.6;
}
.v2-state-empty .semi-empty-content {
margin-top: 14px;
}
.v2-state-surface.is-inline .v2-state-empty.semi-empty {
min-height: 50px;
align-items: flex-start;
padding: 4px 0;
}
.v2-state-surface.is-inline .v2-state-empty .semi-empty-image,
.v2-state-surface.is-inline .v2-state-empty .semi-empty-description {
display: none;
}
@media (max-width: 680px) {
.v2-state-surface.is-page {
gap: 18px;
padding: 18px 12px 88px;
}
.v2-state-surface.is-panel {
min-height: 142px;
padding: 18px 12px;
}
.v2-state-message {
max-width: 100%;
gap: 9px;
}
.v2-state-icon {
width: 34px;
height: 34px;
border-radius: 9px;
}
.v2-state-copy > .semi-typography-primary {
font-size: 12px;
}
.v2-state-copy > .semi-typography-tertiary,
.v2-state-copy > .semi-typography-danger {
font-size: 9px;
}
.v2-state-skeleton {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.v2-state-skeleton > i:last-child {
grid-column: 1 / -1;
}
.v2-state-surface.is-page .v2-state-skeleton > i {
min-height: 72px;
}
.v2-state-surface.is-page .v2-state-skeleton > i:last-child {
min-height: 190px;
}
.v2-state-surface.is-inline {
align-items: stretch;
padding: 9px;
}
.v2-state-surface.is-inline .v2-state-message {
width: 100%;
align-items: flex-start;
}
.v2-state-surface.is-inline .v2-state-action {
margin-left: auto;
}
.v2-state-surface.is-panel.is-compact .v2-state-copy > .semi-typography-tertiary {
display: none;
}
}