feat(web): unify semantic empty states
This commit is contained in:
@@ -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>}>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user