Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx
2026-07-20 07:58:45 +08:00

475 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery } from '@tanstack/react-query';
import { IconArrowRight, IconBox, IconChevronRight, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { Button, Card, Input, 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';
import type { Page, VehicleCoverageRow } from '../../api/types';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
import { formatZhNumber } from '../domain/formatters';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
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';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
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,
document.documentElement,
document.body,
document.querySelector<HTMLElement>('.v2-shell'),
document.querySelector<HTMLElement>('.v2-main'),
document.querySelector<HTMLElement>('.v2-content')
];
for (const target of targets) {
if (!(target instanceof HTMLElement)) continue;
target.scrollTop = 0;
target.scrollLeft = 0;
target.scrollTo?.({ top: 0, left: 0, behavior: 'auto' });
}
}
function vehicleLastSeenLabel(value?: string) {
if (!value) return '暂无上报时间';
const normalized = value.replace('T', ' ');
return normalized.length >= 16 ? `${normalized.slice(5, 16)} 更新` : normalized;
}
export default function VehicleSearchWorkspace() {
const navigate = useNavigate();
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 [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, 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, directoryView, page, pageSize]);
const candidates = useQuery<Page<VehicleCoverageRow>>({
queryKey: ['vehicle-directory', directoryScope, pageSize, page],
queryFn: ({ signal }) => api.vehicleCoverage(candidateParams, signal),
placeholderData: retainPreviousPageWithinScope<Page<VehicleCoverageRow>>(directoryScope),
staleTime: 30_000,
gcTime: QUERY_MEMORY.optionGcTime
});
const mobileCandidateParams = useMemo(() => {
const params = new URLSearchParams({ limit: '6', offset: '0' });
if (deferredDraftKeyword) params.set('keyword', deferredDraftKeyword);
applyDirectoryView(params, directoryView);
return params;
}, [deferredDraftKeyword, directoryView]);
const mobileCandidates = useQuery<Page<VehicleCoverageRow>>({
queryKey: ['vehicle-mobile-candidates', queryScopeKey({ keyword: deferredDraftKeyword, view: directoryView })],
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));
const onlineOnPage = useMemo(() => pageVehicles.filter((vehicle) => vehicle.online).length, [pageVehicles]);
const multiSourceOnPage = useMemo(() => pageVehicles.filter((vehicle) => vehicle.protocols.length > 1).length, [pageVehicles]);
const administrator = canAdminister(session);
useEffect(() => () => window.clearTimeout(closeTimerRef.current), []);
useEffect(() => {
setFiltersCollapsed(mobileLayout);
setCandidatesOpen(false);
}, [mobileLayout]);
const openCandidates = () => {
window.clearTimeout(closeTimerRef.current);
if (!mobileLayout) setCandidatesOpen(Boolean(keyword.trim()));
};
const closeCandidates = () => {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = window.setTimeout(() => setCandidatesOpen(false), 140);
};
const openVehicle = (value: string) => {
const normalized = value.trim();
if (!normalized) return;
setCandidatesOpen(false);
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
resetWorkspaceScroll();
navigate(`/vehicles/${encodeURIComponent(normalized)}`);
window.queueMicrotask(resetWorkspaceScroll);
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 resetMobileDraft = () => {
setDraftKeyword('');
setCandidatesOpen(false);
};
const submit = (event: FormEvent) => {
event.preventDefault();
setCandidatesOpen(false);
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
};
const submitMobileFilters = (event: FormEvent) => {
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}${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 ? '匹配车辆' : directoryView === 'all' ? '授权车辆' : directoryViewLabel,
value: <>{candidates.data ? formatZhNumber(total) : '—'}<i className="v2-vehicle-directory-unit"></i></>,
note: deferredKeyword || directoryView !== 'all' ? '当前视图' : '账号范围',
tone: 'primary',
emphasis: 'primary'
},
{
label: '本页在线',
value: <>{candidates.data ? formatZhNumber(onlineOnPage) : '—'}<i className="v2-vehicle-directory-unit"></i></>,
note: candidates.data ? `本页 ${formatZhNumber(pageVehicles.length)}` : '正在读取',
tone: 'success',
emphasis: 'primary'
},
{
label: '本页多源',
value: <>{candidates.data ? formatZhNumber(multiSourceOnPage) : '—'}<i className="v2-vehicle-directory-unit"></i></>,
note: '2+ 协议来源',
tone: 'primary',
emphasis: 'secondary'
}
];
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 ? '车辆治理' : undefined}
icon={mobileLayout ? <IconBox /> : <IconSearch />}
title={mobileLayout ? '车辆主档' : '查找车辆'}
description={mobileLayout ? '批量维护品牌、车型与运营属性' : '车牌、VIN 或手机号快速查车'}
status={mobileLayout ? undefined : candidates.isPending ? '正在读取授权范围' : vehicleScope}
statusColor={candidates.isError ? 'red' : 'blue'}
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
title="查找车辆"
summary={mobileFilterSummary}
expanded={mobileFiltersOpen}
controls="v2-vehicle-mobile-filter-dialog"
expandedLabel="关闭"
collapsedLabel="查找"
ariaLabel={`${mobileFiltersOpen ? '关闭' : '打开'}车辆搜索:${mobileFilterSummary}`}
onToggle={mobileFiltersOpen ? closeMobileFilters : openMobileFilters}
/>
<MobileFilterSheet
className="v2-vehicle-mobile-filter-sidesheet"
visible={mobileFiltersOpen}
height="min(72dvh, 610px)"
ariaLabel="车辆搜索"
dialogId="v2-vehicle-mobile-filter-dialog"
closeLabel="关闭车辆搜索"
title="查找车辆"
description="车牌优先,支持 VIN 与终端手机号"
summaryItems={[
{
label: '授权范围',
value: candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')}` : '读取中',
detail: '当前账号可见',
tone: 'primary'
},
{
label: '当前条件',
value: draftKeyword.trim() || '全部车辆',
detail: draftKeyword.trim() ? '车牌 / VIN / 终端' : '浏览完整目录'
},
{
label: '快速结果',
value: deferredDraftKeyword ? mobileCandidates.isFetching ? '查找中' : `${mobileCandidates.data?.total ?? 0}` : '待输入',
detail: '最多展示 6 辆',
tone: deferredDraftKeyword ? 'success' : 'neutral'
}
]}
onCancel={closeMobileFilters}
secondaryAction={{ label: '重置范围', onClick: resetMobileDraft }}
primaryAction={{ label: '应用搜索', onClick: applyMobileFilters }}
>
<form className="v2-vehicle-mobile-filter-form" onSubmit={submitMobileFilters}>
<MobileFilterSheetSection ariaLabel="车辆搜索条件" title="车辆身份" description="输入关键词可直接打开候选车辆,或应用到下方目录。">
<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>}
</MobileFilterSheetSection>
</form>
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-vehicle-query-panel"
title="车辆定位"
description="车牌优先VIN 辅助"
mobileSummary={mobileFilterSummary}
expanded
status={deferredKeyword ? candidates.isFetching ? '正在查找' : `${options.length} 辆匹配` : vehicleScope}
statusColor={deferredKeyword ? 'blue' : 'grey'}
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
>
<form className={`v2-vehicle-search-form${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
<div className={`v2-vehicle-search-picker${candidatesOpen ? ' is-open' : ''}`}>
<Input
aria-label="搜索车辆"
aria-controls="v2-vehicle-search-options"
aria-expanded={candidatesOpen}
prefix={<IconSearch />}
value={keyword}
onChange={(value) => { setKeyword(value); setPage(1); setCandidatesOpen(Boolean(value.trim())); }}
onFocus={openCandidates}
onBlur={closeCandidates}
placeholder="输入车牌 / VIN / 终端手机号"
autoComplete="off"
/>
</div>
<Button theme="solid" htmlType="submit" icon={<IconArrowRight />} iconPosition="right"></Button>
{candidatesOpen && deferredKeyword ? <VehicleCandidateList
id="v2-vehicle-search-options"
className="v2-vehicle-search-options"
items={options}
loading={candidates.isFetching}
loadingText="正在搜索授权车辆"
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车辆候选加载失败') : undefined}
onRetry={() => candidates.refetch()}
emptyText="没有匹配的授权车辆"
header="车辆候选"
meta="车牌优先 · VIN 辅助"
showProtocols
layout={mobileLayout ? 'list' : 'grid'}
onSelect={(vehicle) => openVehicle(vehicle.vin)}
/> : 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
variant="queue"
ariaLabel="车辆目录摘要"
className="v2-vehicle-directory-metric-rail"
items={directoryMetrics}
context={<div className="v2-vehicle-directory-context">
<span><h5>{directoryTitle}</h5><small>{directoryDescription}</small></span>
<span className="v2-vehicle-directory-context-actions">
<Tag color="blue" type="light" size="small"> {page} / {totalPages} </Tag>
<Button theme="borderless" type="tertiary" size="small" icon={<IconRefresh />} loading={candidates.isFetching} aria-label="刷新车辆目录" onClick={() => candidates.refetch()}></Button>
</span>
</div>}
/>
{candidates.isFetching && !candidates.data
? <PanelLoading className="v2-vehicle-directory-loading" compact title="正在读取授权车辆" description="车辆目录返回后会自动显示。" />
: candidates.isError
? <div className="v2-vehicle-recent-state is-error" role="alert"><span>{candidates.error instanceof Error ? candidates.error.message : '授权车辆加载失败'}</span><Button size="small" theme="light" onClick={() => candidates.refetch()}></Button></div>
: pageVehicles.length
? mobileLayout ? <div className="v2-vehicle-recent-grid" role="list" aria-label={deferredKeyword ? '匹配车辆' : '授权车辆目录'}>
{pageVehicles.map((vehicle) => <div key={vehicle.vin} className={`v2-vehicle-recent-item-shell ${vehicle.online ? 'is-online' : 'is-offline'}`} role="listitem">
<Button
className="v2-vehicle-recent-item"
theme="borderless"
type="tertiary"
aria-label={`打开 ${vehicle.plate || '未绑定车牌'} 车辆档案`}
onClick={() => openVehicle(vehicle.vin)}
>
<span className="v2-vehicle-recent-identity">
<strong>{vehicle.plate || '未绑定车牌'}</strong>
<small>{vehicle.vin}</small>
</span>
<span className="v2-vehicle-recent-status">
<Tag color={vehicle.online ? 'green' : 'grey'} type="light" size="small">{vehicle.online ? '在线' : '离线'}</Tag>
<small>{vehicleLastSeenLabel(vehicle.lastSeen)}</small>
</span>
<span className="v2-vehicle-recent-protocols">{vehicle.protocols.slice(0, 2).map((protocol) => <ProtocolTag key={protocol} protocol={protocol} compact />)}</span>
<IconChevronRight className="v2-vehicle-recent-arrow" />
</Button>
</div>)}
</div> : <Table
className="v2-vehicle-directory-table"
columns={[
{
title: '车辆',
dataIndex: 'plate',
width: 220,
render: (_value: string, vehicle: VehicleCoverageRow) => <span className="v2-vehicle-directory-identity"><strong>{vehicle.plate || '未绑定车牌'}</strong><small>{vehicle.vin}</small></span>
},
{
title: '品牌 / 终端',
dataIndex: 'oem',
width: 260,
render: (_value: string, vehicle: VehicleCoverageRow) => <span className="v2-vehicle-directory-profile"><strong>{vehicle.oem || '品牌待维护'}</strong><small>{vehicle.phone || '暂无终端手机号'}</small></span>
},
{
title: '实时状态',
dataIndex: 'online',
width: 180,
render: (_value: boolean, vehicle: VehicleCoverageRow) => <span className="v2-vehicle-directory-status"><Tag color={vehicle.online ? 'green' : 'grey'} type="light" size="small">{vehicle.online ? '在线' : '离线'}</Tag><small>{vehicleLastSeenLabel(vehicle.lastSeen)}</small></span>
},
{
title: '协议来源',
dataIndex: 'protocols',
render: (protocols: string[]) => <span className="v2-vehicle-directory-protocols">{protocols.length ? protocols.slice(0, 3).map((protocol) => <ProtocolTag key={protocol} protocol={protocol} compact />) : <span></span>}</span>
},
{
title: '',
dataIndex: 'vin',
width: 116,
align: 'right',
render: () => <span className="v2-vehicle-directory-open" aria-hidden="true"><IconChevronRight /></span>
}
]}
dataSource={pageVehicles}
rowKey="vin"
onRow={(vehicle) => vehicle ? ({
className: vehicle.online ? 'is-online' : 'is-offline',
role: 'button',
tabIndex: 0,
'aria-label': `打开 ${vehicle.plate || vehicle.vin} 车辆档案`,
'data-testid': `vehicle-directory-row-${vehicle.vin}`,
onClick: () => openVehicle(vehicle.vin),
onKeyDown: (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
openVehicle(vehicle.vin);
}
}) : ({})}
pagination={false}
loading={candidates.isFetching}
empty={null}
/>
: <PanelEmpty
className="v2-vehicle-recent-empty"
tone="primary"
icon={<IconSearch />}
title={deferredKeyword ? '没有匹配车辆' : '暂无授权车辆'}
description={deferredKeyword ? '当前范围没有结果,可清除搜索返回完整车辆目录。' : '管理员分配车辆权限后会显示在这里。'}
action={deferredKeyword
? <Button theme="light" type="primary" icon={<IconRefresh />} onClick={() => { setKeyword(''); setDraftKeyword(''); setPage(1); setCandidatesOpen(false); }}></Button>
: <Button theme="light" type="primary" icon={<IconRefresh />} loading={candidates.isFetching} onClick={() => candidates.refetch()}></Button>}
/>}
{total > 0 ? <footer className="v2-vehicle-directory-pagination"><TablePagination page={page} totalPages={totalPages} info={`${total.toLocaleString('zh-CN')} 辆 · 本页 ${pageVehicles.length.toLocaleString('zh-CN')}`} disabled={candidates.isFetching} onPageChange={(next) => { setPage(next); setCandidatesOpen(false); }} /></footer> : null}
</Card>
{syncOpen ? <Suspense fallback={<Card className="v2-profile-sync-panel v2-profile-sync-loading" bodyStyle={{ padding: 0 }}><PanelLoading compact title="正在加载主档同步工具" description="同步配置就绪后会自动显示。" /></Card>}><VehicleProfileSyncPanel onClose={() => setSyncOpen(false)} /></Suspense> : null}
</section>;
}