feat: unify mobile vehicle search
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user