475 lines
25 KiB
TypeScript
475 lines
25 KiB
TypeScript
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>;
|
||
}
|