feat(platform): harden telemetry pipeline and unify Semi UI workspaces
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { IconAlertTriangle, IconRefresh } from '@douyinfe/semi-icons';
|
||||
import { Button, Empty, Spin, Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
export function PageLoading({ label = '正在加载车辆数据' }: { label?: string }) {
|
||||
return <section className="v2-page-state" role="status" aria-live="polite">
|
||||
<header><span className="v2-spinner" /><div><strong>{label}</strong><small>导航与筛选状态已保留</small></div></header>
|
||||
<header><Spin size="large" /><div><Typography.Text strong>{label}</Typography.Text><Typography.Text type="tertiary" size="small">导航与筛选状态已保留</Typography.Text></div></header>
|
||||
<div className="v2-page-skeleton"><i /><i /><i /><i /></div>
|
||||
</section>;
|
||||
}
|
||||
@@ -12,11 +13,11 @@ export function InlineError({ message, onRetry }: { message: string; onRetry?: (
|
||||
<div className="v2-inline-state is-error" role="alert">
|
||||
<IconAlertTriangle />
|
||||
<span>{message}</span>
|
||||
{onRetry ? <button type="button" onClick={onRetry}><IconRefresh />重试</button> : null}
|
||||
{onRetry ? <Button theme="light" icon={<IconRefresh />} onClick={onRetry}>重试</Button> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ title = '暂无符合条件的车辆' }: { title?: string }) {
|
||||
return <div className="v2-inline-state"><span>{title}</span></div>;
|
||||
return <div className="v2-inline-state"><Empty title={title} /></div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { MetricActionButton } from './MetricActionButton';
|
||||
|
||||
describe('MetricActionButton', () => {
|
||||
test('uses a pressed Semi button for the active metric filter', () => {
|
||||
const onClick = vi.fn();
|
||||
render(<MetricActionButton label="未处理" value="12" tone="warning" active ariaLabel="筛选未处理告警,共 12 条" onClick={onClick} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: '筛选未处理告警,共 12 条', pressed: true });
|
||||
expect(button).toHaveClass('semi-button', 'v2-metric-action', 'is-active');
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type MetricActionButtonProps = {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
onClick: () => void;
|
||||
tone?: string;
|
||||
hint?: ReactNode;
|
||||
active?: boolean;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export function MetricActionButton({
|
||||
label,
|
||||
value,
|
||||
onClick,
|
||||
tone = 'default',
|
||||
hint,
|
||||
active = false,
|
||||
ariaLabel
|
||||
}: MetricActionButtonProps) {
|
||||
return <Button
|
||||
className={`v2-metric-action is-${tone}${active ? ' is-active' : ''}`}
|
||||
theme={active ? 'light' : 'borderless'}
|
||||
type="tertiary"
|
||||
htmlType="button"
|
||||
aria-pressed={active}
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="v2-metric-action-content"><small>{label}</small><strong>{value}</strong>{hint ? <em>{hint}</em> : null}</span>
|
||||
</Button>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { MobileFilterToggle } from './MobileFilterToggle';
|
||||
|
||||
describe('MobileFilterToggle', () => {
|
||||
test('exposes the current filter summary and expanded state through a Semi button', () => {
|
||||
const onToggle = vi.fn();
|
||||
const { rerender } = render(<MobileFilterToggle summary="全部车辆 · 7 天" expanded={false} onToggle={onToggle} collapsedLabel="修改" />);
|
||||
|
||||
const collapsed = screen.getByRole('button', { name: '修改筛选条件:全部车辆 · 7 天' });
|
||||
expect(collapsed).toHaveClass('semi-button');
|
||||
expect(collapsed).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(collapsed);
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<MobileFilterToggle summary="已选 2 辆 · 7 天" expanded onToggle={onToggle} collapsedLabel="修改" />);
|
||||
expect(screen.getByRole('button', { name: '收起筛选条件:已选 2 辆 · 7 天' })).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { IconChevronDown, IconChevronUp, IconFilter } from '@douyinfe/semi-icons';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
|
||||
type MobileFilterToggleProps = {
|
||||
title?: string;
|
||||
summary: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
expandedLabel?: string;
|
||||
collapsedLabel?: string;
|
||||
};
|
||||
|
||||
export function MobileFilterToggle({
|
||||
title = '筛选条件',
|
||||
summary,
|
||||
expanded,
|
||||
onToggle,
|
||||
expandedLabel = '收起',
|
||||
collapsedLabel = '展开'
|
||||
}: MobileFilterToggleProps) {
|
||||
const actionLabel = expanded ? expandedLabel : collapsedLabel;
|
||||
return <Button
|
||||
className="v2-mobile-filter-toggle"
|
||||
theme="light"
|
||||
type="tertiary"
|
||||
block
|
||||
htmlType="button"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${actionLabel}${title}:${summary}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="v2-mobile-filter-toggle-content">
|
||||
<span className="v2-mobile-filter-toggle-icon" aria-hidden="true"><IconFilter /></span>
|
||||
<span className="v2-mobile-filter-toggle-copy"><b>{title}</b><small>{summary}</small></span>
|
||||
<span className="v2-mobile-filter-toggle-action">{actionLabel}{expanded ? <IconChevronUp /> : <IconChevronDown />}</span>
|
||||
</span>
|
||||
</Button>;
|
||||
}
|
||||
33
vehicle-data-platform/apps/web/src/v2/shared/PageHeader.tsx
Normal file
33
vehicle-data-platform/apps/web/src/v2/shared/PageHeader.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
statusColor = 'blue',
|
||||
meta,
|
||||
actions
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
status?: string;
|
||||
statusColor?: React.ComponentProps<typeof Tag>['color'];
|
||||
meta?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="v2-page-heading">
|
||||
<div className="v2-page-heading-copy">
|
||||
<div>
|
||||
<Title heading={2}>{title}</Title>
|
||||
{status ? <Tag color={statusColor} size="small">{status}</Tag> : null}
|
||||
</div>
|
||||
<Text type="secondary">{description}</Text>
|
||||
</div>
|
||||
{meta || actions ? <div className="v2-page-heading-aside">{meta}{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { MapRetryAction, RecoveryActions } from './RecoveryActions';
|
||||
|
||||
describe('RecoveryActions', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
test('renders a compact Semi retry action for map errors', () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<MapRetryAction onRetry={onRetry} />);
|
||||
const action = screen.getByRole('button', { name: '重新加载地图' });
|
||||
expect(action).toHaveClass('semi-button', 'v2-map-retry-action');
|
||||
fireEvent.click(action);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('renders primary and secondary recovery hierarchy with Semi buttons', () => {
|
||||
const onPrimary = vi.fn();
|
||||
const onSecondary = vi.fn();
|
||||
render(<RecoveryActions primaryLabel="重试加载模块" onPrimary={onPrimary} secondaryLabel="刷新当前页面" onSecondary={onSecondary} showHome={false} />);
|
||||
|
||||
const primary = screen.getByRole('button', { name: '重试加载模块' });
|
||||
const secondary = screen.getByRole('button', { name: '刷新当前页面' });
|
||||
expect(primary).toHaveClass('semi-button-primary');
|
||||
expect(secondary).toHaveClass('semi-button-light');
|
||||
fireEvent.click(primary);
|
||||
fireEvent.click(secondary);
|
||||
expect(onPrimary).toHaveBeenCalledOnce();
|
||||
expect(onSecondary).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { IconHome, IconRefresh } from '@douyinfe/semi-icons';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
|
||||
export function MapRetryAction({ onRetry, label = '重新加载地图' }: { onRetry: () => void; label?: string }) {
|
||||
return <Button
|
||||
className="v2-map-retry-action"
|
||||
theme="light"
|
||||
type="danger"
|
||||
size="small"
|
||||
icon={<IconRefresh />}
|
||||
aria-label={label}
|
||||
onClick={onRetry}
|
||||
>
|
||||
{label}
|
||||
</Button>;
|
||||
}
|
||||
|
||||
export function RecoveryActions({
|
||||
primaryLabel,
|
||||
onPrimary,
|
||||
secondaryLabel,
|
||||
onSecondary,
|
||||
showHome = true
|
||||
}: {
|
||||
primaryLabel: string;
|
||||
onPrimary: () => void;
|
||||
secondaryLabel?: string;
|
||||
onSecondary?: () => void;
|
||||
showHome?: boolean;
|
||||
}) {
|
||||
return <footer className="v2-recovery-actions">
|
||||
<Button theme="solid" type="primary" icon={<IconRefresh />} aria-label={primaryLabel} onClick={onPrimary}>{primaryLabel}</Button>
|
||||
{secondaryLabel && onSecondary ? <Button theme="light" icon={<IconRefresh />} aria-label={secondaryLabel} onClick={onSecondary}>{secondaryLabel}</Button> : null}
|
||||
{showHome ? <Button theme="borderless" type="tertiary" icon={<IconHome />} aria-label="返回全局监控" onClick={() => window.location.assign('/monitor')}>返回全局监控</Button> : null}
|
||||
</footer>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { SegmentedTabs } from './SegmentedTabs';
|
||||
|
||||
function Example() {
|
||||
const [value, setValue] = useState<'one' | 'two' | 'three'>('one');
|
||||
return <SegmentedTabs
|
||||
ariaLabel="示例页签"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
items={[
|
||||
{ key: 'one', label: '第一个', count: 3 },
|
||||
{ key: 'two', label: '第二个' },
|
||||
{ key: 'three', label: '第三个' }
|
||||
]}
|
||||
/>;
|
||||
}
|
||||
|
||||
describe('SegmentedTabs', () => {
|
||||
test('uses Semi buttons with tab semantics and keyboard navigation', () => {
|
||||
render(<Example />);
|
||||
const first = screen.getByRole('tab', { name: /第一个\s+3/, selected: true });
|
||||
expect(first).toHaveClass('semi-button');
|
||||
expect(first).toHaveAttribute('tabindex', '0');
|
||||
|
||||
fireEvent.keyDown(first, { key: 'ArrowRight' });
|
||||
expect(screen.getByRole('tab', { name: '第二个', selected: true })).toHaveAttribute('tabindex', '0');
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('tab', { name: '第二个' }), { key: 'End' });
|
||||
expect(screen.getByRole('tab', { name: '第三个', selected: true })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import type { KeyboardEvent, ReactNode } from 'react';
|
||||
|
||||
export type SegmentedTabItem<Key extends string> = {
|
||||
key: Key;
|
||||
label: ReactNode;
|
||||
icon?: ReactNode;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
type SegmentedTabsProps<Key extends string> = {
|
||||
ariaLabel: string;
|
||||
value: Key;
|
||||
items: readonly SegmentedTabItem<Key>[];
|
||||
onChange: (key: Key) => void;
|
||||
className?: string;
|
||||
variant?: 'filled' | 'line';
|
||||
};
|
||||
|
||||
export function SegmentedTabs<Key extends string>({
|
||||
ariaLabel,
|
||||
value,
|
||||
items,
|
||||
onChange,
|
||||
className = '',
|
||||
variant = 'line'
|
||||
}: SegmentedTabsProps<Key>) {
|
||||
const selectFromKeyboard = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
let nextIndex = index;
|
||||
if (event.key === 'ArrowRight') nextIndex = (index + 1) % items.length;
|
||||
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + items.length) % items.length;
|
||||
else if (event.key === 'Home') nextIndex = 0;
|
||||
else if (event.key === 'End') nextIndex = items.length - 1;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
onChange(items[nextIndex].key);
|
||||
const tabs = event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="tab"]');
|
||||
window.requestAnimationFrame(() => tabs?.[nextIndex]?.focus());
|
||||
};
|
||||
|
||||
return <div className={`v2-segmented-tabs is-${variant}${className ? ` ${className}` : ''}`} role="tablist" aria-label={ariaLabel}>
|
||||
{items.map((item, index) => {
|
||||
const selected = item.key === value;
|
||||
return <Button
|
||||
className={selected ? 'is-active' : ''}
|
||||
theme={variant === 'filled' && selected ? 'solid' : 'borderless'}
|
||||
type={selected ? 'primary' : 'tertiary'}
|
||||
size="small"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onKeyDown={(event) => selectFromKeyboard(event, index)}
|
||||
onClick={() => onChange(item.key)}
|
||||
key={item.key}
|
||||
>
|
||||
<span className="v2-segmented-tab-label">{item.icon}<span>{item.label}</span>{item.count != null ? <em className="v2-segmented-tab-count">{item.count}</em> : null}</span>
|
||||
</Button>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { TablePagination } from './TablePagination';
|
||||
|
||||
describe('TablePagination', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
test('renders one shared Semi pagination pattern and changes pages', () => {
|
||||
const onPageChange = vi.fn();
|
||||
render(<footer><TablePagination page={2} totalPages={4} info="共 80 条" onPageChange={onPageChange} /></footer>);
|
||||
|
||||
expect(screen.getByText('共 80 条')).toHaveClass('v2-table-pagination-info');
|
||||
expect(screen.getByText('2').closest('.v2-table-pagination-current')).toHaveTextContent('2/4');
|
||||
const previous = screen.getByRole('button', { name: '上一页' });
|
||||
const next = screen.getByRole('button', { name: '下一页' });
|
||||
expect(previous).toHaveClass('semi-button');
|
||||
expect(next).toHaveClass('semi-button');
|
||||
fireEvent.click(previous);
|
||||
fireEvent.click(next);
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(1, 1);
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(2, 3);
|
||||
});
|
||||
|
||||
test('clamps invalid pages and disables both boundaries for a single page', () => {
|
||||
render(<footer><TablePagination page={0} totalPages={0} info="暂无数据" onPageChange={vi.fn()} /></footer>);
|
||||
expect(screen.getByRole('button', { name: '上一页' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '下一页' })).toBeDisabled();
|
||||
expect(document.querySelector('.v2-table-pagination-current')).toHaveTextContent('1/1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { IconChevronLeft, IconChevronRight } from '@douyinfe/semi-icons';
|
||||
import { Button, Select } from '@douyinfe/semi-ui';
|
||||
import { useId, type ReactNode } from 'react';
|
||||
|
||||
type PageSizeOption = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type TablePaginationProps = {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
info: ReactNode;
|
||||
onPageChange: (page: number) => void;
|
||||
disabled?: boolean;
|
||||
pageSize?: number;
|
||||
pageSizeOptions?: PageSizeOption[];
|
||||
pageSizeLabel?: string;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
};
|
||||
|
||||
export function TablePagination({
|
||||
page,
|
||||
totalPages,
|
||||
info,
|
||||
onPageChange,
|
||||
disabled = false,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
pageSizeLabel = '每页数量',
|
||||
onPageSizeChange
|
||||
}: TablePaginationProps) {
|
||||
const safeTotalPages = Math.max(1, totalPages);
|
||||
const safePage = Math.min(Math.max(1, page), safeTotalPages);
|
||||
const pageSizeID = `v2-page-size-${useId().replace(/:/g, '')}`;
|
||||
|
||||
return <>
|
||||
<span className="v2-table-pagination-info">{info}</span>
|
||||
<div className="v2-table-pagination-controls">
|
||||
<Button
|
||||
className="v2-table-pagination-button"
|
||||
theme="light"
|
||||
icon={<IconChevronLeft />}
|
||||
disabled={disabled || safePage <= 1}
|
||||
aria-label="上一页"
|
||||
onClick={() => onPageChange(safePage - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="v2-table-pagination-current" aria-live="polite" aria-atomic="true">
|
||||
<b>{safePage}</b><i>/</i><span>{safeTotalPages}</span>
|
||||
</span>
|
||||
<Button
|
||||
className="v2-table-pagination-button"
|
||||
theme="light"
|
||||
icon={<IconChevronRight />}
|
||||
iconPosition="right"
|
||||
disabled={disabled || safePage >= safeTotalPages}
|
||||
aria-label="下一页"
|
||||
onClick={() => onPageChange(safePage + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
{pageSize != null && pageSizeOptions?.length && onPageSizeChange ? <>
|
||||
<span className="v2-sr-only" id={pageSizeID}>{pageSizeLabel}</span>
|
||||
<Select
|
||||
className="v2-table-pagination-size"
|
||||
aria-labelledby={pageSizeID}
|
||||
disabled={disabled}
|
||||
value={pageSize}
|
||||
onChange={(value) => onPageSizeChange(Number(value))}
|
||||
optionList={pageSizeOptions}
|
||||
/>
|
||||
</> : null}
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { VehicleCandidateList } from './VehicleCandidateList';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const vehicles = [
|
||||
{ vin: 'VIN001', plate: '粤A00001', protocols: ['GB32960', 'JT808'] },
|
||||
{ vin: 'VIN002', plate: '粤A00002', protocols: ['JT808'] }
|
||||
];
|
||||
|
||||
test('renders consistent Semi vehicle options and keeps plate first', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<VehicleCandidateList
|
||||
items={vehicles}
|
||||
selectedVins={new Set(['VIN002'])}
|
||||
selectedLabel="已分配"
|
||||
actionLabel="选择"
|
||||
showProtocols
|
||||
header="车辆候选"
|
||||
meta="1/20 已选"
|
||||
onSelect={onSelect}
|
||||
/>);
|
||||
|
||||
expect(screen.getByText('车辆候选')).toBeInTheDocument();
|
||||
expect(screen.getByText('1/20 已选')).toBeInTheDocument();
|
||||
const option = screen.getByRole('option', { name: '粤A00001 VIN001 GB32960 JT808 选择' });
|
||||
expect(option).toHaveClass('semi-button', 'v2-vehicle-option');
|
||||
expect(option).toHaveTextContent('粤A00001VIN001GB32960JT808选择');
|
||||
expect(screen.getByRole('option', { name: '粤A00002 VIN002 JT808 已分配' })).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
fireEvent.click(option);
|
||||
expect(onSelect).toHaveBeenCalledWith(vehicles[0]);
|
||||
});
|
||||
|
||||
test('uses the shared retry and empty states', () => {
|
||||
const onRetry = vi.fn();
|
||||
const { rerender } = render(<VehicleCandidateList items={[]} error="车辆目录查询超时" onRetry={onRetry} onSelect={() => undefined} />);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('车辆目录查询超时');
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }));
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
|
||||
rerender(<VehicleCandidateList items={[]} emptyText="没有已绑定车辆" onSelect={() => undefined} />);
|
||||
expect(screen.getByText('没有已绑定车辆')).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Button, Spin, Tag } from '@douyinfe/semi-ui';
|
||||
import type { ReactNode } from 'react';
|
||||
import { VehicleOptionError } from './VehicleOptionError';
|
||||
|
||||
export interface VehicleCandidateItem {
|
||||
vin: string;
|
||||
plate?: string;
|
||||
protocols?: string[];
|
||||
}
|
||||
|
||||
interface VehicleCandidateListProps<T extends VehicleCandidateItem> {
|
||||
id?: string;
|
||||
items: T[];
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
error?: string;
|
||||
onRetry?: () => void;
|
||||
emptyText?: string;
|
||||
selectedVins?: ReadonlySet<string>;
|
||||
disableSelected?: boolean;
|
||||
actionLabel?: string;
|
||||
selectedLabel?: string;
|
||||
showProtocols?: boolean;
|
||||
header?: ReactNode;
|
||||
meta?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
layout?: 'list' | 'grid';
|
||||
onSelect: (vehicle: T) => void;
|
||||
}
|
||||
|
||||
export function VehicleCandidateList<T extends VehicleCandidateItem>({
|
||||
id,
|
||||
items,
|
||||
loading = false,
|
||||
loadingText = '正在搜索车辆…',
|
||||
error,
|
||||
onRetry,
|
||||
emptyText = '没有匹配车辆',
|
||||
selectedVins,
|
||||
disableSelected = false,
|
||||
actionLabel = '选择',
|
||||
selectedLabel = '已选择',
|
||||
showProtocols = false,
|
||||
header,
|
||||
meta,
|
||||
footer,
|
||||
className = '',
|
||||
layout = 'list',
|
||||
onSelect
|
||||
}: VehicleCandidateListProps<T>) {
|
||||
return <div id={id} className={`v2-vehicle-candidate-list is-${layout}${className ? ` ${className}` : ''}`} role="listbox" aria-busy={loading}>
|
||||
{header || meta ? <header><span>{header}</span>{meta ? <em>{meta}</em> : null}</header> : null}
|
||||
{loading ? <div className="v2-vehicle-candidate-state" role="status"><Spin size="small" /><span>{loadingText}</span></div> : null}
|
||||
{!loading && error && onRetry ? <VehicleOptionError message={error} onRetry={onRetry} /> : null}
|
||||
{!loading && !error ? items.map((vehicle) => {
|
||||
const selected = selectedVins?.has(vehicle.vin) ?? false;
|
||||
const label = selected ? selectedLabel : actionLabel;
|
||||
const protocols = showProtocols ? (vehicle.protocols ?? []).filter(Boolean) : [];
|
||||
return <Button
|
||||
key={vehicle.vin}
|
||||
className={`v2-vehicle-option${selected ? ' is-selected' : ''}`}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-label={`${vehicle.plate || '未绑定车牌'} ${vehicle.vin} ${protocols.join(' ')} ${label}`.replace(/\s+/g, ' ').trim()}
|
||||
disabled={selected && disableSelected}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onSelect(vehicle)}
|
||||
>
|
||||
<span className="v2-vehicle-option-identity">
|
||||
<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}
|
||||
<Tag className="v2-vehicle-option-action" color={selected ? 'blue' : 'grey'} type="light" size="small">{label}</Tag>
|
||||
</Button>;
|
||||
}) : null}
|
||||
{!loading && !error && items.length === 0 ? <div className="v2-vehicle-candidate-state is-empty"><span>{emptyText}</span></div> : null}
|
||||
{footer ? <footer>{footer}</footer> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { VehicleOptionError } from './VehicleOptionError';
|
||||
|
||||
describe('VehicleOptionError', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
test('renders a shared Semi retry action without blurring the picker', () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<VehicleOptionError message="车辆候选加载失败" onRetry={onRetry} />);
|
||||
|
||||
const retry = screen.getByRole('button', { name: '重试' });
|
||||
const mouseDown = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
|
||||
retry.dispatchEvent(mouseDown);
|
||||
expect(mouseDown.defaultPrevented).toBe(true);
|
||||
expect(retry).toHaveClass('semi-button');
|
||||
fireEvent.click(retry);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
|
||||
type VehicleOptionErrorProps = {
|
||||
message: string;
|
||||
onRetry: () => void | Promise<unknown>;
|
||||
};
|
||||
|
||||
export function VehicleOptionError({ message, onRetry }: VehicleOptionErrorProps) {
|
||||
return <div className="v2-vehicle-option-error" role="alert">
|
||||
<span>{message}</span>
|
||||
<Button
|
||||
size="small"
|
||||
theme="light"
|
||||
type="danger"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => void onRetry()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</div>;
|
||||
}
|
||||
@@ -46,12 +46,15 @@ test('loads all source evidence only after the user expands it', async () => {
|
||||
const sourceEvidence = vi.spyOn(api, 'vehicleSourceEvidence').mockResolvedValue(evidence);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
render(<QueryClientProvider client={client}><VehicleSourceEvidencePanel vin="VIN-001" /></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={client}><VehicleSourceEvidencePanel vin="VIN-001" /></QueryClientProvider>);
|
||||
|
||||
expect(view.container.querySelector('.v2-source-evidence')).toHaveClass('semi-card');
|
||||
expect(sourceEvidence).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看全部来源' }));
|
||||
await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText('北斗平台')).toBeInTheDocument();
|
||||
expect(view.container.querySelectorAll('.v2-source-evidence-card.semi-card')).toHaveLength(3);
|
||||
expect(screen.getAllByText('当前推荐').every((item) => item.closest('.semi-tag'))).toBe(true);
|
||||
expect(screen.getAllByText('当前推荐').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('13307795425')).not.toBeInTheDocument();
|
||||
client.clear();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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';
|
||||
@@ -28,16 +29,16 @@ function evidenceTone(source: Pick<VehicleLocationSourceEvidence, 'recommended'
|
||||
|
||||
function sourceBadges(source: Pick<VehicleLocationSourceEvidence, 'recommended' | 'selectedWithinProtocol' | 'enabled' | 'online' | 'qualityStatus'>) {
|
||||
return <>
|
||||
{source.recommended ? <b className="is-recommended">当前推荐</b> : null}
|
||||
{!source.recommended && source.selectedWithinProtocol ? <b>协议内选中</b> : null}
|
||||
{!source.enabled ? <b className="is-disabled">已禁用</b> : null}
|
||||
{source.enabled && !source.online ? <b className="is-offline">离线</b> : null}
|
||||
{source.qualityStatus !== 'OK' ? <b className="is-warning">{source.qualityStatus}</b> : null}
|
||||
{source.recommended ? <Tag className="is-recommended" color="blue" type="light" size="small">当前推荐</Tag> : null}
|
||||
{!source.recommended && source.selectedWithinProtocol ? <Tag color="cyan" type="light" size="small">协议内选中</Tag> : null}
|
||||
{!source.enabled ? <Tag className="is-disabled" color="grey" type="light" size="small">已禁用</Tag> : null}
|
||||
{source.enabled && !source.online ? <Tag className="is-offline" color="grey" type="light" size="small">离线</Tag> : null}
|
||||
{source.qualityStatus !== 'OK' ? <Tag className="is-warning" color="orange" type="light" size="small">{source.qualityStatus}</Tag> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
function LocationSourceCard({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <article className={`v2-source-evidence-card is-${evidenceTone(source)}`}>
|
||||
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>
|
||||
<dl>
|
||||
<div><dt>坐标</dt><dd>{coordinate(source)}</dd></div>
|
||||
@@ -48,12 +49,12 @@ function LocationSourceCard({ source }: { source: VehicleLocationSourceEvidence
|
||||
<div><dt>接收时间</dt><dd>{source.receivedAt || '—'}</dd></div>
|
||||
</dl>
|
||||
{source.qualityReason ? <p>质量说明:{source.qualityReason}</p> : null}
|
||||
</article>;
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function MileageSourceCard({ source }: { source: VehicleMileageSourceEvidence }) {
|
||||
const comparable = { ...source, online: true };
|
||||
return <article className={`v2-source-evidence-card is-${evidenceTone(comparable)}`}>
|
||||
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>
|
||||
<dl>
|
||||
<div><dt>当日里程</dt><dd>{source.dailyMileageKm == null ? '—' : `${number(source.dailyMileageKm)} km`}</dd></div>
|
||||
@@ -64,7 +65,7 @@ function MileageSourceCard({ source }: { source: VehicleMileageSourceEvidence })
|
||||
<div><dt>末条时间</dt><dd>{source.latestEventTime || '—'}</dd></div>
|
||||
</dl>
|
||||
{source.qualityReason ? <p>选举说明:{source.qualityReason}</p> : null}
|
||||
</article>;
|
||||
</Card>;
|
||||
}
|
||||
|
||||
export function VehicleSourceEvidencePanel({
|
||||
@@ -100,18 +101,18 @@ export function VehicleSourceEvidencePanel({
|
||||
return `已读取 ${query.data.locationSources.length} 个位置来源、${query.data.mileageSources.length} 个里程来源`;
|
||||
}, [query.data, sourceCount]);
|
||||
|
||||
return <section className={`v2-source-evidence${compact ? ' is-compact' : ''}${expanded ? ' is-open' : ''}`}>
|
||||
return <Card className={`v2-source-evidence${compact ? ' is-compact' : ''}${expanded ? ' is-open' : ''}`} bodyStyle={{ padding: 0 }}>
|
||||
<header className="v2-source-evidence-trigger">
|
||||
<div><strong>位置与里程来源</strong><span>{description}</span></div>
|
||||
<button type="button" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>{expanded ? '收起来源' : '查看全部来源'}</button>
|
||||
<Button theme="light" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>{expanded ? '收起来源' : '查看全部来源'}</Button>
|
||||
</header>
|
||||
{expanded ? <div className="v2-source-evidence-body">
|
||||
<div className="v2-source-evidence-toolbar">
|
||||
<label><span>里程日期</span><input type="date" value={date} max={today()} onChange={(event) => setDate(event.target.value)} /></label>
|
||||
<label><span>里程日期</span><Input aria-label="里程日期" type="date" value={date} max={today()} onChange={setDate} /></label>
|
||||
<small>终端标识已脱敏;推荐结果来自后台选举,展开不会改变原始证据。</small>
|
||||
</div>
|
||||
{query.isPending ? <div className="v2-source-evidence-state"><i />正在读取全部来源证据…</div> : null}
|
||||
{query.isError ? <div className="v2-source-evidence-state is-error"><span>{query.error instanceof Error ? query.error.message : '来源证据读取失败'}</span><button type="button" onClick={() => void query.refetch()}>重试</button></div> : null}
|
||||
{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>
|
||||
@@ -121,8 +122,8 @@ 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 ? <div className="v2-source-evidence-state">该车辆当前没有可展示的来源证据</div> : null}
|
||||
{!query.data.locationSources.length && !query.data.mileageSources.length ? <Empty className="v2-source-evidence-empty" title="暂无来源证据" description="该车辆当前没有可展示的位置或里程来源。" /> : null}
|
||||
</> : null}
|
||||
</div> : null}
|
||||
</section>;
|
||||
</Card>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import { WorkspacePanelHeader } from './WorkspacePanelHeader';
|
||||
|
||||
describe('WorkspacePanelHeader', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
test('renders one shared Semi workspace heading with metadata and actions', () => {
|
||||
render(<WorkspacePanelHeader title="数据明细" description="最近一次查询结果" meta="共 20 条" actions={<Button>刷新</Button>} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: '数据明细', level: 5 })).toHaveClass('semi-typography');
|
||||
expect(screen.getByRole('heading', { name: '数据明细', level: 5 }).closest('header')).toHaveClass('is-default');
|
||||
expect(screen.getByText('最近一次查询结果')).toHaveClass('semi-typography');
|
||||
expect(screen.getByText('共 20 条')).toHaveClass('v2-workspace-panel-meta');
|
||||
expect(screen.getByRole('button', { name: '刷新' })).toHaveClass('semi-button');
|
||||
});
|
||||
|
||||
test('supports compact and inverted panel anatomy without changing semantics', () => {
|
||||
const { rerender } = render(<WorkspacePanelHeader variant="compact" title="行程概览" meta="完整点集" />);
|
||||
expect(screen.getByRole('heading', { name: '行程概览', level: 5 }).closest('header')).toHaveClass('is-compact');
|
||||
|
||||
rerender(<WorkspacePanelHeader variant="inverted" title="粤A12345" description="07/17 11:30:00" meta="50%" />);
|
||||
expect(screen.getByRole('heading', { name: '粤A12345', level: 5 }).closest('header')).toHaveClass('is-inverted');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
export function WorkspacePanelHeader({
|
||||
title,
|
||||
description,
|
||||
meta,
|
||||
actions,
|
||||
variant = 'default',
|
||||
className = '',
|
||||
actionsClassName = ''
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
meta?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
variant?: 'default' | 'compact' | 'inverted';
|
||||
className?: string;
|
||||
actionsClassName?: string;
|
||||
}) {
|
||||
const headerClassName = ['v2-workspace-panel-header', `is-${variant}`, className].filter(Boolean).join(' ');
|
||||
const trailingClassName = ['v2-workspace-panel-actions', actionsClassName].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<header className={headerClassName}>
|
||||
<div className="v2-workspace-panel-copy">
|
||||
<Title heading={5}>{title}</Title>
|
||||
{description ? <Text type="tertiary">{description}</Text> : null}
|
||||
</div>
|
||||
{meta || actions ? <div className={trailingClassName}>
|
||||
{meta ? <span className="v2-workspace-panel-meta">{meta}</span> : null}
|
||||
{actions}
|
||||
</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { detailTriggerRow } from './detailTriggerRow';
|
||||
|
||||
describe('detailTriggerRow', () => {
|
||||
test('opens from pointer, Enter, and Space with consistent expanded semantics', () => {
|
||||
const onOpen = vi.fn();
|
||||
const props = detailTriggerRow({
|
||||
className: 'is-selected',
|
||||
expanded: true,
|
||||
label: '查看车辆详情',
|
||||
testId: 'vehicle-row',
|
||||
onOpen
|
||||
});
|
||||
|
||||
expect(props).toMatchObject({
|
||||
className: 'is-selected',
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
'aria-expanded': true,
|
||||
'aria-label': '查看车辆详情',
|
||||
'data-testid': 'vehicle-row'
|
||||
});
|
||||
|
||||
props.onClick?.({} as never);
|
||||
const enter = { key: 'Enter', preventDefault: vi.fn() };
|
||||
props.onKeyDown?.(enter as never);
|
||||
const space = { key: ' ', preventDefault: vi.fn() };
|
||||
props.onKeyDown?.(space as never);
|
||||
props.onKeyDown?.({ key: 'Escape', preventDefault: vi.fn() } as never);
|
||||
|
||||
expect(onOpen).toHaveBeenCalledTimes(3);
|
||||
expect(enter.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(space.preventDefault).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
type DetailTriggerRowOptions = {
|
||||
expanded: boolean;
|
||||
label: string;
|
||||
onOpen: () => void;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
type DetailTriggerRowProps = HTMLAttributes<HTMLElement> & {
|
||||
role: 'button';
|
||||
tabIndex: 0;
|
||||
'aria-expanded': boolean;
|
||||
'data-testid'?: string;
|
||||
};
|
||||
|
||||
export function detailTriggerRow({
|
||||
expanded,
|
||||
label,
|
||||
onOpen,
|
||||
className = '',
|
||||
testId
|
||||
}: DetailTriggerRowOptions): DetailTriggerRowProps {
|
||||
return {
|
||||
className,
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
'aria-expanded': expanded,
|
||||
'aria-label': label,
|
||||
...(testId ? { 'data-testid': testId } : {}),
|
||||
onClick: onOpen,
|
||||
onKeyDown: (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { mergeVehicleCandidates } from './vehicleCandidates';
|
||||
|
||||
test('deduplicates candidates by normalized VIN and merges protocol evidence', () => {
|
||||
const result = mergeVehicleCandidates([
|
||||
{ vin: ' ltest000000000001 ', plate: '', protocol: 'JT808' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'GB32960' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', protocols: ['JT808', 'YUTONG_MQTT'] },
|
||||
{ vin: '', plate: '无效记录', protocol: 'JT808' }
|
||||
]);
|
||||
|
||||
expect(result).toEqual([{
|
||||
vin: 'LTEST000000000001',
|
||||
plate: '粤A12345',
|
||||
protocol: 'JT808',
|
||||
protocols: ['JT808', 'GB32960', 'YUTONG_MQTT']
|
||||
}]);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
export type VehicleCandidateSource = {
|
||||
vin: string;
|
||||
plate?: string;
|
||||
protocol?: string;
|
||||
protocols?: string[];
|
||||
};
|
||||
|
||||
export type MergedVehicleCandidate<T extends VehicleCandidateSource> = T & {
|
||||
protocols: string[];
|
||||
};
|
||||
|
||||
export function mergeVehicleCandidates<T extends VehicleCandidateSource>(
|
||||
vehicles: readonly T[]
|
||||
): Array<MergedVehicleCandidate<T>> {
|
||||
const byVIN = new Map<string, MergedVehicleCandidate<T>>();
|
||||
|
||||
for (const vehicle of vehicles) {
|
||||
const vin = vehicle.vin.trim().toUpperCase();
|
||||
if (!vin) continue;
|
||||
const incomingProtocols = new Set([
|
||||
...(vehicle.protocols ?? []),
|
||||
vehicle.protocol ?? ''
|
||||
].map((protocol) => protocol.trim()).filter(Boolean));
|
||||
const current = byVIN.get(vin);
|
||||
|
||||
if (!current) {
|
||||
byVIN.set(vin, {
|
||||
...vehicle,
|
||||
vin,
|
||||
protocols: [...incomingProtocols]
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const protocols = new Set([...current.protocols, ...incomingProtocols]);
|
||||
byVIN.set(vin, {
|
||||
...current,
|
||||
...(!current.plate && vehicle.plate ? { plate: vehicle.plate } : {}),
|
||||
protocols: [...protocols]
|
||||
});
|
||||
}
|
||||
|
||||
return [...byVIN.values()];
|
||||
}
|
||||
Reference in New Issue
Block a user