refine Semi UI vehicle evidence

This commit is contained in:
lingniu
2026-07-18 18:07:15 +08:00
parent 801a9851ea
commit 5bfdc7f637
13 changed files with 110 additions and 47 deletions

View File

@@ -0,0 +1,16 @@
import { describe, expect, test } from 'vitest';
import { protocolDisplayLabel, protocolSourceLabel } from './protocols';
describe('protocol display vocabulary', () => {
test('uses one user-facing label for every supported vehicle protocol', () => {
expect(protocolDisplayLabel('GB32960')).toBe('GB/T 32960');
expect(protocolDisplayLabel('JT808')).toBe('JT/T 808');
expect(protocolDisplayLabel('YUTONG_MQTT')).toBe('宇通 MQTT');
});
test('keeps unknown protocols intact and explains qualified sources', () => {
expect(protocolDisplayLabel('CUSTOM')).toBe('CUSTOM');
expect(protocolSourceLabel('YUTONG_MQTT:canonical')).toBe('宇通 MQTT · canonical');
expect(protocolSourceLabel('CUSTOM:gateway')).toBe('CUSTOM:gateway');
});
});

View File

@@ -0,0 +1,21 @@
const PROTOCOL_LABELS: Record<string, string> = {
GB32960: 'GB/T 32960',
JT808: 'JT/T 808',
YUTONG_MQTT: '宇通 MQTT'
};
export function protocolDisplayLabel(protocol?: string) {
const normalized = protocol?.trim() ?? '';
return normalized ? PROTOCOL_LABELS[normalized] ?? normalized : '—';
}
export function protocolSourceLabel(source?: string) {
const normalized = source?.trim() ?? '';
if (!normalized) return '';
const separator = normalized.indexOf(':');
if (separator < 0) return protocolDisplayLabel(normalized);
const protocol = normalized.slice(0, separator);
const qualifier = normalized.slice(separator + 1);
const label = protocolDisplayLabel(protocol);
return label === protocol ? normalized : qualifier ? `${label} · ${qualifier}` : label;
}

View File

@@ -256,7 +256,7 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
expect(view.container.querySelector('.v2-monitor-table-panel')).toHaveClass('semi-card');
expect(view.container.querySelector('.v2-monitor-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.v2-monitor-table-scroll > table')).not.toBeInTheDocument();
expect(screen.getByText('覆盖全部授权车辆;实时字段缺失显示“—”,文字地址按需解析')).toBeInTheDocument();
expect(screen.getByText('覆盖全部授权车辆;缺失显示“—”,地址按需解析')).toBeInTheDocument();
expect(await screen.findAllByText('粤A12345')).toHaveLength(1);
expect(screen.getAllByText('42')).toHaveLength(1);
expect(screen.getAllByText(/18\.6/)).toHaveLength(1);

View File

@@ -104,7 +104,7 @@ const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehic
const moved = Boolean(current && requested && current.key !== requested.key);
if (!current) return <span className="v2-monitor-address-empty"></span>;
if (!requested) return <Button className="v2-monitor-address-action" size="small" theme="light" type="primary" icon={<IconMapPin />} aria-label={`解析${vehicle.plate || vehicle.vin}位置`} title="按需调用高德逆地理编码,不随实时数据刷新重复请求" onClick={() => setRequested(current)}></Button>;
if (!requested) return <Button className="v2-monitor-address-action" size="small" theme="light" type="primary" icon={<IconMapPin />} aria-label={`解析${vehicle.plate || vehicle.vin}位置`} title="按需调用高德地址解析,不随实时数据刷新" onClick={() => setRequested(current)}></Button>;
if (addressQuery.isFetching && !addressQuery.data) return <span className="v2-monitor-address-loading"><Spin size="small" /></span>;
if (addressQuery.isError) return <Button className="v2-monitor-address-action is-error" size="small" theme="light" type="danger" aria-label={`${vehicle.plate || vehicle.vin}位置解析失败,重试`} onClick={() => void addressQuery.refetch()}></Button>;
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span><small>{addressQuery.dataUpdatedAt ? `解析于 ${new Date(addressQuery.dataUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })}` : ''}</small>{moved ? <Button className="v2-monitor-address-update" size="small" theme="borderless" type="warning" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}> · </Button> : null}</div>;
@@ -123,7 +123,7 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, mo
return <Card className="v2-monitor-table-panel" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="车辆实时列表"
description="覆盖全部授权车辆;实时字段缺失显示“—”,文字地址按需解析"
description="覆盖全部授权车辆;缺失显示“—”,地址按需解析"
meta={`${total.toLocaleString('zh-CN')} 辆车辆`}
/>
{!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}

View File

@@ -225,7 +225,7 @@ test('supports searching and selecting license plates before querying exact VINs
const search = screen.getByRole('textbox', { name: '搜索车牌' });
fireEvent.focus(search);
fireEvent.change(search, { target: { value: '粤A12' } });
const option = await screen.findByRole('option', { name: '粤A12345 LTEST000000000001 GB32960 JT808 选择' });
const option = await screen.findByRole('option', { name: '粤A12345 LTEST000000000001 GB/T 32960 JT/T 808 选择' });
expect(screen.getAllByRole('option')).toHaveLength(1);
fireEvent.click(option);
fireEvent.click(screen.getByRole('button', { name: '查询' }));

View File

@@ -161,10 +161,10 @@ test('merges duplicate vehicle sources and keeps the active date shortcut visibl
const search = screen.getByRole('textbox', { name: '搜索轨迹车辆' });
fireEvent.focus(search);
fireEvent.change(search, { target: { value: '粤A12345' } });
const option = await screen.findByRole('option', { name: '粤A12345 LTEST000000000001 JT808 GB32960 选择' });
const option = await screen.findByRole('option', { name: '粤A12345 LTEST000000000001 JT/T 808 GB/T 32960 选择' });
expect(document.querySelectorAll('.v2-track-vehicle-options [role="option"]')).toHaveLength(1);
expect(option).toHaveTextContent('粤A12345LTEST000000000001JT808GB32960选择');
expect(option).toHaveTextContent('粤A12345LTEST000000000001JT/T 808GB/T 32960选择');
});
test('renders a map-first replay workspace and connects stop, event, and panel interactions', async () => {

View File

@@ -87,6 +87,7 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
expect(commandCard?.querySelector('.v2-live-grid > div:first-child')).toHaveTextContent('速度');
expect(commandCard?.querySelector('.v2-live-grid > div:nth-child(2)')).toHaveTextContent('SOC');
expect(commandCard?.querySelector('.v2-live-grid > div:nth-child(4)')).toHaveTextContent('当日里程');
expect(commandCard?.querySelector('.v2-live-grid > div:nth-child(5)')).toHaveTextContent('推荐来源JT/T 808');
expect(screen.getByLabelText('粤A12345 车辆档案')).toHaveFocus();
expect(screen.getByText('扫描 0 帧 · 上海时间 10:00:00')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '最新上报', level: 5 })).toBeInTheDocument();
@@ -190,7 +191,7 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as
await waitFor(() => expect(vehicles).toHaveBeenCalledTimes(2));
expect(input).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('heading', { name: '匹配车辆', level: 5 })).toBeInTheDocument();
const option = await screen.findByRole('option', { name: `${initialRealtime.plate} ${initialRealtime.vin} JT808 选择` });
const option = await screen.findByRole('option', { name: `${initialRealtime.plate} ${initialRealtime.vin} JT/T 808 选择` });
expect(view.container.querySelector('#v2-vehicle-search-options.v2-vehicle-candidate-list')).toBeInTheDocument();
expect(view.container.querySelector('#v2-vehicle-search-options')).toHaveAttribute('aria-busy', 'false');
expect(screen.getAllByRole('option')).toHaveLength(1);
@@ -230,7 +231,9 @@ test('defaults to a compact mobile vehicle directory and keeps eight recent vehi
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
expect(await screen.findByText('本页 8 辆')).toBeInTheDocument();
expect(screen.getAllByRole('listitem', { name: /打开 .* 车辆档案/ })).toHaveLength(8);
expect(screen.getAllByRole('listitem')).toHaveLength(8);
expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8);
expect(screen.getAllByText(/GB\/T 32960|JT\/T 808/).length).toBeGreaterThan(0);
expect(view.container.querySelector('.v2-vehicle-directory-table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-query-panel')).toHaveClass('is-mobile-collapsed');
expect(screen.queryByLabelText('查车操作')).not.toBeInTheDocument();
@@ -486,6 +489,7 @@ test('uses compact Semi telemetry evidence cards on mobile', async () => {
expect(screen.getByRole('group', { name: '车辆快捷操作' }).querySelectorAll('.semi-button')).toHaveLength(5);
expect(screen.getByRole('group', { name: '车辆快捷操作' })).toHaveTextContent('切换车辆轨迹回放历史数据里程查询告警事件');
expect(document.querySelectorAll('.v2-identity-sources .semi-tag')).toHaveLength(3);
expect(document.querySelector('.v2-identity-sources')).toHaveTextContent('GB/T 32960JT/T 808宇通 MQTT');
expect(document.querySelector('.v2-vehicle-command-card .v2-live-grid')).toHaveAttribute('aria-label', '车辆实时指标');
expect(document.querySelectorAll('.v2-vehicle-command-card .v2-live-grid > [role="listitem"]')).toHaveLength(6);
});

View File

@@ -13,6 +13,7 @@ import { canAdminister, hasMenu } from '../auth/session';
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from '../domain/telemetry';
import { formatZhNumber } from '../domain/formatters';
import { protocolDisplayLabel, protocolSourceLabel } from '../domain/protocols';
import { isValidAMapCoordinate } from '../../integrations/amap';
import { FleetMap } from '../map/FleetMap';
import { InlineError, PageLoading } from '../shared/AsyncState';
@@ -118,7 +119,7 @@ function Archive({ detail, editable, onUpdated }: { detail: VehicleDetail; edita
function Events({ detail }: { detail: VehicleDetail }) {
const events = [
...detail.quality.items.slice(0, 3).map((item) => ({ tone: issueTone(item), title: item.severity === 'error' ? '质量异常' : '质量提醒', detail: item.detail, time: item.lastSeen })),
...detail.sourceStatus.slice(0, 3).map((item) => ({ tone: item.online ? 'success' : 'muted', title: item.online ? '数据上报' : '来源离线', detail: `${item.protocol} · ${item.online ? '当前在线' : '暂无在线数据'}`, time: item.lastSeen }))
...detail.sourceStatus.slice(0, 3).map((item) => ({ tone: item.online ? 'success' : 'muted', title: item.online ? '数据上报' : '来源离线', detail: `${protocolDisplayLabel(item.protocol)} · ${item.online ? '当前在线' : '暂无在线数据'}`, time: item.lastSeen }))
].slice(0, 5);
return <Card className="v2-record-card v2-events-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
@@ -222,7 +223,7 @@ function TelemetryTimeCell({ item }: { item: LatestTelemetryValue }) {
}
function TelemetrySourceCell({ item }: { item: LatestTelemetryValue }) {
return <div className="v2-telemetry-source"><Tag color="blue" type="light" size="small">{item.protocol}</Tag><small title={item.sourceEndpoint}>{item.sourceEndpoint || '协议默认来源'}</small></div>;
return <div className="v2-telemetry-source"><Tag color="blue" type="light" size="small">{protocolDisplayLabel(item.protocol)}</Tag><small title={item.sourceEndpoint}>{item.sourceEndpoint || '协议默认来源'}</small></div>;
}
function telemetryRowKey(item?: LatestTelemetryValue) {
@@ -260,7 +261,6 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
.map((key) => ({ key, label: categoryLabels.get(key) ?? key, count: indexed.valuesByProtocolCategory.get(`${activeProtocol}\u0000${key}`)?.length ?? 0 }));
const activeCategory = indexed.valuesByProtocolCategory.has(`${activeProtocol}\u0000${selectedCategory}`) ? selectedCategory : categories[0]?.key ?? '';
const visibleMetrics = indexed.valuesByProtocolCategory.get(`${activeProtocol}\u0000${activeCategory}`) ?? [];
const protocolLabel = (protocol: string) => protocol === 'GB32960' ? 'GB/T 32960' : protocol === 'JT808' ? 'JT/T 808' : protocol === 'YUTONG_MQTT' ? '宇通 MQTT' : protocol;
const columns = [
{ title: '字段 / 协议映射', dataIndex: 'label', width: 260, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryFieldCell item={item} /> },
{ title: '当前值', dataIndex: 'value', width: 170, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryValueCell item={item} onInspect={setInspectedValue} /> },
@@ -296,11 +296,11 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
meta={`${data?.values.length ?? 0}`}
/>
<div className="v2-telemetry-tabs">
<SegmentedTabs className="v2-telemetry-protocols" variant="filled" ariaLabel="数据协议" value={activeProtocol} onChange={(protocol) => { setSelectedProtocol(protocol); setSelectedCategory('vehicle'); }} items={indexed.protocols.map((protocol) => ({ key: protocol, label: protocolLabel(protocol), count: (data?.values ?? []).filter((value) => value.protocol === protocol).length }))} />
<SegmentedTabs className="v2-telemetry-protocols" variant="filled" ariaLabel="数据协议" value={activeProtocol} onChange={(protocol) => { setSelectedProtocol(protocol); setSelectedCategory('vehicle'); }} items={indexed.protocols.map((protocol) => ({ key: protocol, label: protocolDisplayLabel(protocol), count: (data?.values ?? []).filter((value) => value.protocol === protocol).length }))} />
<SegmentedTabs className="v2-telemetry-categories" ariaLabel="字段分类" value={activeCategory} onChange={setSelectedCategory} items={categories} />
</div>
{state}
<footer><b></b>{indexed.sources.map((source) => <span key={`${source.protocol}-${source.endpoint ?? ''}`} title={source.endpoint}>{source.protocol}</span>)}<small> {data?.scannedFrames ?? 0} · {formatTelemetryTime(data?.asOf)}</small></footer>
<footer><b></b>{indexed.sources.map((source) => <span key={`${source.protocol}-${source.endpoint ?? ''}`} title={source.endpoint}>{protocolDisplayLabel(source.protocol)}</span>)}<small> {data?.scannedFrames ?? 0} · {formatTelemetryTime(data?.asOf)}</small></footer>
<SideSheet
className="v2-telemetry-value-sidesheet"
visible={Boolean(inspectedValue)}
@@ -315,7 +315,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
size="small"
row
data={[
{ key: '协议', value: inspectedValue.protocol },
{ key: '协议', value: protocolDisplayLabel(inspectedValue.protocol) },
{ key: '数据类型', value: inspectedValue.valueType || '—' },
{ key: '单位', value: inspectedValue.unit || '—' },
{ key: '质量', value: telemetryQualityLabel(inspectedValue.quality) },
@@ -467,7 +467,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
description={<span className="v2-identity-vin"><small>VIN</small><b>{detail.vin}</b><Button theme="borderless" aria-label="复制 VIN" title="复制 VIN" icon={<IconCopy />} onClick={() => navigator.clipboard?.writeText(detail.vin)} /></span>}
meta={<span className="v2-identity-meta">
<span className="v2-identity-model"><small> / </small><strong>{[detail.profile?.brandName, detail.profile?.modelName].filter(Boolean).join(' / ') || fmt(identity?.oem || realtime?.oem)}</strong></span>
<span className="v2-identity-source-context"><small></small><span className="v2-identity-sources">{detail.sources.length ? detail.sources.map((source) => <Tag key={source} color="blue" type="light" size="small">{source}</Tag>) : <Tag color="grey" type="light" size="small"></Tag>}</span></span>
<span className="v2-identity-source-context"><small></small><span className="v2-identity-sources">{detail.sources.length ? detail.sources.map((source) => <Tag key={source} color="blue" type="light" size="small">{protocolDisplayLabel(source)}</Tag>) : <Tag color="grey" type="light" size="small"></Tag>}</span></span>
</span>}
actions={<div className="v2-identity-actions" role="group" aria-label="车辆快捷操作">
{actions.map((action) => <Button key={action.key} theme="light" type={action.type} icon={action.icon} aria-label={action.label} onClick={() => navigate(action.to)}>{action.label}</Button>)}
@@ -484,7 +484,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
<div role="listitem"><small>SOC</small><strong>{availableMetric(realtime?.socPercent, realtime?.socAvailable)}<em>%</em></strong><span></span></div>
<div role="listitem"><small></small><Button theme="borderless" type="tertiary" className="v2-live-source-link" title="展开全部里程来源" aria-label="查看总里程全部来源" onClick={() => setSourceEvidenceOpen(true)}><span>{availableMetric(realtime?.totalMileageKm, realtime?.mileageAvailable)}<em>km</em></span><IconChevronRight /></Button><span></span></div>
<div role="listitem"><small></small><Button theme="borderless" type="tertiary" className="v2-live-source-link" title="展开全部里程来源" aria-label="查看当日里程全部来源" onClick={() => setSourceEvidenceOpen(true)}><span>{availableMetric(realtime?.todayMileageKm, realtime?.todayMileageAvailable)}<em>km</em></span><IconChevronRight /></Button><span></span></div>
<div role="listitem"><small></small><strong className="is-text">{fmt(realtime?.primaryProtocol)}</strong><span title={realtime?.locationSource}>{realtime?.locationSource || '当前最优来源'}</span></div>
<div role="listitem"><small></small><strong className="is-text" title={realtime?.primaryProtocol}>{protocolDisplayLabel(realtime?.primaryProtocol)}</strong><span title={realtime?.locationSource}>{protocolSourceLabel(realtime?.locationSource) || '当前最优来源'}</span></div>
<div role="listitem"><small></small><strong>{realtime?.onlineSourceCount ?? 0}<em> / {detail.sourceStatus.length}</em></strong><span>线 / </span></div>
</div>
<CurrentVehicleAddress vehicle={realtime} fallback={identity?.locationText} />

View File

@@ -8,6 +8,7 @@ import type { Page, VehicleCoverageRow } from '../../api/types';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
import { formatZhNumber } from '../domain/formatters';
import { protocolDisplayLabel } from '../domain/protocols';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
import { TablePagination } from '../shared/TablePagination';
@@ -177,26 +178,26 @@ export default function VehicleSearchWorkspace() {
? <div className="v2-vehicle-recent-state is-error" role="alert"><span>{candidates.error instanceof Error ? candidates.error.message : '授权车辆加载失败'}</span><Button size="small" theme="light" onClick={() => candidates.refetch()}></Button></div>
: pageVehicles.length
? mobileLayout ? <div className="v2-vehicle-recent-grid" role="list" aria-label={deferredKeyword ? '匹配车辆' : '授权车辆目录'}>
{pageVehicles.map((vehicle) => <Button
key={vehicle.vin}
className="v2-vehicle-recent-item"
theme="borderless"
type="tertiary"
role="listitem"
aria-label={`打开 ${vehicle.plate || '未绑定车牌'} 车辆档案`}
onClick={() => openVehicle(vehicle.vin)}
>
<span className="v2-vehicle-recent-identity">
<strong>{vehicle.plate || '未绑定车牌'}</strong>
<small>{vehicle.vin}</small>
</span>
<span className="v2-vehicle-recent-status">
<Tag color={vehicle.online ? 'green' : 'grey'} type="light" size="small">{vehicle.online ? '在线' : '离线'}</Tag>
<small>{vehicleLastSeenLabel(vehicle.lastSeen)}</small>
</span>
<span className="v2-vehicle-recent-protocols">{vehicle.protocols.slice(0, 2).map((protocol) => <Tag key={protocol} color="blue" type="light" size="small">{protocol}</Tag>)}</span>
<IconChevronRight className="v2-vehicle-recent-arrow" />
</Button>)}
{pageVehicles.map((vehicle) => <div key={vehicle.vin} className="v2-vehicle-recent-item-shell" role="listitem">
<Button
className="v2-vehicle-recent-item"
theme="borderless"
type="tertiary"
aria-label={`打开 ${vehicle.plate || '未绑定车牌'} 车辆档案`}
onClick={() => openVehicle(vehicle.vin)}
>
<span className="v2-vehicle-recent-identity">
<strong>{vehicle.plate || '未绑定车牌'}</strong>
<small>{vehicle.vin}</small>
</span>
<span className="v2-vehicle-recent-status">
<Tag color={vehicle.online ? 'green' : 'grey'} type="light" size="small">{vehicle.online ? '在线' : '离线'}</Tag>
<small>{vehicleLastSeenLabel(vehicle.lastSeen)}</small>
</span>
<span className="v2-vehicle-recent-protocols">{vehicle.protocols.slice(0, 2).map((protocol) => <Tag key={protocol} color="blue" type="light" size="small">{protocolDisplayLabel(protocol)}</Tag>)}</span>
<IconChevronRight className="v2-vehicle-recent-arrow" />
</Button>
</div>)}
</div> : <Table
className="v2-vehicle-directory-table"
columns={[
@@ -221,7 +222,7 @@ export default function VehicleSearchWorkspace() {
{
title: '协议来源',
dataIndex: 'protocols',
render: (protocols: string[]) => <span className="v2-vehicle-directory-protocols">{protocols.length ? protocols.slice(0, 3).map((protocol) => <Tag key={protocol} color="blue" type="light" size="small">{protocol}</Tag>) : <span></span>}</span>
render: (protocols: string[]) => <span className="v2-vehicle-directory-protocols">{protocols.length ? protocols.slice(0, 3).map((protocol) => <Tag key={protocol} color="blue" type="light" size="small">{protocolDisplayLabel(protocol)}</Tag>) : <span></span>}</span>
},
{
title: '',

View File

@@ -24,10 +24,10 @@ test('renders consistent Semi vehicle options and keeps plate first', () => {
expect(screen.getByText('车辆候选')).toBeInTheDocument();
expect(screen.getByText('1/20 已选')).toBeInTheDocument();
const option = screen.getByRole('option', { name: '粤A00001 VIN001 GB32960 JT808 选择' });
const option = screen.getByRole('option', { name: '粤A00001 VIN001 GB/T 32960 JT/T 808 选择' });
expect(option).toHaveClass('semi-button', 'v2-vehicle-option');
expect(option).toHaveTextContent('粤A00001VIN001GB32960JT808选择');
expect(screen.getByRole('option', { name: '粤A00002 VIN002 JT808 已分配' })).toHaveAttribute('aria-selected', 'true');
expect(option).toHaveTextContent('粤A00001VIN001GB/T 32960JT/T 808选择');
expect(screen.getByRole('option', { name: '粤A00002 VIN002 JT/T 808 已分配' })).toHaveAttribute('aria-selected', 'true');
fireEvent.click(option);
expect(onSelect).toHaveBeenCalledWith(vehicles[0]);

View File

@@ -1,5 +1,6 @@
import { Button, Spin, Tag } from '@douyinfe/semi-ui';
import type { ReactNode } from 'react';
import { protocolDisplayLabel } from '../domain/protocols';
import { VehicleOptionError } from './VehicleOptionError';
export interface VehicleCandidateItem {
@@ -57,6 +58,7 @@ export function VehicleCandidateList<T extends VehicleCandidateItem>({
const selected = selectedVins?.has(vehicle.vin) ?? false;
const label = selected ? selectedLabel : actionLabel;
const protocols = showProtocols ? (vehicle.protocols ?? []).filter(Boolean) : [];
const protocolLabels = protocols.map(protocolDisplayLabel);
return <Button
key={vehicle.vin}
className={`v2-vehicle-option${selected ? ' is-selected' : ''}`}
@@ -64,7 +66,7 @@ export function VehicleCandidateList<T extends VehicleCandidateItem>({
type="tertiary"
role="option"
aria-selected={selected}
aria-label={`${vehicle.plate || '未绑定车牌'} ${vehicle.vin} ${protocols.join(' ')} ${label}`.replace(/\s+/g, ' ').trim()}
aria-label={`${vehicle.plate || '未绑定车牌'} ${vehicle.vin} ${protocolLabels.join(' ')} ${label}`.replace(/\s+/g, ' ').trim()}
disabled={selected && disableSelected}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onSelect(vehicle)}
@@ -73,7 +75,7 @@ export function VehicleCandidateList<T extends VehicleCandidateItem>({
<strong>{vehicle.plate || '未绑定车牌'}</strong>
<small>{vehicle.vin}</small>
</span>
{protocols.length ? <span className="v2-vehicle-option-protocols">{protocols.slice(0, 3).map((protocol) => <Tag key={protocol} color="grey" type="light" size="small">{protocol}</Tag>)}</span> : null}
{protocols.length ? <span className="v2-vehicle-option-protocols">{protocols.slice(0, 3).map((protocol) => <Tag key={protocol} color="grey" type="light" size="small">{protocolDisplayLabel(protocol)}</Tag>)}</span> : null}
<Tag className="v2-vehicle-option-action" color={selected ? 'blue' : 'grey'} type="light" size="small">{label}</Tag>
</Button>;
}) : null}

View File

@@ -3,6 +3,7 @@ import { Button, Card, Empty, 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 { QUERY_MEMORY } from '../queryPolicy';
function today() {
@@ -37,9 +38,15 @@ function sourceBadges(source: Pick<VehicleLocationSourceEvidence, 'recommended'
</>;
}
function evidenceSourceLabel(sourceLabel: string | undefined, protocol: string) {
const normalized = sourceLabel?.trim();
if (!normalized || normalized === protocol) return protocolDisplayLabel(protocol);
return protocolSourceLabel(normalized);
}
function LocationSourceCard({ source }: { source: VehicleLocationSourceEvidence }) {
return <Card className={`v2-source-evidence-card is-${evidenceTone(source)}`} bodyStyle={{ padding: 0 }}>
<header><div><strong>{source.sourceLabel || source.protocol}</strong><span>{source.protocol}{source.terminalLabel ? ` · ${source.terminalLabel}` : ''}</span></div><aside>{sourceBadges(source)}</aside></header>
<header><div><strong>{evidenceSourceLabel(source.sourceLabel, source.protocol)}</strong><span>{protocolDisplayLabel(source.protocol)}{source.terminalLabel ? ` · ${source.terminalLabel}` : ''}</span></div><aside>{sourceBadges(source)}</aside></header>
<dl>
<div><dt></dt><dd>{coordinate(source)}</dd></div>
<div><dt></dt><dd>{source.speedKmh == null ? '—' : `${number(source.speedKmh)} km/h`}</dd></div>
@@ -55,7 +62,7 @@ function LocationSourceCard({ source }: { source: VehicleLocationSourceEvidence
function MileageSourceCard({ source }: { source: VehicleMileageSourceEvidence }) {
const comparable = { ...source, online: true };
return <Card className={`v2-source-evidence-card is-${evidenceTone(comparable)}`} bodyStyle={{ padding: 0 }}>
<header><div><strong>{source.sourceLabel || source.protocol}</strong><span>{source.protocol}{source.terminalLabel ? ` · ${source.terminalLabel}` : ''}</span></div><aside>{sourceBadges(comparable)}</aside></header>
<header><div><strong>{evidenceSourceLabel(source.sourceLabel, source.protocol)}</strong><span>{protocolDisplayLabel(source.protocol)}{source.terminalLabel ? ` · ${source.terminalLabel}` : ''}</span></div><aside>{sourceBadges(comparable)}</aside></header>
<dl>
<div><dt></dt><dd>{source.dailyMileageKm == null ? '—' : `${number(source.dailyMileageKm)} km`}</dd></div>
<div><dt></dt><dd>{source.latestTotalMileageKm == null ? '—' : `${number(source.latestTotalMileageKm)} km`}</dd></div>
@@ -96,7 +103,7 @@ export function VehicleSourceEvidencePanel({
});
const sourceCount = (query.data?.locationSources.length ?? 0) + (query.data?.mileageSources.length ?? 0);
const description = useMemo(() => {
if (!query.data) return '按需读取,不影响车辆列表和地图刷新性能';
if (!query.data) return '按需读取,不影响实时刷新';
if (sourceCount <= 2) return '当前车辆来源较少,仅展示实际存在的证据';
return `已读取 ${query.data.locationSources.length} 个位置来源、${query.data.mileageSources.length} 个里程来源`;
}, [query.data, sourceCount]);
@@ -109,13 +116,13 @@ export function VehicleSourceEvidencePanel({
{expanded ? <div className="v2-source-evidence-body">
<div className="v2-source-evidence-toolbar">
<label><span></span><Input aria-label="里程日期" type="date" value={date} max={today()} onChange={setDate} /></label>
<small></small>
<small></small>
</div>
{query.isPending ? <div className="v2-source-evidence-state" role="status"><Spin size="small" /></div> : null}
{query.isError ? <div className="v2-source-evidence-state is-error"><span>{query.error instanceof Error ? query.error.message : '来源证据读取失败'}</span><Button theme="borderless" type="danger" size="small" onClick={() => void query.refetch()}></Button></div> : null}
{query.data ? <>
<div className="v2-source-evidence-summary">
<div><small></small><strong>{query.data.recommendedLocationLabel || query.data.recommendedLocationProtocol || '—'}</strong></div>
<div><small></small><strong>{evidenceSourceLabel(query.data.recommendedLocationLabel, query.data.recommendedLocationProtocol)}</strong></div>
<div><small></small><strong>{number(query.data.comparison.locationMaxDistanceM)}<em>m</em></strong></div>
<div><small></small><strong>{number(query.data.comparison.totalMileageDeltaKm)}<em>km</em></strong></div>
<div><small></small><strong>{number(query.data.comparison.reportTimeDeltaSeconds, 0)}<em>s</em></strong></div>

View File

@@ -2472,6 +2472,14 @@
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.v2-vehicle-recent-item-shell {
min-width: 0;
}
.v2-vehicle-recent-item-shell > .v2-vehicle-recent-item.semi-button {
height: 100%;
}
.v2-vehicle-recent-item.semi-button {
min-height: 78px;
}
@@ -2771,6 +2779,10 @@
grid-template-columns: 1fr;
}
.v2-vehicle-recent-item-shell:last-child > .v2-vehicle-recent-item.semi-button {
border-bottom: 0;
}
.v2-vehicle-recent-card > .semi-card-body > .v2-vehicle-directory-pagination {
min-height: 52px;
padding-inline: 10px;