feat(platform): harden telemetry pipeline and unify Semi UI workspaces

This commit is contained in:
lingniu
2026-07-18 00:26:36 +08:00
parent 65b4e4f055
commit 159c80b0ae
136 changed files with 21616 additions and 1785 deletions

View File

@@ -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>;
}

View File

@@ -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);
});
});

View File

@@ -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>;
}

View File

@@ -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');
});
});

View File

@@ -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>;
}

View 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>
);
}

View File

@@ -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();
});
});

View File

@@ -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>;
}

View File

@@ -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();
});
});

View File

@@ -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>;
}

View File

@@ -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');
});
});

View File

@@ -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>
</>;
}

View File

@@ -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();
});

View File

@@ -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>;
}

View File

@@ -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();
});
});

View File

@@ -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>;
}

View File

@@ -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();

View File

@@ -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>;
}

View File

@@ -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');
});
});

View File

@@ -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>
);
}

View File

@@ -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();
});
});

View File

@@ -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();
}
};
}

View File

@@ -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']
}]);
});

View File

@@ -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()];
}