refine Semi UI vehicle directory

This commit is contained in:
lingniu
2026-07-18 06:47:39 +08:00
parent 4bca0482c1
commit 4b3bb3b3dd
8 changed files with 475 additions and 184 deletions

View File

@@ -1,10 +1,10 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import {
IconAlarm, IconArrowRight, IconBox, IconCalendar, IconClock, IconCopy,
IconChevronRight, IconMapPin, IconRefresh, IconSearch, IconTickCircle
IconAlarm, IconBox, IconCalendar, IconClock, IconCopy,
IconChevronRight, IconMapPin, IconSearch, IconTickCircle
} from '@douyinfe/semi-icons';
import { Button, Card, Descriptions, Empty, Input, List, Select, Table, Tag } from '@douyinfe/semi-ui';
import { FormEvent, lazy, Suspense, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { FormEvent, lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { LatestTelemetryResponse, LatestTelemetryValue, QualityIssueRow, VehicleDetail, VehicleRealtimeRow } from '../../api/types';
@@ -18,11 +18,7 @@ import { FleetMap } from '../map/FleetMap';
import { InlineError, PageLoading } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { monitorReturnFromParams, withMonitorReturn } from '../routing/monitorContext';
import { useMobileLayout } from '../hooks/useMobileLayout';
@@ -35,7 +31,7 @@ function durationHours(seconds?: number | null) { return seconds == null ? '—'
function localDateTime(value?: string) { return value ? value.slice(0, 16) : ''; }
const operationStatusLabels = { unknown: '待维护', active: '运营中', inactive: '停运', maintenance: '维保中', retired: '已退役' } as const;
const SINGLE_VEHICLE_REFRESH_MS = 10_000;
const VehicleProfileSyncPanel = lazy(() => import('./VehicleProfileSyncPanel'));
const VehicleSearch = lazy(() => import('./VehicleSearchWorkspace'));
function resetWorkspaceScroll() {
const content = document.querySelector<HTMLElement>('.v2-content');
@@ -45,159 +41,12 @@ function resetWorkspaceScroll() {
content.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;
}
function eventTimeLabel(value?: string) {
if (!value) return '—';
const normalized = value.replace('T', ' ');
return normalized.length >= 16 ? normalized.slice(5, 16) : normalized;
}
function VehicleSearch() {
const navigate = useNavigate();
const { session } = usePlatformSession();
const mobileLayout = useMobileLayout();
const [keyword, setKeyword] = useState('');
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 candidateParams = useMemo(() => {
const params = new URLSearchParams({ limit: '12', offset: '0' });
if (deferredKeyword) params.set('keyword', deferredKeyword);
return params;
}, [deferredKeyword]);
const candidates = useQuery({
queryKey: ['vehicle-search-options', candidateParams.toString()],
queryFn: ({ signal }) => api.vehicles(candidateParams, signal),
staleTime: 30_000,
gcTime: QUERY_MEMORY.optionGcTime
});
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
const quickVehicles = useMemo(() => options.slice(0, mobileLayout ? 8 : 12), [mobileLayout, options]);
useEffect(() => () => window.clearTimeout(closeTimerRef.current), []);
const openCandidates = () => {
window.clearTimeout(closeTimerRef.current);
setCandidatesOpen(true);
};
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);
resetWorkspaceScroll();
navigate(`/vehicles/${encodeURIComponent(normalized)}`);
window.queueMicrotask(resetWorkspaceScroll);
window.requestAnimationFrame(resetWorkspaceScroll);
window.setTimeout(resetWorkspaceScroll, 120);
};
const submit = (event: FormEvent) => {
event.preventDefault();
openVehicle(keyword);
};
const vehicleScope = candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权` : '授权范围';
return <section className={`v2-vehicle-search-page${syncOpen ? ' has-sync-panel' : ''}${candidatesOpen ? ' has-candidates' : ''}`}>
<WorkspaceCommandBar
className="v2-vehicle-command-bar"
ariaLabel="查车操作"
title="车辆快速定位"
description="车牌、VIN 或手机号快速查车"
status={candidates.isPending ? '正在读取授权范围' : vehicleScope}
statusColor={candidates.isError ? 'red' : 'blue'}
actions={canAdminister(session) ? <Button theme="light" onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起主档同步' : '批量同步主档'}</Button> : null}
/>
<WorkspaceFilterPanel
className="v2-vehicle-query-panel"
title="车辆定位"
description="车牌优先VIN 辅助"
mobileSummary={deferredKeyword ? `正在查找 ${deferredKeyword}` : candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权车辆` : '正在读取授权范围'}
expanded={!filtersCollapsed}
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); setCandidatesOpen(true); }}
onFocus={openCandidates}
onBlur={closeCandidates}
placeholder="输入车牌 / VIN / 终端手机号"
autoComplete="off"
/>
</div>
<Button theme="solid" htmlType="submit" icon={<IconArrowRight />} iconPosition="right"></Button>
{candidatesOpen ? <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>
<Card className="v2-vehicle-recent-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title={deferredKeyword ? '匹配车辆快捷入口' : '最近上报车辆'}
description={deferredKeyword ? '按当前搜索条件展示,可直接进入车辆数字档案' : '按照最新上报时间排列,无需搜索即可继续查看'}
meta={<Tag color={deferredKeyword ? 'blue' : 'grey'} type="light" size="small">{candidates.data ? `当前显示 ${formatZhNumber(quickVehicles.length)}` : '授权范围'}</Tag>}
actions={<Button theme="borderless" type="tertiary" size="small" icon={<IconRefresh />} loading={candidates.isFetching} onClick={() => candidates.refetch()}></Button>}
/>
{candidates.isFetching && !candidates.data
? <div className="v2-vehicle-recent-state" role="status"><span className="v2-spinner" /><span></span></div>
: 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>
: quickVehicles.length
? <div className="v2-vehicle-recent-grid" role="list" aria-label={deferredKeyword ? '匹配车辆' : '最近上报车辆'}>
{quickVehicles.map((vehicle) => <Button
key={vehicle.vin}
className="v2-vehicle-recent-item"
theme="borderless"
type="tertiary"
role="listitem"
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) => <Tag key={protocol} color="blue" type="light" size="small">{protocol}</Tag>)}</span>
<IconChevronRight className="v2-vehicle-recent-arrow" />
</Button>)}
</div>
: <Empty className="v2-vehicle-recent-empty" title={deferredKeyword ? '没有匹配车辆' : '暂无授权车辆'} description={deferredKeyword ? '调整车牌、VIN 或手机号后重试。' : '管理员分配车辆权限后会显示在这里。'} />}
</Card>
{syncOpen ? <Suspense fallback={<Card className="v2-profile-sync-panel v2-profile-sync-loading" bodyStyle={{ padding: 0 }}><span role="status"><span className="v2-spinner" /></span></Card>}><VehicleProfileSyncPanel onClose={() => setSyncOpen(false)} /></Suspense> : null}
</section>;
}
function Archive({ detail, editable, onUpdated }: { detail: VehicleDetail; editable: boolean; onUpdated: () => void }) {
const profile = detail.profile;
const [editing, setEditing] = useState(false);
@@ -539,7 +388,7 @@ export default function VehiclePage() {
window.clearTimeout(settledTimer);
};
}, [vin, resolvedVin]);
if (!vin) return <VehicleSearch />;
if (!vin) return <Suspense fallback={<PageLoading />}><VehicleSearch /></Suspense>;
if (query.isPending) return <PageLoading />;
if (query.isError) return <div className="v2-page-error"><InlineError message={query.error instanceof Error ? query.error.message : '车辆档案加载失败'} onRetry={() => query.refetch()} /></div>;
if (!query.data.lookupResolved) return <Card className="v2-not-found" bodyStyle={{ padding: 0 }}><Empty image={<IconSearch size="extra-large" />} title="未找到车辆" description={`没有匹配“${vin}”的车牌、VIN 或终端记录。`}><Link to="/vehicles"><Button theme="solid" icon={<IconSearch />}></Button></Link></Empty></Card>;