feat(web): unify history and account states
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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="证据结果"
|
||||
|
||||
@@ -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('第 1–1 条,共 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 } } });
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,3 +249,47 @@
|
||||
- 第二轮生产截图确认车辆空结果、轨迹紧凑证据和运维成功状态遵循同一套 Semi UI 结构,未发现 P0、P1 或 P2 视觉与交互问题。
|
||||
|
||||
final result: passed
|
||||
|
||||
## 历史证据与账号治理次级状态 Semi UI Design QA
|
||||
|
||||
- source visual truth: ECS 生产端历史数据与账号管理工作台
|
||||
- implementation screenshots:
|
||||
- users filtered empty state: `/Users/lingniu/.codex/audits/2026-07-20-semi-history-users/04-users-filter-empty-after.jpg`
|
||||
- users restored directory: `/Users/lingniu/.codex/audits/2026-07-20-semi-history-users/05-users-cleared-after.jpg`
|
||||
- history initial workspace: `/Users/lingniu/.codex/audits/2026-07-20-semi-history-users/06-history-desktop-after.jpg`
|
||||
- history export jobs: `/Users/lingniu/.codex/audits/2026-07-20-semi-history-users/07-history-secondary-after.jpg`
|
||||
- viewport and state:
|
||||
- desktop `1440 × 900`
|
||||
- 管理员权限、生产数据、空筛选与有效历史查询状态
|
||||
|
||||
### Fidelity Ledger
|
||||
|
||||
| Surface | Required fidelity | Implementation evidence | Result |
|
||||
| --- | --- | --- | --- |
|
||||
| Secondary state consistency | 趋势、导出、证据和账号筛选不能继续使用彼此不同的原始 Empty、Spin。 | 历史与账号页面移除直接 Empty、Spin,统一使用共享 `PanelEmpty` 与 `PanelLoading`。 | Improved. |
|
||||
| Recovery path | 可恢复错误和无匹配结果必须提供明确且唯一的下一步。 | 趋势与导出失败提供对应重试;账号无匹配提供“清除筛选”;授权车无匹配提供“清除车辆筛选”。 | Passed. |
|
||||
| Semantic meaning | 加载、引导、提醒与失败状态应在同一结构下保持可辨认。 | 使用 primary、success、warning、danger 语义 tone 和真实 Semi Icon,不增加装饰性插图。 | Passed. |
|
||||
| History continuity | 状态迁移不能破坏趋势查询、证据读取、异步导出和原查询条件。 | 生产查询返回 6 条历史记录、1 辆车、2 个来源;趋势展开显示桶点,导出侧栏显示 4 个已完成任务。 | Preserved. |
|
||||
| Account continuity | 清除账号筛选后必须恢复原目录,不改变账号与车辆权限数据。 | 输入不存在账号后显示语义空状态,点击“清除筛选”恢复 5 个客户账号。 | Preserved. |
|
||||
| Responsive density | 次级状态不能在窄屏撑高列表或遮挡主要操作。 | 共享状态使用 compact 契约,并在 `680px` 断点收敛趋势、账号与授权车状态高度。 | Passed by responsive rule and component regression coverage. |
|
||||
| Production readiness | 源码、构建产物和生产服务需要指向同一个可追溯版本。 | 全量 371 项测试、生产构建和 Web build gate 通过;ECS 当前版本为 `semi-history-users-states-20260720041714`。 | Passed. |
|
||||
|
||||
### Interaction And Accessibility Checks
|
||||
|
||||
- 状态外层继续使用 `role="status"` 与 `aria-live="polite"`;失败状态沿用共享告警语义;
|
||||
- 趋势重试、导出重试、清除账号筛选和清除车辆筛选均有明确的可访问名称;
|
||||
- 账号无结果不再形成无操作的大块白色画布,恢复动作位于状态说明之后;
|
||||
- 历史初始引导、趋势加载、趋势无点、导出任务和证据选择使用同一内容层级;
|
||||
- 生产端账号清筛、历史趋势展开和导出任务侧栏均完成实际点击验证;
|
||||
- 本轮未把浏览器视口覆盖能力作为移动端视觉认证依据,窄屏结论来自共享组件回归测试与 CSS 断点审查。
|
||||
|
||||
### Comparison History
|
||||
|
||||
#### Pass 1 — fixed
|
||||
|
||||
- 原历史次级区域混用裸 Semi Empty、Spin,语义、间距与恢复动作不一致;账号筛选无结果没有直接恢复路径。
|
||||
- 迁移后两页复用已经在车辆、轨迹和运维页面验证过的语义状态组件,只为各自业务补充图标、说明和动作。
|
||||
- 首轮测试发现趋势与主表同时加载时会正确产生两个独立 live region,测试随后改为针对趋势状态的精确断言。
|
||||
- 生产复核确认账号清筛和历史导出主流程可用,桌面布局没有新增裁切、覆盖或不可达操作。
|
||||
|
||||
final result: passed
|
||||
|
||||
Reference in New Issue
Block a user