feat(web): unify history and account states

This commit is contained in:
lingniu
2026-07-20 04:25:46 +08:00
parent adf92ecd17
commit 04ec00d4be
7 changed files with 290 additions and 21 deletions

View File

@@ -216,10 +216,13 @@ test('clears stale rows, charts, and evidence as soon as the history scope chang
fireEvent.change(screen.getByPlaceholderText('车牌 / VIN多台用逗号分隔'), { target: { value: 'NEWVIN' } });
fireEvent.click(screen.getByRole('button', { name: '查询' }));
expect(await screen.findByRole('status')).toHaveTextContent('正在加载当前筛选范围的历史数据');
await waitFor(() => {
expect(document.querySelector('.v2-history-loading[role="status"]')).toHaveTextContent('正在加载当前筛选范围的历史数据');
});
expect(screen.queryByText('旧车牌')).not.toBeInTheDocument();
expect(screen.queryByText('OLDVIN-evidence')).not.toBeInTheDocument();
expect(screen.getByText('正在聚合时间序列')).toBeInTheDocument();
expect(screen.getByText('正在聚合时间序列')).toBeInTheDocument();
expect(document.querySelector('.v2-history-chart-loading.v2-state-surface')).toBeInTheDocument();
await act(async () => {
resolveNewData(historyData('NEWVIN', '新车牌', 'new-as-of'));
@@ -334,6 +337,7 @@ test('renders only selectable Semi history cards on mobile', async () => {
expect(screen.getByRole('button', { name: '刷新导出任务' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '完成' })).toBeInTheDocument();
expect(screen.getByText('暂无导出任务')).toBeInTheDocument();
expect(document.querySelector('.v2-history-side-empty.v2-state-surface.tone-primary')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭历史数据导出任务' }));
expect(toolsButton).toHaveAttribute('aria-expanded', 'false');
expect(document.body.style.overflow).not.toBe('hidden');

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query';
import { IconCalendar, IconChevronRight, IconClose, IconDownload, IconFilter, IconHistory, IconMapPin, IconMore, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Checkbox, Descriptions, Dropdown, Empty, Input, Progress, Select, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { IconAlertTriangle, IconCalendar, IconChevronRight, IconClose, IconDownload, IconFilter, IconHistory, IconMapPin, IconMore, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Checkbox, Descriptions, Dropdown, Input, Progress, Select, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { FormEvent, KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
@@ -53,7 +53,7 @@ function HistoryQualityTag({ quality }: { quality: string }) {
return <Tag className="v2-history-quality-tag" color={color} type="light" size="small">{historyQualityLabel(quality)}</Tag>;
}
function HistoryTrend({ response, category, loading, error, hasMetrics, expanded, onToggle }: {
function HistoryTrend({ response, category, loading, error, hasMetrics, expanded, onToggle, onRetry }: {
response?: HistorySeriesResponse;
category: string;
loading: boolean;
@@ -61,6 +61,7 @@ function HistoryTrend({ response, category, loading, error, hasMetrics, expanded
hasMetrics: boolean;
expanded: boolean;
onToggle: () => void;
onRetry: () => void;
}) {
const panels = useMemo(() => buildHistorySeriesPanels(response), [response]);
const summary = response?.summary;
@@ -89,14 +90,43 @@ function HistoryTrend({ response, category, loading, error, hasMetrics, expanded
></Button>}
/>;
if (!expanded) return null;
if (category !== 'location') return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}<Empty className="v2-history-chart-empty" title={category === 'raw' ? '原始报文不生成趋势' : '日里程按自然日展示'} description={category === 'raw' ? '离散报文请通过明细与导出核验证据。' : '请通过明细表核对每日起止里程。'} /></Card>;
if (!hasMetrics) return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}<Empty className="v2-history-chart-empty" title="尚未选择趋势指标" description="选择“速度”或“总里程”后展示聚合趋势。" /></Card>;
if (category !== 'location') return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}<PanelEmpty
className="v2-history-chart-empty"
compact
tone={category === 'mileage' ? 'success' : 'neutral'}
icon={category === 'mileage' ? <IconCalendar /> : <IconHistory />}
title={category === 'raw' ? '原始报文不生成趋势' : '日里程按自然日展示'}
description={category === 'raw' ? '离散报文请通过明细与导出核验证据。' : '请通过明细表核对每日起止里程。'}
/></Card>;
if (!hasMetrics) return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}<PanelEmpty
className="v2-history-chart-empty"
compact
tone="primary"
icon={<IconSetting />}
title="尚未选择趋势指标"
description="通过字段设置选择“速度”或“总里程”后展示聚合趋势。"
/></Card>;
return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}
{error ? <Empty className="v2-history-chart-empty is-error" title="趋势加载失败" description={error} /> : loading && !response ? <div className="v2-history-chart-loading"><Spin size="middle" tip="正在聚合时间序列…" /></div> : panels.length ? <div className="v2-history-trend-panels">{panels.map((panel) => <article key={panel.key}><header><strong>{panel.label}</strong><span>{panel.unit || '数值'} · {panel.lines.reduce((sum, line) => sum + line.points, 0)} </span></header><svg viewBox="0 0 800 116" role="img" aria-label={`${panel.label}按时间变化趋势`}>
{error ? <PanelEmpty
className="v2-history-chart-empty is-error"
compact
tone="danger"
icon={<IconAlertTriangle />}
title="趋势加载失败"
description={error}
action={<Button theme="light" type="danger" icon={<IconRefresh />} aria-label="重新聚合趋势" onClick={onRetry}></Button>}
/> : loading && !response ? <PanelLoading className="v2-history-chart-loading" compact title="正在聚合时间序列" description="结果就绪后会保留当前查询范围。" /> : panels.length ? <div className="v2-history-trend-panels">{panels.map((panel) => <article key={panel.key}><header><strong>{panel.label}</strong><span>{panel.unit || '数值'} · {panel.lines.reduce((sum, line) => sum + line.points, 0)} </span></header><svg viewBox="0 0 800 116" role="img" aria-label={`${panel.label}按时间变化趋势`}>
<g className="v2-chart-grid"><line x1="54" y1="10" x2="786" y2="10" /><line x1="54" y1="53" x2="786" y2="53" /><line x1="54" y1="96" x2="786" y2="96" /></g>
<g className="v2-chart-axis"><text x="49" y="14">{panel.maximum.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</text><text x="49" y="100">{panel.minimum.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</text><text x="54" y="112">{formatAxisTime(panel.start)}</text><text x="786" y="112" textAnchor="end">{formatAxisTime(panel.end)}</text></g>
{panel.lines.flatMap((line) => line.paths.map((path, index) => <path key={`${line.key}-${index}`} d={path} fill="none" stroke={line.color} strokeWidth="2" vectorEffect="non-scaling-stroke"><title>{line.label}</title></path>))}
</svg><footer>{panel.lines.map((line) => <span key={line.key}><i style={{ background: line.color }} />{line.label}</span>)}</footer></article>)}</div> : <Empty className="v2-history-chart-empty" title="当前时间窗没有趋势点" description="没有可聚合的数值点,空窗不会被人工补值。" />}
</svg><footer>{panel.lines.map((line) => <span key={line.key}><i style={{ background: line.color }} />{line.label}</span>)}</footer></article>)}</div> : <PanelEmpty
className="v2-history-chart-empty"
compact
tone="warning"
icon={<IconHistory />}
title="当前时间窗没有趋势点"
description="没有可聚合的数值点,空窗不会被人工补值。"
/>}
{summary ? <footer className="v2-history-trend-evidence">
<Tag color={summary.missingBucketCount ? 'orange' : 'green'} type="light" size="small">{summary.missingBucketCount ? '存在空窗' : '时间窗完整'}</Tag>
<span title={summary.evidence}>{summary.evidence}</span>
@@ -164,7 +194,22 @@ function ExportJobsPanel({ query, showHeader = true }: { query: UseQueryResult<H
const statusLabel = (status: string) => status === 'queued' ? '排队中' : status === 'running' ? '执行中' : status === 'completed' ? '已完成' : '失败';
const statusColor = (status: string) => status === 'completed' ? 'green' : status === 'running' ? 'blue' : status === 'failed' ? 'red' : 'grey';
return <Card className="v2-export-jobs" bodyStyle={{ padding: 0 }}>{showHeader ? <WorkspacePanelHeader title="导出任务" description="异步生成并保留可追溯下载记录" meta={`${jobs.length.toLocaleString('zh-CN')} 个任务`} /> : null}
<div className="v2-export-job-list">{query.isPending ? <div className="v2-history-side-loading"><Spin size="middle" tip="正在读取导出任务" /></div> : query.isError ? <Empty className="v2-history-side-empty" title="导出任务加载失败" description="请稍后刷新重试。" /> : !jobs.length ? <Empty className="v2-history-side-empty" title="暂无导出任务" description="创建任务后可在这里查看进度并下载。" /> : <CardGroup type="grid" spacing={0}>{jobs.slice(0, 6).map((job) => <Card className={`v2-export-job-card is-${job.status}`} key={job.id} title={<span className="v2-export-job-name" title={job.name}>{job.name}</span>} headerExtraContent={<Tag color={statusColor(job.status)} type="light" size="small">{statusLabel(job.status)}</Tag>} headerLine bodyStyle={{ padding: 0 }}>
<div className="v2-export-job-list">{query.isPending ? <PanelLoading className="v2-history-side-loading" title="正在读取导出任务" description="任务状态就绪后会自动刷新。" /> : query.isError ? <PanelEmpty
className="v2-history-side-empty"
compact
tone="danger"
icon={<IconAlertTriangle />}
title="导出任务加载失败"
description="当前任务列表没有更新,请重新读取。"
action={<Button theme="light" type="danger" icon={<IconRefresh />} aria-label="重新读取导出任务" onClick={() => query.refetch()}></Button>}
/> : !jobs.length ? <PanelEmpty
className="v2-history-side-empty"
compact
tone="primary"
icon={<IconDownload />}
title="暂无导出任务"
description="在历史明细中创建任务后,可在这里跟踪进度并下载。"
/> : <CardGroup type="grid" spacing={0}>{jobs.slice(0, 6).map((job) => <Card className={`v2-export-job-card is-${job.status}`} key={job.id} title={<span className="v2-export-job-name" title={job.name}>{job.name}</span>} headerExtraContent={<Tag color={statusColor(job.status)} type="light" size="small">{statusLabel(job.status)}</Tag>} headerLine bodyStyle={{ padding: 0 }}>
<div className="v2-export-job-summary">
<strong>{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')}` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)}` : job.error || '任务失败'}</strong>
<span><small>{job.ownerUsername || job.ownerName || '历史任务'} · {job.vehicleVins?.length ?? job.keywords.length} </small>{job.downloadUrl ? <ExportDownloadButton id={job.id} /> : null}</span>
@@ -245,8 +290,22 @@ function EvidencePanel({ row, metrics, onClose, showHeader = true }: { row?: His
{ key: '质量原因', value: row.qualityReason || '平台未返回质量说明' }
]} />
<div className="v2-evidence-section-title"><strong></strong><Tag type="light" color="grey" size="small">{metrics.length}</Tag></div>
{metrics.length ? <Descriptions className="v2-evidence-values" align="left" size="small" data={metrics.slice(0, 20).map((metric) => ({ key: <span>{metric.label}<small>{metric.key}</small></span>, value: formatHistoryValue(row.values[metric.key], metric) }))} /> : <Empty className="v2-evidence-values-empty" title="没有选中业务字段" description="通过列设置选择需要核对的字段。" />}
{row.evidenceId ? <footer><Tag color="blue" type="light" size="small">RAW </Tag><Typography.Text copyable={{ content: row.evidenceId }}>{row.evidenceId}</Typography.Text></footer> : null}</> : <Empty className="v2-history-side-empty" title="选择一条数据" description="查看来源、质量原因、业务字段与 RAW 证据。" />}
{metrics.length ? <Descriptions className="v2-evidence-values" align="left" size="small" data={metrics.slice(0, 20).map((metric) => ({ key: <span>{metric.label}<small>{metric.key}</small></span>, value: formatHistoryValue(row.values[metric.key], metric) }))} /> : <PanelEmpty
className="v2-evidence-values-empty"
compact
tone="primary"
icon={<IconSetting />}
title="没有选中业务字段"
description="通过列设置选择需要核对的字段。"
/>}
{row.evidenceId ? <footer><Tag color="blue" type="light" size="small">RAW </Tag><Typography.Text copyable={{ content: row.evidenceId }}>{row.evidenceId}</Typography.Text></footer> : null}</> : <PanelEmpty
className="v2-history-side-empty"
compact
tone="primary"
icon={<IconSearch />}
title="选择一条数据"
description="查看来源、质量原因、业务字段与 RAW 证据。"
/>}
</Card>;
}
@@ -620,7 +679,7 @@ export default function HistoryPage() {
{dataQuery.isError ? <InlineError message={dataQuery.error instanceof Error ? dataQuery.error.message : '历史查询失败'} onRetry={() => dataQuery.refetch()} /> : null}
<div className={`v2-history-workspace${selectedRow && !mobileLayout ? ' is-inspector-open' : ''}`}>
<div className={`v2-history-main${trendExpanded ? ' has-expanded-trend' : ''}`}>
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} hasMetrics={Boolean(seriesMetricKey)} expanded={trendExpanded} onToggle={() => setTrendExpanded((value) => !value)} />
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} hasMetrics={Boolean(seriesMetricKey)} expanded={trendExpanded} onToggle={() => setTrendExpanded((value) => !value)} onRetry={() => { void seriesQuery.refetch(); }} />
<Card className={`v2-history-table-card is-${density}`} bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
eyebrow="证据结果"

View File

@@ -164,7 +164,7 @@ test('pages and filters large vehicle grants without nesting a vehicle-list scro
}]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
fireEvent.click(await screen.findByRole('button', { name: /选择客户 华东客户/ }));
expect(screen.getAllByRole('button', { name: /^移除 / })).toHaveLength(10);
@@ -178,6 +178,12 @@ test('pages and filters large vehicle grants without nesting a vehicle-list scro
expect(screen.getAllByRole('button', { name: /^移除 / })).toHaveLength(1);
expect(screen.getByText('1 条匹配结果')).toBeInTheDocument();
expect(screen.getByText('第 11 条,共 1 辆')).toBeInTheDocument();
fireEvent.change(screen.getByRole('textbox', { name: '筛选已授权车辆' }), { target: { value: '不存在车辆' } });
expect(await screen.findByText('没有匹配的授权车辆')).toBeInTheDocument();
expect(document.querySelector('.v2-user-assigned-filter-empty.v2-state-surface.tone-primary')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '清除车辆筛选' }));
expect(screen.getAllByRole('button', { name: /^移除 / })).toHaveLength(10);
});
test('makes draft state explicit, protects bulk removal and preserves edits across a same-revision refresh', async () => {
@@ -260,6 +266,9 @@ test('keeps the customer directory visible on mobile and opens details on demand
fireEvent.click(customer);
expect(await screen.findByRole('button', { name: '关闭账号详情' })).toBeInTheDocument();
expect(customer).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('尚未分配车辆')).toBeInTheDocument();
expect(document.querySelector('.v2-user-vehicle-empty.v2-state-surface.tone-warning')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '添加授权车辆' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭账号详情' }));
await waitFor(() => expect(document.querySelector('.v2-user-editor-form')).not.toBeInTheDocument());
expect(customer).toHaveAttribute('aria-expanded', 'false');
@@ -413,6 +422,27 @@ test('filters the customer directory by status and exposes the active result sco
expect(screen.getByRole('list', { name: '客户权限目录统计' })).toHaveTextContent('待完善1缺少菜单或车辆');
});
test('uses an actionable semantic empty state for unmatched customer filters', async () => {
mocks.adminUsers.mockResolvedValue([{
id: 7, username: 'customer-east', displayName: '华东客户', userType: 'customer', status: 'enabled',
customerRef: 'east', tenantRef: '', authProvider: 'local', menuKeys: ['monitor'], vehicleVins: [],
vehicles: [], grantHistory: [], createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z'
}]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
expect(await screen.findByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument();
fireEvent.change(screen.getByRole('textbox', { name: '搜索客户账号' }), { target: { value: '不存在的账号' } });
expect(await screen.findByText('没有匹配账号')).toBeInTheDocument();
expect(view.container.querySelector('.v2-user-filter-empty.v2-state-surface.tone-primary')).toBeInTheDocument();
expect(screen.getByText('当前“全部状态”范围没有匹配结果。')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }));
expect(screen.getByRole('textbox', { name: '搜索客户账号' })).toHaveValue('');
expect(await screen.findByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument();
});
test('uses a standard Semi empty state when no customer account exists', async () => {
mocks.adminUsers.mockResolvedValue([]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { IconChevronRight, IconDelete, IconPlus, IconRefresh, IconSearch, IconUserGroup } from '@douyinfe/semi-icons';
import { Avatar, Button, Card, Checkbox, Collapse, Empty, Input, List, Select, Spin, Switch, Tabs, Tag, Typography } from '@douyinfe/semi-ui';
import { Avatar, Button, Card, Checkbox, Collapse, Input, List, Select, Switch, Tabs, Tag, Typography } from '@douyinfe/semi-ui';
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import type { AdminUser, CustomerUserInput, CustomerVehicleGrantInput } from '../../api/types';
@@ -539,7 +539,14 @@ export default function UsersPage() {
items={directoryMetrics}
/>
<div className="v2-user-directory-body">
{users.isPending ? <PanelLoading className="v2-user-list-loading" title="正在加载客户账号" description="账号目录和授权摘要就绪后会自动显示。" /> : customers.length === 0 ? <PanelEmpty className="v2-user-list-empty" title="还没有客户账号" description="创建客户账号后,可在这里配置菜单和车辆范围。" action={<Button theme="solid" onClick={startCreate}></Button>} /> : <>
{users.isPending ? <PanelLoading className="v2-user-list-loading" title="正在加载客户账号" description="账号目录和授权摘要就绪后会自动显示。" /> : customers.length === 0 ? <PanelEmpty
className="v2-user-list-empty"
tone="primary"
icon={<IconUserGroup />}
title="还没有客户账号"
description="创建客户账号后,可在这里配置菜单和车辆范围。"
action={<Button theme="solid" icon={<IconPlus />} aria-label="创建第一个客户账号" onClick={startCreate}></Button>}
/> : <>
{visibleCustomers.length ? <div className="v2-user-directory-columns" role="row" aria-label="客户账号目录列">
<span></span>
<span></span>
@@ -551,7 +558,23 @@ export default function UsersPage() {
<List
className="v2-customer-list"
dataSource={visibleCustomers}
emptyContent={<Empty className="v2-user-filter-empty" title="没有匹配账号" description="请调整关键词或访问状态筛选。" />}
emptyContent={<PanelEmpty
className="v2-user-filter-empty"
tone="primary"
icon={<IconSearch />}
title="没有匹配账号"
description={`当前“${customerScopeLabel(customerStatus)}”范围没有匹配结果。`}
action={<Button
theme="light"
type="primary"
icon={<IconRefresh />}
aria-label="清除筛选"
onClick={() => {
setCustomerKeyword('');
setCustomerStatus('all');
}}
></Button>}
/>}
renderItem={(user) => {
const accessState = customerAccessState(user.status, user.menuKeys.length, user.vehicles.length);
const authProvider = authProviderMeta(user.authProvider);
@@ -721,7 +744,15 @@ export default function UsersPage() {
{visibleAssignedGrants.length ? <div className="v2-assigned-vins">{visibleAssignedGrants.map((grant) => { const plate = vehicleLabels[grant.vin]; return <Card key={grant.vin} className={`v2-assigned-vehicle-card${mobileLayout ? ' is-mobile-compact' : ''}`} bodyStyle={{ padding: 0 }}>
<header><span><b>{plate || '未登记车牌'}</b><small>{grant.vin}</small></span><span className="v2-assigned-vehicle-actions"><Button theme="light" type="tertiary" size="small" aria-haspopup="dialog" aria-controls="v2-user-grant-window" aria-label={`调整 ${plate || grant.vin} 有效期`} onClick={() => openGrantEditor(grant.vin)}></Button><Button theme="borderless" type="tertiary" size="small" aria-label={`移除 ${plate || grant.vin}`} icon={<IconDelete />} onClick={() => toggleVIN(grant.vin)} /></span></header>
<div className="v2-assigned-vehicle-interval" aria-label={`授权有效期:启用 ${formatGrantTime(grant.validFrom)};停用 ${grant.validTo ? formatGrantTime(grant.validTo) : '持续有效'}`}><span><small></small><strong>{formatGrantTime(grant.validFrom)}</strong></span><i aria-hidden="true"></i><span><small></small><strong>{grant.validTo ? formatGrantTime(grant.validTo) : '持续有效'}</strong></span></div>
</Card>; })}</div> : <Empty className="v2-user-assigned-filter-empty" title="没有匹配的授权车辆" description="请更换车牌或 VIN 关键词。" />}
</Card>; })}</div> : <PanelEmpty
className="v2-user-assigned-filter-empty"
compact
tone="primary"
icon={<IconSearch />}
title="没有匹配的授权车辆"
description="请更换车牌或 VIN 关键词。"
action={<Button theme="light" type="primary" icon={<IconRefresh />} aria-label="清除车辆筛选" onClick={() => setAssignedKeyword('')}></Button>}
/>}
<div className="v2-assigned-pagination">
<TablePagination
page={safeAssignedPage}
@@ -730,7 +761,14 @@ export default function UsersPage() {
onPageChange={setAssignedPage}
/>
</div>
</> : <Empty className="v2-user-vehicle-empty" title="尚未分配车辆" description="客户将无法看到任何车辆数据。" />}
</> : <PanelEmpty
className="v2-user-vehicle-empty"
tone="warning"
icon={<IconPlus />}
title="尚未分配车辆"
description={mobileLayout ? '添加至少一辆车后,客户才能看到车辆数据。' : '请在上方按车牌或 VIN 搜索并添加授权车辆。'}
action={mobileLayout ? <Button theme="solid" icon={<IconPlus />} aria-label="添加授权车辆" onClick={() => setVehicleComposerOpen(true)}></Button> : undefined}
/>}
{!creating && selected?.grantHistory?.length ? <Collapse className="v2-grant-history">
<Collapse.Panel itemKey="grant-history" header={<span className="v2-grant-history-title"> <Tag color="blue" type="light" size="small">{selected.grantHistory.length} </Tag></span>}>
<div className="v2-grant-history-list">{selected.grantHistory.map((item) => <Card key={item.id} className="v2-grant-history-card" bodyStyle={{ padding: 0 }}>

View File

@@ -201,6 +201,10 @@ describe('V2 production entry', () => {
expect(corePageSources.HistoryPage).toContain('<PanelEmpty');
expect(corePageSources.HistoryPage).toContain('<WorkspaceEmptyGuide');
expect(corePageSources.HistoryPage).toContain('<PanelLoading className="v2-history-loading"');
expect(corePageSources.HistoryPage).toContain('className="v2-history-chart-empty"');
expect(corePageSources.HistoryPage).toContain('className="v2-history-side-empty"');
expect(corePageSources.HistoryPage).not.toContain('<Empty');
expect(corePageSources.HistoryPage).not.toContain('<Spin');
expect(corePageSources.HistoryPage).not.toContain('row ? <><dl>');
expect(corePageSources.HistoryPage).not.toContain('job) => <article');
expect(corePageSources.HistoryPage).not.toContain('<section className="v2-history-trend"');
@@ -333,9 +337,13 @@ describe('V2 production entry', () => {
expect(corePageSources.UsersPage).toContain('<Card className="v2-user-editor-section v2-user-menu-section"');
expect(corePageSources.UsersPage).toContain('v2-user-editor-section v2-user-vehicle-section');
expect(corePageSources.UsersPage).toContain('id="v2-user-vehicle-composer"');
expect(corePageSources.UsersPage).toContain('<PanelEmpty className="v2-user-list-empty"');
expect(corePageSources.UsersPage).toContain('className="v2-user-list-empty"');
expect(corePageSources.UsersPage).not.toContain('className="v2-user-editor-empty"');
expect(corePageSources.UsersPage).toContain('<Empty className="v2-user-vehicle-empty"');
expect(corePageSources.UsersPage).toContain('className="v2-user-filter-empty"');
expect(corePageSources.UsersPage).toContain('className="v2-user-assigned-filter-empty"');
expect(corePageSources.UsersPage).toContain('className="v2-user-vehicle-empty"');
expect(corePageSources.UsersPage).not.toContain('<Empty');
expect(corePageSources.UsersPage).not.toContain('<Spin');
expect(corePageSources.UsersPage).not.toContain('<section>');
expect(corePageSources.UsersPage).not.toContain('<aside className="v2-user-list"');
expect(corePageSources.UsersPage).not.toContain('<main className="v2-user-editor"');

View File

@@ -27525,3 +27525,89 @@
padding: 9px 10px;
}
}
/*
* History and account governance now use the shared Semi UI state language
* inside secondary panels as well as their primary workspaces.
*/
.v2-history-trend > .semi-card-body > .v2-history-chart-empty.v2-state-surface,
.v2-history-trend > .semi-card-body > .v2-history-chart-loading.v2-state-surface {
min-height: 134px;
border: 0;
border-radius: 0;
}
.v2-history-trend > .semi-card-body > .v2-history-chart-loading.v2-state-surface {
position: relative;
display: grid;
top: auto;
align-content: center;
border-bottom: 0;
background: linear-gradient(180deg, #f5f9ff, #fff);
padding: 14px;
backdrop-filter: none;
}
.v2-history-trend .v2-history-chart-loading .v2-state-copy {
display: grid;
text-align: left;
}
.v2-export-job-list > .v2-history-side-loading.v2-state-surface,
.v2-export-job-list > .v2-history-side-empty.v2-state-surface {
min-height: 170px;
border: 0;
border-radius: 0;
}
.v2-history-evidence .v2-evidence-values-empty.v2-state-surface,
.v2-history-evidence .v2-history-side-empty.v2-state-surface {
min-height: 124px;
margin: 10px;
}
.v2-user-filter-empty.v2-state-surface {
min-height: 250px;
border: 0;
border-radius: 0;
}
.v2-user-filter-empty.v2-state-surface > .v2-state-empty.v2-user-filter-empty.semi-empty {
min-height: 0;
}
.v2-user-assigned-filter-empty.v2-state-surface {
min-height: 132px;
margin-top: 9px;
}
.v2-user-assigned-filter-empty.v2-state-surface > .v2-state-empty.v2-user-assigned-filter-empty.semi-empty {
min-height: 0;
margin-top: 0;
border: 0;
background: transparent;
}
.v2-user-vehicle-empty.v2-state-surface {
min-height: 176px;
margin-top: 10px;
}
@media (max-width: 680px) {
.v2-history-trend > .semi-card-body > .v2-history-chart-empty.v2-state-surface,
.v2-history-trend > .semi-card-body > .v2-history-chart-loading.v2-state-surface {
min-height: 116px;
}
.v2-user-filter-empty.v2-state-surface {
min-height: 184px;
}
.v2-user-assigned-filter-empty.v2-state-surface {
min-height: 118px;
}
.v2-user-vehicle-empty.v2-state-surface {
min-height: 164px;
}
}