feat(web): unify semantic empty states

This commit is contained in:
lingniu
2026-07-20 03:49:28 +08:00
parent df03aaac6a
commit 5ba636419b
13 changed files with 345 additions and 46 deletions

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, Empty, Input, Select, Spin, Table, Tag, TextArea, Timeline, Typography } from '@douyinfe/semi-ui';
import { Button, Card, CardGroup, Collapse, Descriptions, Input, Select, Spin, 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';
@@ -129,7 +129,7 @@ const AlertEventTable = memo(function AlertEventTable({ rows, selectedID, compac
});
function EventInspector({ event, note, acting, actionError, editable, onNote, onAction, onClose, sheet = false }: { event?: AlertEvent; note: string; acting: boolean; actionError?: string; editable: boolean; onNote: (value: string) => void; onAction: (action: 'acknowledge' | 'close' | 'ignore') => void; onClose: () => void; sheet?: boolean }) {
if (!event) return <Card className="v2-alert-inspector" bodyStyle={{ padding: 0 }}><Empty className="v2-alert-inspector-empty" image={<IconAlarm />} title="选择告警事件" description="查看触发证据、状态时间线和处置动作。" /></Card>;
if (!event) return <Card className="v2-alert-inspector" bodyStyle={{ padding: 0 }}><PanelEmpty className="v2-alert-inspector-empty" compact tone="warning" icon={<IconAlarm />} title="选择告警事件" description="查看触发证据、状态时间线和处置动作。" /></Card>;
const actionCount = event.actions?.length ?? 0;
return <Card className={`v2-alert-inspector${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}>
{!sheet ? <WorkspacePanelHeader className="v2-alert-inspector-heading" title="事件处置" description={event.ruleName} actions={<><span className="v2-alert-inspector-status"><SeverityTag severity={event.severity} /></span><Button className="v2-alert-inspector-close" theme="borderless" icon={<IconClose />} aria-label="关闭告警详情" onClick={onClose} /></>} /> : null}
@@ -155,7 +155,7 @@ function EventInspector({ event, note, acting, actionError, editable, onNote, on
{editable ? <><TextArea maxCount={200} autosize={{ minRows: 2, maxRows: 5 }} placeholder="请输入处置说明(选填)" value={note} onChange={onNote} />{actionError ? <p className="v2-alert-action-error">{actionError}</p> : null}<div className="v2-alert-actions"><Button theme="solid" className="is-primary" disabled={acting || !canAct(event.status, 'acknowledge')} onClick={() => onAction('acknowledge')}></Button><Button theme="light" disabled={acting || !canAct(event.status, 'close')} onClick={() => onAction('close')}></Button><Button theme="borderless" disabled={acting || !canAct(event.status, 'ignore')} onClick={() => onAction('ignore')}></Button></div></> : <p className="v2-role-notice"></p>}
</Card>
<Card className="v2-alert-detail-card v2-alert-progress-card" title={<span className="v2-alert-detail-title"><strong></strong><Tag color={actionCount ? 'blue' : 'amber'} type="light" size="small">{actionCount ? `${actionCount} 条记录` : '待处置'}</Tag></span>} headerLine>
{event.actions?.length ? <Timeline className="v2-alert-timeline" aria-label="告警处理进度">{event.actions.map((item, index) => <Timeline.Item key={item.id} type={index === event.actions!.length - 1 ? 'ongoing' : 'default'} time={<span>{item.actor} · <AlertTime value={item.createdAt} /></span>}><strong>{actionLabels[item.action] ?? item.action}</strong>{item.note ? <p>{item.note}</p> : null}</Timeline.Item>)}</Timeline> : <Empty className="v2-alert-timeline-empty" title="暂无处理记录" description="事件被确认、关闭或忽略后,将在这里形成审计履历。" />}
{event.actions?.length ? <Timeline className="v2-alert-timeline" aria-label="告警处理进度">{event.actions.map((item, index) => <Timeline.Item key={item.id} type={index === event.actions!.length - 1 ? 'ongoing' : 'default'} time={<span>{item.actor} · <AlertTime value={item.createdAt} /></span>}><strong>{actionLabels[item.action] ?? item.action}</strong>{item.note ? <p>{item.note}</p> : null}</Timeline.Item>)}</Timeline> : <PanelEmpty className="v2-alert-timeline-empty" compact title="暂无处理记录" description="事件被确认、关闭或忽略后,将在这里形成审计履历。" />}
</Card>
<Collapse className="v2-alert-technical-collapse" keepDOM>
<Collapse.Panel
@@ -556,7 +556,7 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
</span>
<Tag className="v2-alert-rule-state" size="small" color={rule.enabled ? 'green' : 'grey'} type="light">{rule.enabled ? '已启用' : '已停用'}</Tag>
</Button>)}
{!rules.length ? <Empty className="v2-alert-rule-empty" title="暂无告警规则" description="新建规则后可从常用模板开始,也可直接配置自定义条件。" /> : null}
{!rules.length ? <PanelEmpty className="v2-alert-rule-empty" tone="warning" icon={<IconAlarm />} title="暂无告警规则" description="新建规则后可从常用模板开始,也可直接配置自定义条件。" /> : null}
</div>
</Card>
{!mobileLayout ? editor : null}
@@ -602,7 +602,7 @@ function NotificationsWorkspace({ editable }: { editable: boolean }) {
<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 }}>
<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 ? <Empty className="v2-alert-notification-empty" image={<IconBell />} title="暂无站内通知" description="告警触发或状态变更后,通知会在这里形成可追溯记录。" /> : null}</div>
</Card>)}</CardGroup> : !notifications.isError ? <PanelEmpty className="v2-alert-notification-empty" compact tone="primary" icon={<IconBell />} title="暂无站内通知" description="告警触发或状态变更后,通知会在这里形成可追溯记录。" /> : null}</div>
<footer className="v2-alert-notification-pagination"><TablePagination page={page} totalPages={totalPages} info={`${total.toLocaleString('zh-CN')} 条 · 本页 ${items.length.toLocaleString('zh-CN')}`} disabled={notifications.isFetching} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={limit} pageSizeLabel="每页通知数" onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={[{ value: 20, label: '20 条/页' }, { value: 50, label: '50 条/页' }]} /></footer>
<Collapse className="v2-alert-channel-collapse" keepDOM>
<Collapse.Panel itemKey="external-channels" header={<span className="v2-alert-channel-heading"><span><strong></strong><small></small></span><Tag color="grey" type="light" size="small">3 · </Tag></span>}>

View File

@@ -2,14 +2,14 @@ import {
IconChevronLeft, IconFilter, IconList, IconMapPin,
IconQrCode, IconRefresh, IconSearch
} from '@douyinfe/semi-icons';
import { Button, Card, Empty, Input, Select, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { Button, Card, Input, Select, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
import { lazy, memo, Suspense, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
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 } from '../shared/AsyncState';
import { EmptyState, InlineError, PanelEmpty } from '../shared/AsyncState';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { TablePagination } from '../shared/TablePagination';
import { ProtocolTag } from '../shared/ProtocolTag';
@@ -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 ? <Empty className="v2-monitor-table-empty" 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 ? <Empty className="v2-monitor-table-empty" title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
{!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}
<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>;
}

View File

@@ -1,11 +1,11 @@
import { IconPulse, IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, Progress, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
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 { 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 } from '../shared/AsyncState';
import { InlineError, PanelEmpty } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { PlatformTime } from '../shared/PlatformTime';
@@ -408,7 +408,7 @@ function SourceDiagnosticWorkspace() {
actions={selected ? <Button theme="light" icon={<IconRefresh />} onClick={() => diagnostic.refetch()} disabled={diagnostic.isFetching}></Button> : null}
/>
{diagnostic.isError ? <InlineError message={diagnostic.error.message} onRetry={() => diagnostic.refetch()} /> : null}
{!selected ? <Empty className="v2-source-empty" title="先选择一辆车" description="将展示所有协议和同协议多终端、推荐原因、上报周期与可审计策略。" /> : 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}
{data ? <>
<section className="v2-source-summary-region" role="group" aria-label="车辆来源诊断概览">
@@ -594,18 +594,24 @@ export default function OperationsPage() {
<div className="v2-ops-source-coverage"><span><strong>线</strong><b>{source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}</b></span><Progress aria-label={`${source.protocol} 在线覆盖率`} percent={onlineRate} showInfo={false} stroke="var(--v2-blue)" /><small>{number(onlineRate)}% 线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''}</small></div>
<div className="v2-ops-source-evidence"><strong></strong><p>{source.evidence}</p></div><div className="v2-ops-source-action"><strong></strong><p>{source.action}</p></div><footer><Tag color="green" type="light" size="small"></Tag><span>{source.acceptance}</span></footer>
</Card>;
})}</CardGroup> : <Empty className="v2-ops-source-empty" title="暂无协议来源" description="当前响应没有可展示的协议来源就绪度。" /> : readinessUnavailable ? <Empty className="v2-ops-source-empty is-error" title="协议来源证据不可用" description="运行证据仍可查看;请使用上方重试恢复协议覆盖判断。" /> : <div className="v2-ops-source-loading" role="status"><Spin size="middle" tip="正在读取协议来源就绪度" /></div>}</Card>
})}</CardGroup> : <PanelEmpty className="v2-ops-source-empty" icon={<IconPulse />} title="暂无协议来源" description="当前响应没有可展示的协议来源就绪度。" /> : readinessUnavailable ? <PanelEmpty
className="v2-ops-source-empty is-error"
tone="danger"
icon={<IconAlertTriangle />}
title="协议来源证据不可用"
description="运行证据仍可查看;恢复来源证据后再判断协议覆盖。"
/> : <div className="v2-ops-source-loading" role="status"><Spin size="middle" tip="正在读取协议来源就绪度" /></div>}</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>;
}) : <Empty className="v2-ops-link-empty" title={data ? '暂无链路探针' : '正在读取链路状态'} description={data ? '当前响应没有可展示的数据链路证据。' : '全局健康数据返回后将在这里更新。'} />}</div></Card>
}) : <PanelEmpty className="v2-ops-link-empty" compact icon={<IconPulse />} title={data ? '暂无链路探针' : '正在读取链路状态'} description={data ? '当前响应没有可展示的数据链路证据。' : '全局健康数据返回后将在这里更新。'} />}</div></Card>
<Card className="v2-ops-panel v2-ops-runtime" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="运行时安全" description="生产模式、写入探针与代理配置" meta={<HealthTag status={!data ? 'unknown' : runtimeIssueCount + capacityIssueCount > 0 ? 'warning' : 'ok'}>{!data ? '读取中' : runtimeIssueCount + capacityIssueCount > 0 ? `${runtimeIssueCount + capacityIssueCount} 项关注` : '配置正常'}</HealthTag>} /><Descriptions className="v2-ops-runtime-descriptions" align="left" size="small" data={[
{ key: '生产数据模式', value: <HealthTag status={data?.runtime.dataMode === 'production' ? 'ok' : 'error'}>{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'}</HealthTag> },
{ key: 'MySQL 写探针', value: <HealthTag status={data?.mysqlWritable ? 'ok' : 'error'}>{data?.mysqlWritable ? '正常' : '异常'}</HealthTag> },
{ key: 'TDengine 写探针', value: <HealthTag status={data?.tdengineWritable ? 'ok' : 'error'}>{data?.tdengineWritable ? '正常' : '异常'}</HealthTag> },
{ key: '请求超时', value: `${data?.runtime.requestTimeoutMs ?? '—'} ms` },
{ key: '高德安全代理', value: <HealthTag status={data?.runtime.amapSecurityProxyEnabled && !data?.runtime.amapSecurityCodeExposed ? 'ok' : 'warning'}>{data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'}</HealthTag> }
]} />{capacityFindings.length ? <div className="v2-ops-capacity-findings">{capacityFindings.map((item) => <Card className="v2-ops-capacity-finding" key={item}><HealthTag status="warning"></HealthTag><p>{item}</p></Card>)}</div> : <Empty className="v2-ops-capacity-empty" image={<IconTickCircle />} title="容量检查通过" description="当前没有需要处理的容量风险。" />}</Card></div>
]} />{capacityFindings.length ? <div className="v2-ops-capacity-findings">{capacityFindings.map((item) => <Card className="v2-ops-capacity-finding" key={item}><HealthTag status="warning"></HealthTag><p>{item}</p></Card>)}</div> : <PanelEmpty className="v2-ops-capacity-empty" compact tone="success" icon={<IconTickCircle />} title="容量检查通过" description="当前没有需要处理的容量风险。" />}</Card></div>
</> : null}
</section>
</div>;

View File

@@ -1,10 +1,10 @@
import { IconAlertTriangle, IconChevronDown, IconChevronRight, IconChevronUp, IconClose, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { Button, Card, Empty, Input, RadioGroup, Select, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { Button, Card, Input, RadioGroup, Select, Spin, 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 } from '../shared/AsyncState';
import { InlineError, PanelEmpty } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { PlatformTime } from '../shared/PlatformTime';
import { TablePagination } from '../shared/TablePagination';
@@ -268,6 +268,13 @@ export default function ReconciliationCenter() {
const [selectedID, setSelectedID] = useState('');
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const [trendExpanded, setTrendExpanded] = useState(false);
const resetFilters = () => {
setKeyword('');
setStatus('active');
setSeverity('all');
setRuleCode('all');
setOffset(0);
};
const closeDetail = () => setSelectedID('');
const openDetail = (id: string) => {
setTrendExpanded(false);
@@ -439,7 +446,14 @@ export default function ReconciliationCenter() {
</Button>
</Card>)}</div>}
{issues.isPending ? <div className="v2-reconcile-loading" role="status"><Spin size="middle" tip="正在读取差异队列…" /></div> : null}
{!issues.isPending && !issueRows.length ? <Empty className="v2-reconcile-empty" title="当前筛选条件没有差异" description="调整状态、等级、规则或搜索条件后重试。" /> : null}
{!issues.isPending && !issueRows.length ? <PanelEmpty
className="v2-reconcile-empty"
tone="success"
icon={<IconSearch />}
title="当前筛选条件没有差异"
description="当前范围没有待复核证据,可重置筛选返回活跃差异。"
action={<Button theme="light" type="primary" icon={<IconRefresh />} onClick={resetFilters}>重置筛选</Button>}
/> : null}
</div>
<footer className="v2-reconcile-pagination"><TablePagination page={currentPage} totalPages={totalPages} info={` ${(page?.total ?? 0).toLocaleString('zh-CN')} · ${activeCount} `} onPageChange={(next) => setOffset((next - 1) * pageSize)} /></footer>
</div>

View File

@@ -3,7 +3,7 @@ import {
IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed,
IconEyeOpened, IconList, IconMapPin, IconPause, IconPlay, IconRefresh, IconRoute, IconSearch
} from '@douyinfe/semi-icons';
import { Button, Card, Descriptions, Empty, Input, List, Select, Slider, Tag } from '@douyinfe/semi-ui';
import { Button, Card, Descriptions, Input, List, Select, Slider, Tag } from '@douyinfe/semi-ui';
import { FormEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
@@ -11,7 +11,7 @@ import type { TrackPlaybackEvent, TrackPlaybackResponse, VehicleRow } from '../.
import { protocolDisplayLabel } from '../domain/protocols';
import { buildTrackRailSelection, compactTrackWindowLabel, downloadTrackCsv, formatDuration, sampledEventIndex, TRACK_ADDRESS_SETTLE_MS, trackAddressCoordinate, trackPlaybackInterval, trackPointMileageAvailable, type TrackAddressCoordinate } from '../domain/track';
import { TrackMap } from '../map/TrackMap';
import { InlineError, WorkspaceEmptyGuide } from '../shared/AsyncState';
import { InlineError, PanelEmpty, WorkspaceEmptyGuide } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { ProtocolTag } from '../shared/ProtocolTag';
import { SegmentedTabs } from '../shared/SegmentedTabs';
@@ -310,9 +310,9 @@ const TrackRail = memo(function TrackRail({ draft, track, summaryItems, loading,
/>
<SegmentedTabs className="v2-track-rail-tabs" ariaLabel="轨迹明细分类" value={tab} onChange={onTab} items={[{ key: 'stops', label: '停留点', count: track?.stops.length ?? 0 }, { key: 'events', label: '事件点', count: track?.events.length ?? 0 }, { key: 'overview', label: '概览' }]} />
<div className="v2-track-rail-scroll">
{!track ? <Empty className="v2-track-rail-empty" image={<IconMapPin />} title="选择车辆后查询轨迹" description="停留点、轨迹事件与行程证据会在这里统一呈现。" /> : null}
{track && tab === 'stops' ? <List className="v2-track-evidence-list v2-track-stop-list">{track.stops.map((stop, index) => <List.Item className="v2-track-evidence-item" key={`${stop.startTime}-${index}`}><Button theme="borderless" type="tertiary" className={`v2-track-list-action${activeStopIndexes.includes(index) ? ' is-active' : ''}`} onClick={() => onSelectIndex(stop.sampledIndex)}><i>{index + 1}</i><span><strong>{dateTime(stop.startTime)}</strong><small> {formatDuration(stop.durationSeconds)} · {stop.pointCount} </small></span><em>{time(stop.endTime)}</em></Button></List.Item>)}{!track.stops.length ? <Empty className="v2-track-list-empty" image={<IconMapPin />} title="当前没有停留点" description="时间窗内没有超过 3 分钟的连续停留。" /> : null}</List> : null}
{track && tab === 'events' ? <List className="v2-track-evidence-list v2-track-event-list">{track.events.map((event, index) => { const sampled = sampledEventIndex(event, track.points.length, track.summary.pointCount); return <List.Item className="v2-track-evidence-item" key={`${event.type}-${event.time}-${index}`}><Button theme="borderless" type="tertiary" className={`v2-track-list-action${activeEventIndexes.includes(index) ? ' is-active' : ''}`} onClick={() => onSelectIndex(sampled)}><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{dateTime(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></Button></List.Item>; })}{!track.events.length ? <Empty className="v2-track-list-empty" image={<IconMapPin />} title="当前没有事件点" description="时间窗内没有识别到启停、急加速或异常间隔。" /> : null}</List> : null}
{!track ? <PanelEmpty className="v2-track-rail-empty" compact tone="primary" icon={<IconMapPin />} title="选择车辆后查询轨迹" description="停留点、轨迹事件与行程证据会在这里统一呈现。" /> : null}
{track && tab === 'stops' ? <List className="v2-track-evidence-list v2-track-stop-list">{track.stops.map((stop, index) => <List.Item className="v2-track-evidence-item" key={`${stop.startTime}-${index}`}><Button theme="borderless" type="tertiary" className={`v2-track-list-action${activeStopIndexes.includes(index) ? ' is-active' : ''}`} onClick={() => onSelectIndex(stop.sampledIndex)}><i>{index + 1}</i><span><strong>{dateTime(stop.startTime)}</strong><small> {formatDuration(stop.durationSeconds)} · {stop.pointCount} </small></span><em>{time(stop.endTime)}</em></Button></List.Item>)}{!track.stops.length ? <PanelEmpty className="v2-track-list-empty" compact icon={<IconMapPin />} title="当前没有停留点" description="时间窗内没有超过 3 分钟的连续停留。" /> : null}</List> : null}
{track && tab === 'events' ? <List className="v2-track-evidence-list v2-track-event-list">{track.events.map((event, index) => { const sampled = sampledEventIndex(event, track.points.length, track.summary.pointCount); return <List.Item className="v2-track-evidence-item" key={`${event.type}-${event.time}-${index}`}><Button theme="borderless" type="tertiary" className={`v2-track-list-action${activeEventIndexes.includes(index) ? ' is-active' : ''}`} onClick={() => onSelectIndex(sampled)}><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{dateTime(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></Button></List.Item>; })}{!track.events.length ? <PanelEmpty className="v2-track-list-empty" compact icon={<IconMapPin />} title="当前没有事件点" description="时间窗内没有识别到启停、急加速或异常间隔。" /> : null}</List> : null}
{track && tab === 'overview' ? <OverviewPanel track={track} /> : null}
</div>
</section>

View File

@@ -16,7 +16,7 @@ import { formatZhNumber, vehicleOperationStatusLabels } from '../domain/formatte
import { protocolDisplayLabel, protocolSourceLabel } from '../domain/protocols';
import { isValidAMapCoordinate } from '../../integrations/amap';
import { FleetMap } from '../map/FleetMap';
import { InlineError, PageLoading } from '../shared/AsyncState';
import { InlineError, PageLoading, PanelEmpty } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { PlatformTime } from '../shared/PlatformTime';
import { ProtocolTag } from '../shared/ProtocolTag';
@@ -134,7 +134,7 @@ function Events({ detail }: { detail: VehicleDetail }) {
className="v2-event-list"
dataSource={events}
split={false}
emptyContent={<Empty className="v2-event-empty" title="暂无可用事件证据" description="车辆产生质量提醒或来源状态变化后会显示在这里。" />}
emptyContent={<PanelEmpty className="v2-event-empty" compact tone="warning" icon={<IconAlarm />} title="暂无可用事件证据" description="车辆产生质量提醒或来源状态变化后会显示在这里。" />}
renderItem={(event, index) => <List.Item className={`v2-event-row is-${event.tone}`} key={`${event.title}-${event.time}-${index}`}>
<span className="v2-event-icon">{event.tone === 'success' ? <IconTickCircle /> : <IconAlarm />}</span>
<div><strong>{event.title}</strong><p>{event.detail}</p></div><time title={fmt(event.time)}>{eventTimeLabel(event.time)}</time>
@@ -284,7 +284,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
: error
? <div className="v2-telemetry-state is-error" role="alert"><strong></strong><span>{error}</span></div>
: visibleMetrics.length === 0
? <Empty className="v2-telemetry-empty" title="暂无可展示字段" description={`该协议最近 ${data?.scannedFrames ?? 0} 帧没有可展示的标量遥测。`} />
? <PanelEmpty className="v2-telemetry-empty" compact icon={<IconBox />} title="暂无可展示字段" description={`该协议最近 ${data?.scannedFrames ?? 0} 帧没有可展示的标量遥测。`} />
: mobileLayout
? <List className="v2-telemetry-mobile-list" dataSource={visibleMetrics} split={false} renderItem={(item) => <List.Item className="v2-telemetry-mobile-item" key={telemetryRowKey(item)}>
<header><TelemetryFieldCell item={item} /><TelemetryValueCell item={item} /></header>

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { IconArrowRight, IconBox, IconChevronRight, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { Button, Card, Empty, Input, Table, Tag } from '@douyinfe/semi-ui';
import { Button, Card, Input, Table, Tag } from '@douyinfe/semi-ui';
import { type FormEvent, lazy, Suspense, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../api/client';
@@ -10,6 +10,7 @@ import { canAdminister } from '../auth/session';
import { formatZhNumber } from '../domain/formatters';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
import { PanelEmpty } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { ProtocolTag } from '../shared/ProtocolTag';
@@ -405,7 +406,16 @@ export default function VehicleSearchWorkspace() {
loading={candidates.isFetching}
empty={null}
/>
: <Empty className="v2-vehicle-recent-empty" title={deferredKeyword ? '没有匹配车辆' : '暂无授权车辆'} description={deferredKeyword ? '调整车牌、VIN 或手机号后重试。' : '管理员分配车辆权限后会显示在这里。'} />}
: <PanelEmpty
className="v2-vehicle-recent-empty"
tone="primary"
icon={<IconSearch />}
title={deferredKeyword ? '没有匹配车辆' : '暂无授权车辆'}
description={deferredKeyword ? '当前范围没有结果,可清除搜索返回完整车辆目录。' : '管理员分配车辆权限后会显示在这里。'}
action={deferredKeyword
? <Button theme="light" type="primary" icon={<IconRefresh />} onClick={() => { setKeyword(''); setDraftKeyword(''); setPage(1); setCandidatesOpen(false); }}></Button>
: <Button theme="light" type="primary" icon={<IconRefresh />} loading={candidates.isFetching} onClick={() => candidates.refetch()}></Button>}
/>}
{total > 0 ? <footer className="v2-vehicle-directory-pagination"><TablePagination page={page} totalPages={totalPages} info={`${total.toLocaleString('zh-CN')} 辆 · 本页 ${pageVehicles.length.toLocaleString('zh-CN')}`} disabled={candidates.isFetching} onPageChange={(next) => { setPage(next); setCandidatesOpen(false); }} /></footer> : null}
</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}

View File

@@ -153,17 +153,17 @@ describe('V2 production entry', () => {
expect(corePageSources.MonitorPage).not.toContain('<aside className="v2-detail-peek"');
expect(corePageSources.MonitorPage).not.toContain('<section className="v2-event-strip"');
expect(corePageSources.MonitorPage).toContain('<Table className="v2-monitor-table"');
expect(corePageSources.MonitorPage).toContain('<Empty className="v2-monitor-table-empty"');
expect(corePageSources.MonitorPage).toContain('<PanelEmpty className="v2-monitor-table-empty"');
expect(corePageSources.MonitorPage).toContain('<Spin size="small" tip="正在更新车辆实时数据…"');
expect(corePageSources.MonitorPage).not.toContain('<table><thead><tr><th>车辆</th>');
expect(sourceEvidenceSource).toContain("import { Button, Card, Empty, Input, Spin, Tag } from '@douyinfe/semi-ui'");
expect(sourceEvidenceSource).toContain("import { Button, Card, Input, Spin, Tag } from '@douyinfe/semi-ui'");
expect(sourceEvidenceSource).toContain('<Card className={`v2-source-evidence');
expect(sourceEvidenceSource).toContain('<Card className={`v2-source-evidence-card');
expect(sourceEvidenceSource).toContain('<WorkspaceDetailSideSheet');
expect(sourceEvidenceSource).toContain('className="v2-source-evidence-sidesheet"');
expect(sourceEvidenceSource).toContain('<Tag className="is-recommended"');
expect(sourceEvidenceSource).toContain('<Spin size="small"');
expect(sourceEvidenceSource).toContain('<Empty className="v2-source-evidence-empty"');
expect(sourceEvidenceSource).toContain('<PanelEmpty className="v2-source-evidence-empty"');
expect(sourceEvidenceSource).not.toContain('<article className={`v2-source-evidence-card');
expect(sourceEvidenceSource).not.toContain('<section className={`v2-source-evidence');
expect(workspaceDetailSideSheetSource).toContain('variant="detail"');
@@ -243,7 +243,7 @@ describe('V2 production entry', () => {
expect(corePageSources.AlertsPage).not.toContain('<button');
expect(corePageSources.AlertsPage).toContain('<Descriptions className="v2-alert-descriptions"');
expect(corePageSources.AlertsPage).toContain('<Timeline className="v2-alert-timeline"');
expect(corePageSources.AlertsPage).toContain('<Empty className="v2-alert-inspector-empty"');
expect(corePageSources.AlertsPage).toContain('<PanelEmpty className="v2-alert-inspector-empty"');
expect(corePageSources.AlertsPage).toContain('<Card className="v2-alert-detail-card v2-alert-focus-card"');
expect(corePageSources.AlertsPage).toContain('<Collapse className="v2-alert-technical-collapse"');
expect(corePageSources.AlertsPage).not.toContain('<section><h3>事件信息</h3>');
@@ -254,7 +254,7 @@ describe('V2 production entry', () => {
expect(corePageSources.AlertsPage).toContain('<CardGroup className="v2-alert-notification-cards"');
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('<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).not.toContain('<article className={item.read');
@@ -372,13 +372,13 @@ describe('V2 production entry', () => {
expect(corePageSources.OperationsPage).toContain('<article className={`v2-ops-link-card');
expect(corePageSources.OperationsPage).not.toContain('<Card className={`v2-ops-link-card');
expect(corePageSources.OperationsPage).toContain('<Descriptions className="v2-ops-runtime-descriptions"');
expect(corePageSources.OperationsPage).toContain('<Empty className="v2-ops-capacity-empty"');
expect(corePageSources.OperationsPage).toContain('<PanelEmpty className="v2-ops-capacity-empty"');
expect(corePageSources.OperationsPage).toContain('<CardGroup className="v2-ops-source-list"');
expect(corePageSources.OperationsPage).toContain('<Card className={`v2-ops-source-card');
expect(corePageSources.OperationsPage).not.toContain('<article key={item.name}');
expect(corePageSources.OperationsPage).not.toContain('<article key={source.protocol}');
expect(corePageSources.OperationsPage).not.toContain('<dl><div><dt>生产数据模式</dt>');
expect(corePageSources.OperationsPage).toContain('<Empty className="v2-source-empty"');
expect(corePageSources.OperationsPage).toContain('<PanelEmpty className="v2-source-empty"');
expect(corePageSources.OperationsPage).toContain('<Spin size="large"');
expect(corePageSources.OperationsPage).toContain('className="v2-source-summary-rail"');
expect(corePageSources.OperationsPage).toContain('ariaLabel="车辆来源诊断概览"');
@@ -401,7 +401,8 @@ describe('V2 production entry', () => {
expect(reconciliationSource).toContain('<Table className="v2-reconcile-table"');
expect(reconciliationSource).toContain('detailTriggerRow({');
expect(reconciliationSource).toContain('<Card key={item.id} className={`v2-reconcile-mobile-card');
expect(reconciliationSource).toContain('<Empty className="v2-reconcile-empty"');
expect(reconciliationSource).toContain('<PanelEmpty');
expect(reconciliationSource).toContain('className="v2-reconcile-empty"');
expect(reconciliationSource).toContain('<Spin size="middle" tip="正在读取差异队列…"');
expect(reconciliationSource).not.toContain('<table className="v2-reconcile-table"');
expect(detailTriggerRowSource).toContain("'aria-expanded': expanded");
@@ -425,7 +426,7 @@ describe('V2 production entry', () => {
expect(corePageSources.TrackPage).toContain('<Card className="v2-track-playback-dock"');
expect(corePageSources.TrackPage).toContain('<section className="v2-track-overview-section">');
expect(corePageSources.TrackPage).toContain('<Descriptions className="v2-track-overview-descriptions"');
expect(corePageSources.TrackPage).toContain('<Empty className="v2-track-rail-empty"');
expect(corePageSources.TrackPage).toContain('<PanelEmpty className="v2-track-rail-empty"');
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"');

View File

@@ -1,3 +1,4 @@
import { IconSearch } from '@douyinfe/semi-icons';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { EmptyState, InlineError, PageLoading, PanelEmpty, PanelLoading, WorkspaceEmptyGuide } from './AsyncState';
@@ -20,11 +21,19 @@ test('uses one semantic loading language for page and panel scopes', () => {
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>} />
<PanelEmpty
title="还没有客户账号"
description="创建后可分配菜单和车辆。"
tone="primary"
compact
icon={<IconSearch />}
action={<button onClick={action}></button>}
/>
<EmptyState title="暂无符合条件的车辆" />
</>);
expect(view.container.querySelectorAll('.v2-state-empty.semi-empty')).toHaveLength(2);
expect(view.container.querySelector('.v2-state-surface.tone-primary.is-compact .v2-state-empty-icon')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '创建账号' }));
expect(action).toHaveBeenCalledTimes(1);
});

View File

@@ -4,6 +4,7 @@ import type { ReactNode } from 'react';
type StateScope = 'page' | 'panel' | 'inline';
type StateKind = 'loading' | 'empty' | 'error';
export type WorkspaceStateTone = 'neutral' | 'primary' | 'success' | 'warning' | 'danger';
const PAGE_LOADING_SKELETON = <div className="v2-state-skeleton v2-page-skeleton" aria-hidden="true"><i /><i /><i /><i /></div>;
const PANEL_LOADING_SKELETON = <div className="v2-state-skeleton" aria-hidden="true"><i /><i /><i /></div>;
@@ -19,7 +20,9 @@ function StateSurface({
description,
className = '',
action,
compact = false
compact = false,
tone = 'neutral',
icon
}: {
kind: StateKind;
scope: StateScope;
@@ -28,12 +31,19 @@ function StateSurface({
className?: string;
action?: ReactNode;
compact?: boolean;
tone?: WorkspaceStateTone;
icon?: ReactNode;
}) {
const classes = ['v2-state-surface', `is-${kind}`, `is-${scope}`, compact ? 'is-compact' : '', className].filter(Boolean).join(' ');
const classes = ['v2-state-surface', `is-${kind}`, `is-${scope}`, `tone-${tone}`, 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>
? <Empty
className={`v2-state-empty${className ? ` ${className}` : ''}`}
image={icon ? <span className="v2-state-empty-icon">{icon}</span> : undefined}
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>
@@ -65,14 +75,30 @@ export function PanelEmpty({
title,
description,
className = '',
action
action,
compact = false,
tone = 'neutral',
icon
}: {
title: string;
description?: string;
className?: string;
action?: ReactNode;
compact?: boolean;
tone?: WorkspaceStateTone;
icon?: ReactNode;
}) {
return <StateSurface className={className} kind="empty" scope="panel" title={title} description={description} action={action} />;
return <StateSurface
className={className}
kind="empty"
scope="panel"
title={title}
description={description}
action={action}
compact={compact}
tone={tone}
icon={icon}
/>;
}
export type WorkspaceEmptyGuideStep = {

View File

@@ -1,11 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { Button, Card, Empty, Input, Spin, Tag } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import { Button, Card, Input, Spin, Tag } from '@douyinfe/semi-ui';
import { useMemo, useState } from 'react';
import { api } from '../../api/client';
import type { VehicleLocationSourceEvidence, VehicleMileageSourceEvidence } from '../../api/types';
import { protocolDisplayLabel, protocolSourceLabel } from '../domain/protocols';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { QUERY_MEMORY } from '../queryPolicy';
import { PanelEmpty } from './AsyncState';
import { WorkspaceDetailSideSheet } from './WorkspaceDetailSideSheet';
function today() {
@@ -126,7 +128,7 @@ export function VehicleSourceEvidencePanel({
</div>
{query.data.locationSources.length ? <section className="v2-source-evidence-group"><header><strong></strong><span>{query.data.locationConflict ? `后台检测到位置冲突${query.data.conflictDistanceM == null ? '' : ` · ${number(query.data.conflictDistanceM)} m`}` : '推荐来源与备用来源并列展示'}</span></header><div>{query.data.locationSources.map((source, index) => <LocationSourceCard key={`${source.protocol}-${source.sourceLabel}-${source.terminalLabel}-${index}`} source={source} />)}</div></section> : null}
{query.data.mileageSources.length ? <section className="v2-source-evidence-group"><header><strong>{query.data.mileageDate} </strong><span> {number(query.data.comparison.dailyMileageDeltaKm)} km</span></header><div>{query.data.mileageSources.map((source, index) => <MileageSourceCard key={`${source.protocol}-${source.sourceLabel}-${source.terminalLabel}-${index}`} source={source} />)}</div></section> : null}
{!query.data.locationSources.length && !query.data.mileageSources.length ? <Empty className="v2-source-evidence-empty" title="暂无来源证据" description="该车辆当前没有可展示的位置或里程来源。" /> : null}
{!query.data.locationSources.length && !query.data.mileageSources.length ? <PanelEmpty className="v2-source-evidence-empty" compact icon={<IconSearch />} title="暂无来源证据" description="该车辆当前没有可展示的位置或里程来源。" /> : null}
</> : null}
</div>;

View File

@@ -27344,3 +27344,184 @@
line-height: 14px;
}
}
/*
* Semi UI semantic panel states.
* Empty evidence surfaces share one visual and interaction contract instead
* of inheriting page-specific illustrations, spacing and typography.
*/
.v2-state-surface.is-empty {
--v2-state-accent: #70839a;
--v2-state-soft: #f4f7fa;
--v2-state-border: #dfe6ee;
position: relative;
display: flex;
width: 100%;
min-width: 0;
min-height: 190px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--v2-state-border);
border-radius: 10px;
background: linear-gradient(180deg, var(--v2-state-soft) 0, #fff 58%);
padding: 18px;
}
.v2-state-surface.is-empty::before {
position: absolute;
z-index: 0;
top: 0;
right: 0;
left: 0;
height: 2px;
background: var(--v2-state-accent);
content: "";
opacity: .88;
}
.v2-state-surface.is-empty.tone-primary {
--v2-state-accent: #1769dc;
--v2-state-soft: #f2f7ff;
--v2-state-border: #d7e5f8;
}
.v2-state-surface.is-empty.tone-success {
--v2-state-accent: #1b9b6a;
--v2-state-soft: #f2faf7;
--v2-state-border: #d7ece4;
}
.v2-state-surface.is-empty.tone-warning {
--v2-state-accent: #d88622;
--v2-state-soft: #fff8ef;
--v2-state-border: #f0e0ca;
}
.v2-state-surface.is-empty.tone-danger {
--v2-state-accent: #d54c4c;
--v2-state-soft: #fff5f5;
--v2-state-border: #f0d7d7;
}
.v2-state-surface.is-empty > .v2-state-empty.semi-empty {
position: relative;
z-index: 1;
min-height: 0;
padding: 4px;
}
.v2-state-surface.is-empty > .v2-state-empty .semi-empty-image {
display: grid !important;
width: auto;
height: auto;
place-items: center;
margin: 0 0 12px;
opacity: 1;
}
.v2-state-empty-icon {
display: grid;
width: 48px;
height: 48px;
place-items: center;
border: 1px solid var(--v2-state-border);
border-radius: 14px;
background: #fff;
color: var(--v2-state-accent);
box-shadow: 0 8px 18px rgba(38, 58, 82, .08);
}
.v2-state-empty-icon .semi-icon {
font-size: 21px;
}
.v2-state-surface.is-empty > .v2-state-empty .semi-empty-title {
margin-top: 0;
color: #2f435a;
font-size: 14px;
font-weight: 760;
letter-spacing: -.01em;
}
.v2-state-surface.is-empty > .v2-state-empty .semi-empty-description {
max-width: 460px;
margin-top: 6px;
color: #77869a;
font-size: 11px;
line-height: 1.6;
}
.v2-state-surface.is-empty > .v2-state-empty .semi-empty-content {
margin-top: 15px;
}
.v2-state-surface.is-empty > .v2-state-empty .semi-empty-content > .semi-button {
min-height: 34px;
border-radius: 8px;
padding-inline: 15px;
font-weight: 700;
}
.v2-state-surface.is-empty.is-panel.is-compact {
position: relative;
top: auto;
min-height: 112px;
border-bottom: 1px solid var(--v2-state-border);
background: linear-gradient(180deg, var(--v2-state-soft) 0, #fff 72%);
padding: 10px 12px;
backdrop-filter: none;
}
.v2-state-surface.is-empty.is-compact > .v2-state-empty .semi-empty-image {
margin-bottom: 7px;
}
.v2-state-surface.is-empty.is-compact .v2-state-empty-icon {
width: 36px;
height: 36px;
border-radius: 10px;
box-shadow: 0 5px 12px rgba(38, 58, 82, .06);
}
.v2-state-surface.is-empty.is-compact .v2-state-empty-icon .semi-icon {
font-size: 17px;
}
.v2-state-surface.is-empty.is-compact > .v2-state-empty .semi-empty-title {
font-size: 12px;
}
.v2-state-surface.is-empty.is-compact > .v2-state-empty .semi-empty-description {
margin-top: 3px;
font-size: 9px;
line-height: 1.5;
}
.v2-vehicle-recent-card > .semi-card-body > .v2-state-surface.v2-vehicle-recent-empty {
min-height: 0;
flex: 1 1 auto;
border: 0;
border-radius: 0;
}
@media (max-width: 680px) {
.v2-state-surface.is-empty {
min-height: 170px;
padding: 15px 12px;
}
.v2-state-surface.is-empty > .v2-state-empty .semi-empty-title {
font-size: 13px;
}
.v2-state-surface.is-empty > .v2-state-empty .semi-empty-description {
max-width: 300px;
font-size: 10px;
}
.v2-state-surface.is-empty.is-panel.is-compact {
min-height: 104px;
padding: 9px 10px;
}
}