feat(web): add vehicle directory views

This commit is contained in:
lingniu
2026-07-20 07:58:45 +08:00
parent 3c9eea527c
commit 19b187d942
4 changed files with 185 additions and 21 deletions

View File

@@ -196,8 +196,10 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
expect(await screen.findByRole('heading', { name: '车辆快速定位', level: 5 })).toBeInTheDocument();
expect(await screen.findByRole('heading', { name: '查找车辆', level: 5 })).toBeInTheDocument();
expect(screen.getByLabelText('查车操作')).toHaveClass('v2-workspace-command-bar', 'v2-vehicle-command-bar');
expect(screen.getByRole('tablist', { name: '车辆目录视图' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: '全部车辆' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('heading', { name: '车辆定位', level: 5 }).closest('.semi-card')).toHaveClass('v2-vehicle-query-panel', 'v2-workspace-filter-panel');
const input = screen.getByRole('textbox', { name: '搜索车辆' });
expect(input).toHaveAttribute('aria-expanded', 'false');
@@ -210,10 +212,10 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as
expect(screen.getByRole('heading', { name: '授权车辆目录', level: 5 })).toBeInTheDocument();
const directoryMetrics = screen.getByRole('list', { name: '车辆目录摘要' });
expect(directoryMetrics.closest('.v2-vehicle-directory-metric-rail')).toHaveClass('semi-card', 'v2-workspace-metric-rail', 'is-queue');
expect(Array.from(directoryMetrics.querySelectorAll(':scope > [role="listitem"] > small')).map((item) => item.textContent)).toEqual(['授权车辆', '本页在线', '多源车辆']);
expect(Array.from(directoryMetrics.querySelectorAll(':scope > [role="listitem"] > small')).map((item) => item.textContent)).toEqual(['授权车辆', '本页在线', '本页多源']);
expect(directoryMetrics.querySelector(':scope > [role="listitem"]:nth-child(1)')).toHaveTextContent('授权车辆2辆账号范围');
expect(directoryMetrics.querySelector(':scope > [role="listitem"]:nth-child(2)')).toHaveTextContent('本页在线1辆本页 1 辆');
expect(directoryMetrics.querySelector(':scope > [role="listitem"]:nth-child(3)')).toHaveTextContent('多源车辆0辆2+ 协议来源');
expect(directoryMetrics.querySelector(':scope > [role="listitem"]:nth-child(3)')).toHaveTextContent('本页多源0辆2+ 协议来源');
expect(screen.getByText('第 1 / 1 页').closest('.semi-tag')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '刷新车辆目录' })).toHaveClass('semi-button');
expect(view.container.querySelector('.v2-vehicle-directory-table.semi-table-wrapper')).toBeInTheDocument();
@@ -280,6 +282,8 @@ test('keeps the mobile vehicle directory primary and applies search from a Semi
expect(view.container.querySelector('.v2-vehicle-directory-table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-query-panel')).not.toBeInTheDocument();
expect(screen.queryByLabelText('查车操作')).not.toBeInTheDocument();
expect(screen.getByRole('tablist', { name: '车辆目录视图' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: '全部车辆' })).toHaveAttribute('aria-selected', 'true');
const toggle = screen.getByRole('button', { name: '打开车辆搜索10 辆授权车辆' });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(toggle);
@@ -310,6 +314,50 @@ test('keeps the mobile vehicle directory primary and applies search from a Semi
expect(screen.getByRole('heading', { name: '授权车辆', level: 5 })).toBeInTheDocument();
});
test('filters the authorized directory with one consistent Semi view switcher', async () => {
const candidate = {
vin: initialRealtime.vin,
plate: initialRealtime.plate,
phone: '',
oem: '测试品牌',
protocols: ['JT808', 'GB32960'],
missingProtocols: [],
sourceStatus: [],
sourceCount: 2,
onlineSourceCount: 2,
online: true,
lastSeen: initialRealtime.lastSeen,
locationText: '广东省广州市',
bindingScore: 100,
bindingStatus: 'bound'
};
const vehicles = vi.spyOn(api, 'vehicleCoverage').mockResolvedValue({ items: [candidate], total: 1, limit: 10, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
await screen.findByText(initialRealtime.plate);
const viewTabs = screen.getByRole('tablist', { name: '车辆目录视图' });
expect(viewTabs).toHaveTextContent('全部车辆当前在线当前离线多源车辆');
fireEvent.click(screen.getByRole('tab', { name: '当前在线' }));
await waitFor(() => expect(vehicles.mock.calls[vehicles.mock.calls.length - 1]?.[0]?.get('online')).toBe('online'));
expect(screen.getByRole('tab', { name: '当前在线' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('heading', { name: '当前在线', level: 5 })).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: '当前离线' }));
await waitFor(() => expect(vehicles.mock.calls[vehicles.mock.calls.length - 1]?.[0]?.get('online')).toBe('offline'));
fireEvent.click(screen.getByRole('tab', { name: '多源车辆' }));
await waitFor(() => expect(vehicles.mock.calls[vehicles.mock.calls.length - 1]?.[0]?.get('coverage')).toBe('multi'));
fireEvent.click(screen.getByRole('tab', { name: '全部车辆' }));
expect(screen.getByRole('tab', { name: '全部车辆' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('heading', { name: '授权车辆目录', level: 5 })).toBeInTheDocument();
expect(vehicles.mock.calls[0]?.[0]?.has('online')).toBe(false);
expect(vehicles.mock.calls[0]?.[0]?.has('coverage')).toBe(false);
});
test('keeps a partial vehicle query in the directory instead of navigating to an invalid detail route', async () => {
const candidate = {
vin: initialRealtime.vin,

View File

@@ -92,7 +92,7 @@ function Archive({ detail, editable, onUpdated }: { detail: VehicleDetail; edita
return <Card className="v2-record-card v2-archive-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="车辆主档"
description="身份、车型与运营属性"
description="身份与运营属性"
meta={`完整度 ${profile?.completeness ?? 0}%`}
actions={editable && !editing ? <Button theme="borderless" size="small" onClick={startEditing}></Button> : null}
/>
@@ -126,7 +126,7 @@ function Events({ detail }: { detail: VehicleDetail }) {
return <Card className="v2-record-card v2-events-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="最近事件"
description="质量提醒与协议来源状态"
description="质量提醒与来源状态"
meta={`${events.length}`}
actions={<Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><Button theme="borderless" type="tertiary" size="small"></Button></Link>}
/>
@@ -306,7 +306,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
return <Card className="v2-record-card v2-telemetry-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="实时遥测"
description="按协议查看当前值、质量来源"
description="按协议查看值、质量来源"
meta={`${data?.values.length ?? 0}`}
/>
<div className="v2-telemetry-tabs">
@@ -596,7 +596,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
<Card className="v2-single-map-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="实时位置"
description="定位点按车辆上报周期平滑移动"
description="上报周期平滑移动"
meta={realtime?.online ? '实时跟随' : '保留最后位置'}
/>
<FleetMap vehicles={mapVehicles} selectedVin={hasLocation ? detail.vin : undefined} onSelect={() => undefined} />

View File

@@ -14,6 +14,7 @@ import { PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { ProtocolTag } from '../shared/ProtocolTag';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
@@ -23,6 +24,20 @@ import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../share
const VehicleProfileSyncPanel = lazy(() => import('./VehicleProfileSyncPanel'));
type VehicleDirectoryView = 'all' | 'online' | 'offline' | 'multi';
const vehicleDirectoryViews = [
{ key: 'all', label: '全部车辆' },
{ key: 'online', label: '当前在线' },
{ key: 'offline', label: '当前离线' },
{ key: 'multi', label: '多源车辆' }
] satisfies { key: VehicleDirectoryView; label: string }[];
function applyDirectoryView(params: URLSearchParams, view: VehicleDirectoryView) {
if (view === 'online' || view === 'offline') params.set('online', view);
if (view === 'multi') params.set('coverage', 'multi');
}
function resetWorkspaceScroll() {
const targets = [
document.scrollingElement,
@@ -56,17 +71,19 @@ export default function VehicleSearchWorkspace() {
const [syncOpen, setSyncOpen] = useState(false);
const [candidatesOpen, setCandidatesOpen] = useState(false);
const [filtersCollapsed, setFiltersCollapsed] = useState(mobileLayout);
const [directoryView, setDirectoryView] = useState<VehicleDirectoryView>('all');
const closeTimerRef = useRef<number>();
const deferredKeyword = useDeferredValue(keyword.trim());
const deferredDraftKeyword = useDeferredValue(draftKeyword.trim());
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
const pageSize = mobileLayout ? 8 : 10;
const directoryScope = useMemo(() => queryScopeKey({ keyword: deferredKeyword }), [deferredKeyword]);
const directoryScope = useMemo(() => queryScopeKey({ keyword: deferredKeyword, view: directoryView }), [deferredKeyword, directoryView]);
const candidateParams = useMemo(() => {
const params = new URLSearchParams({ limit: String(pageSize), offset: String((page - 1) * pageSize) });
if (deferredKeyword) params.set('keyword', deferredKeyword);
applyDirectoryView(params, directoryView);
return params;
}, [deferredKeyword, page, pageSize]);
}, [deferredKeyword, directoryView, page, pageSize]);
const candidates = useQuery<Page<VehicleCoverageRow>>({
queryKey: ['vehicle-directory', directoryScope, pageSize, page],
queryFn: ({ signal }) => api.vehicleCoverage(candidateParams, signal),
@@ -77,10 +94,11 @@ export default function VehicleSearchWorkspace() {
const mobileCandidateParams = useMemo(() => {
const params = new URLSearchParams({ limit: '6', offset: '0' });
if (deferredDraftKeyword) params.set('keyword', deferredDraftKeyword);
applyDirectoryView(params, directoryView);
return params;
}, [deferredDraftKeyword]);
}, [deferredDraftKeyword, directoryView]);
const mobileCandidates = useQuery<Page<VehicleCoverageRow>>({
queryKey: ['vehicle-mobile-candidates', queryScopeKey({ keyword: deferredDraftKeyword })],
queryKey: ['vehicle-mobile-candidates', queryScopeKey({ keyword: deferredDraftKeyword, view: directoryView })],
queryFn: ({ signal }) => api.vehicleCoverage(mobileCandidateParams, signal),
enabled: mobileFiltersOpen && Boolean(deferredDraftKeyword),
staleTime: 30_000,
@@ -147,15 +165,23 @@ export default function VehicleSearchWorkspace() {
event.preventDefault();
applyMobileFilters();
};
const selectDirectoryView = (nextView: VehicleDirectoryView) => {
setDirectoryView(nextView);
setPage(1);
setCandidatesOpen(false);
};
const directoryViewLabel = vehicleDirectoryViews.find((item) => item.key === directoryView)?.label ?? '全部车辆';
const vehicleScope = candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权` : '授权范围';
const mobileFilterSummary = deferredKeyword
? `${deferredKeyword} · ${candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆匹配` : '正在查找'}`
: candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权车辆` : '正在读取授权范围';
? `${deferredKeyword}${directoryView === 'all' ? '' : ` · ${directoryViewLabel}`} · ${candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆匹配` : '正在查找'}`
: candidates.data
? directoryView === 'all' ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权车辆` : `${directoryViewLabel} · ${candidates.data.total.toLocaleString('zh-CN')}`
: '正在读取授权范围';
const directoryMetrics: WorkspaceQueueMetricRailItem[] = [
{
label: deferredKeyword ? '匹配车辆' : '授权车辆',
label: deferredKeyword ? '匹配车辆' : directoryView === 'all' ? '授权车辆' : directoryViewLabel,
value: <>{candidates.data ? formatZhNumber(total) : '—'}<i className="v2-vehicle-directory-unit"></i></>,
note: deferredKeyword ? '当前条件' : '账号范围',
note: deferredKeyword || directoryView !== 'all' ? '当前视图' : '账号范围',
tone: 'primary',
emphasis: 'primary'
},
@@ -167,29 +193,45 @@ export default function VehicleSearchWorkspace() {
emphasis: 'primary'
},
{
label: '多源车辆',
label: '本页多源',
value: <>{candidates.data ? formatZhNumber(multiSourceOnPage) : '—'}<i className="v2-vehicle-directory-unit"></i></>,
note: '2+ 协议来源',
tone: 'primary',
emphasis: 'secondary'
}
];
const directoryTitle = deferredKeyword ? '匹配车辆' : mobileLayout ? '授权车辆' : '授权车辆目录';
const directoryTitle = deferredKeyword
? '匹配车辆'
: directoryView === 'all'
? mobileLayout ? '授权车辆' : '授权车辆目录'
: directoryViewLabel;
const directoryDescription = deferredKeyword
? '按当前搜索条件展示,可直接进入车辆数字档案'
: '按照最新上报时间排列,分页浏览全部授权车辆';
: directoryView === 'all'
? '按照最新上报时间排列,分页浏览全部授权车辆'
: `仅展示${directoryViewLabel},按最新上报时间排列`;
return <section className={`v2-vehicle-search-page v2-vehicle-directory-v3${syncOpen ? ' has-sync-panel' : ''}${!mobileLayout && candidatesOpen ? ' has-candidates' : ''}`}>
<div className="v2-vehicle-discovery-shell">
{!mobileLayout || administrator ? <WorkspaceCommandBar
className={`v2-vehicle-command-bar${mobileLayout ? ' is-mobile-admin' : ''}`}
ariaLabel={mobileLayout ? '车辆主档操作' : '查车操作'}
eyebrow={mobileLayout ? '车辆治理' : '车辆目录'}
eyebrow={mobileLayout ? '车辆治理' : undefined}
icon={mobileLayout ? <IconBox /> : <IconSearch />}
title={mobileLayout ? '车辆主档' : '车辆快速定位'}
title={mobileLayout ? '车辆主档' : '查找车辆'}
description={mobileLayout ? '批量维护品牌、车型与运营属性' : '车牌、VIN 或手机号快速查车'}
status={mobileLayout ? undefined : candidates.isPending ? '正在读取授权范围' : vehicleScope}
statusColor={candidates.isError ? 'red' : 'blue'}
actions={administrator ? <Button theme="light" type="tertiary" icon={mobileLayout ? <IconBox /> : undefined} onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起同步' : mobileLayout ? '主档同步' : '批量同步主档'}</Button> : null}
actions={<div className="v2-vehicle-command-actions">
{!mobileLayout ? <SegmentedTabs
className="v2-vehicle-directory-view-tabs"
ariaLabel="车辆目录视图"
value={directoryView}
items={vehicleDirectoryViews}
onChange={selectDirectoryView}
variant="filled"
/> : null}
{administrator ? <Button theme="light" type="tertiary" icon={mobileLayout ? <IconBox /> : undefined} onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起同步' : mobileLayout ? '主档同步' : '批量同步主档'}</Button> : null}
</div>}
/> : null}
{mobileLayout ? <div className="v2-vehicle-mobile-discovery">
<MobileFilterToggle
@@ -313,6 +355,14 @@ export default function VehicleSearchWorkspace() {
/> : null}
</form>
</WorkspaceFilterPanel>}
{mobileLayout ? <SegmentedTabs
className="v2-vehicle-directory-view-tabs"
ariaLabel="车辆目录视图"
value={directoryView}
items={vehicleDirectoryViews}
onChange={selectDirectoryView}
variant="filled"
/> : null}
</div>
<Card className="v2-vehicle-recent-card" bodyStyle={{ padding: 0 }}>
<WorkspaceMetricRail

View File

@@ -18358,6 +18358,56 @@
flex: 0 0 auto;
}
.v2-vehicle-command-actions {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
}
.v2-vehicle-directory-view-tabs.v2-segmented-tabs {
display: grid;
min-width: min(386px, 40vw);
grid-template-columns: repeat(4, minmax(78px, 1fr));
gap: 3px;
overflow: hidden;
border: 1px solid #e1e8f1;
border-radius: 9px;
background: #f3f6fa;
padding: 3px;
}
.v2-vehicle-directory-view-tabs.v2-segmented-tabs > .semi-button {
min-width: 0;
min-height: 30px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: #64758b;
font-size: 10px;
font-weight: 680;
box-shadow: none;
}
.v2-vehicle-directory-view-tabs.v2-segmented-tabs > .semi-button:hover {
border-color: #dce5ef;
background: #fff;
color: #315f98;
}
.v2-vehicle-directory-view-tabs.v2-segmented-tabs > .semi-button.is-active,
.v2-vehicle-directory-view-tabs.v2-segmented-tabs > .semi-button.is-active:hover {
border-color: #c5d8f4;
background: #fff !important;
color: #1268df;
box-shadow: 0 2px 8px rgba(18, 104, 223, .08), inset 0 -2px #6fa4ed;
}
.v2-vehicle-directory-view-tabs .v2-segmented-tab-label {
width: 100%;
justify-content: center;
}
.v2-vehicle-directory-v3 > .v2-vehicle-recent-card.semi-card {
width: min(1280px, 100%);
min-height: 0;
@@ -18802,6 +18852,22 @@
width: 100%;
}
.v2-vehicle-directory-v3 > .v2-vehicle-discovery-shell > .v2-vehicle-directory-view-tabs {
width: 100%;
min-width: 0;
grid-template-columns: repeat(4, minmax(0, 1fr));
border-width: 1px 0 0;
border-radius: 0 0 13px 13px;
background: #f7f9fc;
padding: 4px 6px 6px;
}
.v2-vehicle-directory-view-tabs.v2-segmented-tabs > .semi-button {
min-height: 34px;
padding-inline: 2px;
font-size: 9px;
}
.v2-vehicle-directory-v3 .v2-vehicle-command-bar.is-mobile-admin {
height: 44px;
min-height: 44px;