feat: unify mobile vehicle search

This commit is contained in:
lingniu
2026-07-19 00:17:47 +08:00
parent c115b28bfe
commit 23b632a2e0
3 changed files with 326 additions and 20 deletions

View File

@@ -237,7 +237,7 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as
workspace.remove();
});
test('defaults to a compact mobile vehicle directory and keeps eight recent vehicles', async () => {
test('keeps the mobile vehicle directory primary and applies search from a Semi bottom sheet', async () => {
layout.mobile = true;
const items = Array.from({ length: 10 }, (_, index) => ({
vin: `LTEST${String(index).padStart(12, '0')}`,
@@ -265,24 +265,31 @@ test('defaults to a compact mobile vehicle directory and keeps eight recent vehi
expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8);
expect(screen.getAllByText(/GB\/T 32960|JT\/T 808/).length).toBeGreaterThan(0);
expect(view.container.querySelector('.v2-vehicle-directory-table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-query-panel')).toHaveClass('is-mobile-collapsed');
expect(view.container.querySelector('.v2-vehicle-query-panel')).not.toBeInTheDocument();
expect(screen.queryByLabelText('查车操作')).not.toBeInTheDocument();
const toggle = screen.getByRole('button', { name: '修改车辆筛选10 辆授权车辆' });
const toggle = screen.getByRole('button', { name: '修改车辆范围10 辆授权车辆' });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(toggle);
expect(view.container.querySelector('.v2-vehicle-query-panel')).not.toHaveClass('is-mobile-collapsed');
const input = screen.getByRole('textbox', { name: '搜索车辆' });
expect(input).toBeVisible();
expect(view.container.querySelector('.v2-vehicle-search-options')).not.toBeInTheDocument();
fireEvent.focus(input);
expect(view.container.querySelector('.v2-vehicle-search-options')).not.toBeInTheDocument();
expect(document.querySelector('.v2-vehicle-mobile-filter-sidesheet')).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8);
fireEvent.change(input, { target: { value: items[0].plate } });
await waitFor(() => expect(view.container.querySelector('.v2-vehicle-search-options')).toBeInTheDocument());
expect(view.container.querySelector('.v2-vehicle-search-page')).toHaveClass('has-candidates');
fireEvent.click(screen.getByRole('button', { name: /查询车辆/ }));
expect(view.container.querySelector('.v2-vehicle-search-options')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-search-page')).not.toHaveClass('has-candidates');
expect(await screen.findByRole('listbox')).toHaveAttribute('id', 'v2-vehicle-mobile-search-options');
expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8);
fireEvent.click(screen.getByRole('button', { name: '应用搜索' }));
await waitFor(() => expect(screen.getByRole('button', { name: `修改车辆范围:${items[0].plate} · 10 辆匹配` })).toHaveAttribute('aria-expanded', 'false'));
expect(screen.getByRole('heading', { name: '匹配车辆', level: 5 })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: `修改车辆范围:${items[0].plate} · 10 辆匹配` }));
fireEvent.change(screen.getByRole('textbox', { name: '搜索车辆' }), { target: { value: items[1].plate } });
fireEvent.click(screen.getByRole('button', { name: '关闭车辆搜索' }));
await waitFor(() => expect(screen.getByRole('button', { name: `修改车辆范围:${items[0].plate} · 10 辆匹配` })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: `修改车辆范围:${items[0].plate} · 10 辆匹配` }));
fireEvent.click(screen.getByRole('button', { name: '重置范围' }));
await waitFor(() => expect(screen.getByRole('button', { name: '修改车辆范围10 辆授权车辆' })).toBeInTheDocument());
expect(screen.getByRole('heading', { name: '授权车辆', level: 5 })).toBeInTheDocument();
});
test('keeps a partial vehicle query in the directory instead of navigating to an invalid detail route', async () => {

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { IconArrowRight, IconBox, IconChevronRight, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { Button, Card, Empty, Input, Table, Tag } from '@douyinfe/semi-ui';
import { Button, Card, Empty, Input, SideSheet, Table, Tag } from '@douyinfe/semi-ui';
import { type FormEvent, lazy, Suspense, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../api/client';
@@ -9,7 +9,9 @@ import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
import { formatZhNumber } from '../domain/formatters';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { ProtocolTag } from '../shared/ProtocolTag';
import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
@@ -48,12 +50,15 @@ export default function VehicleSearchWorkspace() {
const { session } = usePlatformSession();
const mobileLayout = useMobileLayout();
const [keyword, setKeyword] = useState('');
const [draftKeyword, setDraftKeyword] = useState('');
const [page, setPage] = useState(1);
const [syncOpen, setSyncOpen] = useState(false);
const [candidatesOpen, setCandidatesOpen] = useState(false);
const [filtersCollapsed, setFiltersCollapsed] = useState(mobileLayout);
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 candidateParams = useMemo(() => {
@@ -68,7 +73,20 @@ export default function VehicleSearchWorkspace() {
staleTime: 30_000,
gcTime: QUERY_MEMORY.optionGcTime
});
const mobileCandidateParams = useMemo(() => {
const params = new URLSearchParams({ limit: '6', offset: '0' });
if (deferredDraftKeyword) params.set('keyword', deferredDraftKeyword);
return params;
}, [deferredDraftKeyword]);
const mobileCandidates = useQuery<Page<VehicleCoverageRow>>({
queryKey: ['vehicle-mobile-candidates', queryScopeKey({ keyword: deferredDraftKeyword })],
queryFn: ({ signal }) => api.vehicleCoverage(mobileCandidateParams, signal),
enabled: mobileFiltersOpen && Boolean(deferredDraftKeyword),
staleTime: 30_000,
gcTime: QUERY_MEMORY.optionGcTime
});
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
const mobileOptions = useMemo(() => mergeVehicleCandidates(mobileCandidates.data?.items ?? []).slice(0, 6), [mobileCandidates.data?.items]);
const pageVehicles = useMemo(() => options.slice(0, pageSize), [options, pageSize]);
const total = candidates.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
@@ -80,9 +98,10 @@ export default function VehicleSearchWorkspace() {
setFiltersCollapsed(mobileLayout);
setCandidatesOpen(false);
}, [mobileLayout]);
useSideSheetA11y(mobileFiltersOpen, '.v2-vehicle-mobile-filter-sidesheet', 'v2-vehicle-mobile-filter-dialog', '车辆搜索', '关闭车辆搜索');
const openCandidates = () => {
window.clearTimeout(closeTimerRef.current);
setCandidatesOpen(Boolean(keyword.trim()));
if (!mobileLayout) setCandidatesOpen(Boolean(keyword.trim()));
};
const closeCandidates = () => {
window.clearTimeout(closeTimerRef.current);
@@ -99,13 +118,43 @@ export default function VehicleSearchWorkspace() {
window.requestAnimationFrame(resetWorkspaceScroll);
window.setTimeout(resetWorkspaceScroll, 120);
};
const openMobileFilters = () => {
setDraftKeyword(keyword);
setCandidatesOpen(false);
setFiltersCollapsed(false);
};
const closeMobileFilters = () => {
setDraftKeyword(keyword);
setCandidatesOpen(false);
setFiltersCollapsed(true);
};
const applyMobileFilters = () => {
setKeyword(draftKeyword.trim());
setPage(1);
setCandidatesOpen(false);
setFiltersCollapsed(true);
};
const resetMobileFilters = () => {
setDraftKeyword('');
setKeyword('');
setPage(1);
setCandidatesOpen(false);
setFiltersCollapsed(true);
};
const submit = (event: FormEvent) => {
event.preventDefault();
setCandidatesOpen(false);
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
};
const submitMobileFilters = (event: FormEvent) => {
event.preventDefault();
applyMobileFilters();
};
const vehicleScope = candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权` : '授权范围';
return <section className={`v2-vehicle-search-page v2-vehicle-directory-v3${syncOpen ? ' has-sync-panel' : ''}${candidatesOpen ? ' has-candidates' : ''}`}>
const mobileFilterSummary = deferredKeyword
? `${deferredKeyword} · ${candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆匹配` : '正在查找'}`
: candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权车辆` : '正在读取授权范围';
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' : ''}`}
@@ -116,12 +165,67 @@ export default function VehicleSearchWorkspace() {
statusColor={candidates.isError ? 'red' : 'blue'}
actions={administrator ? <Button theme="light" type="tertiary" icon={mobileLayout ? <IconBox /> : undefined} onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起同步' : mobileLayout ? '主档同步' : '批量同步主档'}</Button> : null}
/> : null}
<WorkspaceFilterPanel
{mobileLayout ? <div className="v2-vehicle-mobile-discovery">
<MobileFilterToggle
title="车辆范围"
summary={mobileFilterSummary}
expanded={mobileFiltersOpen}
expandedLabel="关闭"
collapsedLabel="修改"
onToggle={mobileFiltersOpen ? closeMobileFilters : openMobileFilters}
/>
<SideSheet
className="v2-vehicle-mobile-filter-sidesheet"
visible={mobileFiltersOpen}
placement="bottom"
height="min(68dvh, 560px)"
aria-label="车辆搜索"
title={<div className="v2-vehicle-mobile-filter-title"><strong></strong><span> VIN </span></div>}
onCancel={closeMobileFilters}
footer={<div className="v2-vehicle-mobile-filter-footer"><Button theme="light" onClick={resetMobileFilters}></Button><Button theme="solid" onClick={applyMobileFilters}></Button></div>}
>
<form className="v2-vehicle-mobile-filter-form" onSubmit={submitMobileFilters}>
<section aria-label="车辆搜索条件">
<header><strong></strong><span></span></header>
<label>
<span> / VIN / </span>
<Input
aria-label="搜索车辆"
aria-controls="v2-vehicle-mobile-search-options"
aria-expanded={Boolean(deferredDraftKeyword)}
prefix={<IconSearch />}
showClear
value={draftKeyword}
onChange={setDraftKeyword}
placeholder="输入车牌 / VIN / 终端手机号"
autoComplete="off"
/>
</label>
{deferredDraftKeyword ? <VehicleCandidateList
id="v2-vehicle-mobile-search-options"
className="v2-vehicle-mobile-candidates"
items={mobileOptions}
loading={mobileCandidates.isFetching}
loadingText="正在搜索授权车辆"
error={mobileCandidates.isError ? (mobileCandidates.error instanceof Error ? mobileCandidates.error.message : '车辆候选加载失败') : undefined}
onRetry={() => mobileCandidates.refetch()}
emptyText="没有匹配的授权车辆"
header="快速结果"
meta={mobileCandidates.data ? `${mobileCandidates.data.total.toLocaleString('zh-CN')} 辆匹配` : '授权范围'}
showProtocols
layout="list"
actionLabel="打开"
onSelect={(vehicle) => openVehicle(vehicle.vin)}
/> : <div className="v2-vehicle-mobile-filter-hint"><IconSearch /><span><strong></strong><small></small></span></div>}
</section>
</form>
</SideSheet>
</div> : <WorkspaceFilterPanel
className="v2-vehicle-query-panel"
title={mobileLayout ? '车辆筛选' : '车辆定位'}
title="车辆定位"
description="车牌优先VIN 辅助"
mobileSummary={deferredKeyword ? `正在查找 ${deferredKeyword}` : candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权车辆` : '正在读取授权范围'}
expanded={!filtersCollapsed}
mobileSummary={mobileFilterSummary}
expanded
status={deferredKeyword ? candidates.isFetching ? '正在查找' : `${options.length} 辆匹配` : vehicleScope}
statusColor={deferredKeyword ? 'blue' : 'grey'}
collapsedLabel="修改"
@@ -159,7 +263,7 @@ export default function VehicleSearchWorkspace() {
onSelect={(vehicle) => openVehicle(vehicle.vin)}
/> : null}
</form>
</WorkspaceFilterPanel>
</WorkspaceFilterPanel>}
</div>
<Card className="v2-vehicle-recent-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader

View File

@@ -17521,3 +17521,198 @@
font-weight: 700;
}
}
/*
* Semi UI vehicle discovery.
* The authorized directory stays visible on phones while search moves into a
* focused bottom sheet with explicit apply and reset boundaries.
*/
@media (max-width: 680px) {
.v2-vehicle-mobile-discovery {
display: grid;
width: 100%;
min-width: 0;
flex: 0 0 auto;
gap: 8px;
}
.v2-vehicle-mobile-discovery > .v2-mobile-filter-toggle.semi-button {
min-height: 58px;
margin: 0;
}
.v2-vehicle-mobile-filter-sidesheet .semi-sidesheet-inner {
width: 100vw !important;
max-width: 100%;
overflow: hidden;
border-radius: 18px 18px 0 0;
background: #f4f7fb;
box-shadow: 0 -20px 56px rgba(25, 45, 72, .2);
padding-bottom: env(safe-area-inset-bottom);
}
.v2-vehicle-mobile-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-header {
min-height: 68px;
border-bottom: 1px solid #dfe7f0;
background: rgba(255, 255, 255, .98);
padding: 11px 14px;
}
.v2-vehicle-mobile-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-body {
min-height: 0;
overflow-y: auto;
background: #f4f7fb;
padding: 12px;
overscroll-behavior: contain;
}
.v2-vehicle-mobile-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-footer {
border-top: 1px solid #dfe7f0;
background: rgba(255, 255, 255, .98);
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
}
.v2-vehicle-mobile-filter-title {
display: grid;
gap: 3px;
}
.v2-vehicle-mobile-filter-title > strong {
color: #253a52;
font-size: 15px;
line-height: 1.3;
}
.v2-vehicle-mobile-filter-title > span {
color: #8090a3;
font-size: 10px;
font-weight: 500;
line-height: 1.4;
}
.v2-vehicle-mobile-filter-form,
.v2-vehicle-mobile-filter-form > section {
display: grid;
gap: 12px;
}
.v2-vehicle-mobile-filter-form > section {
border: 1px solid #dfe7f0;
border-radius: 12px;
background: #fff;
padding: 12px;
box-shadow: 0 8px 24px rgba(31, 53, 80, .055);
}
.v2-vehicle-mobile-filter-form > section > header {
display: grid;
min-width: 0;
gap: 3px;
}
.v2-vehicle-mobile-filter-form > section > header > strong {
color: #2b4058;
font-size: 13px;
line-height: 1.3;
}
.v2-vehicle-mobile-filter-form > section > header > span {
color: #7c8b9e;
font-size: 10px;
line-height: 1.45;
}
.v2-vehicle-mobile-filter-form label {
display: grid;
min-width: 0;
gap: 6px;
color: #5f7188;
font-size: 11px;
font-weight: 650;
}
.v2-vehicle-mobile-filter-form label > .semi-input-wrapper {
width: 100%;
min-height: 46px;
border-radius: 9px;
background: #fff;
}
.v2-vehicle-mobile-filter-form label .semi-input {
height: 44px;
font-size: 14px;
}
.v2-vehicle-mobile-filter-hint {
display: flex;
min-height: 86px;
align-items: center;
gap: 11px;
border: 1px dashed #d6e1ee;
border-radius: 10px;
background: #f8fbff;
padding: 14px;
color: #6e8db8;
}
.v2-vehicle-mobile-filter-hint > .semi-icon {
font-size: 22px;
}
.v2-vehicle-mobile-filter-hint > span {
display: grid;
gap: 3px;
}
.v2-vehicle-mobile-filter-hint strong {
color: #344c69;
font-size: 12px;
}
.v2-vehicle-mobile-filter-hint small {
color: #8392a5;
font-size: 10px;
}
.v2-vehicle-mobile-filter-sidesheet .v2-vehicle-mobile-candidates.v2-vehicle-candidate-list {
position: static;
width: 100%;
max-height: min(32dvh, 260px);
overflow-y: auto;
border-color: #dce5ef;
border-radius: 10px;
box-shadow: none;
overscroll-behavior: contain;
}
.v2-vehicle-mobile-filter-sidesheet .v2-vehicle-mobile-candidates > header {
position: sticky;
z-index: 1;
top: 0;
min-height: 38px;
background: rgba(248, 250, 253, .98);
backdrop-filter: blur(10px);
}
.v2-vehicle-mobile-filter-sidesheet .v2-vehicle-mobile-candidates > .v2-vehicle-option.semi-button {
min-height: 62px;
}
.v2-vehicle-mobile-filter-sidesheet .v2-vehicle-mobile-candidates .v2-vehicle-option-action.semi-tag {
color: #1268f3;
}
.v2-vehicle-mobile-filter-footer {
display: grid;
width: 100%;
grid-template-columns: minmax(0, .72fr) minmax(0, 1.28fr);
gap: 9px;
}
.v2-vehicle-mobile-filter-footer > .semi-button {
width: 100%;
min-height: 44px;
border-radius: 9px;
font-weight: 700;
}
}