Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
T

679 lines
44 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 {
IconChevronLeft, IconFilter, IconList, IconMapPin,
IconQrCode, IconRefresh, IconSearch
} from '@douyinfe/semi-icons';
import { Button, Card, Input, Select, Spin, Table, Tag } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
import { lazy, memo, Suspense, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { Page, VehicleRealtimeRow } from '../../api/types';
import { FleetMap } from '../map/FleetMap';
import { EmptyState, InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { TablePagination } from '../shared/TablePagination';
import { ProtocolTag } from '../shared/ProtocolTag';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { formatNumber, statusLabel, vehicleStatus } from '../domain/monitor';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, type MonitorViewport } from '../hooks/useMonitorData';
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { buildMonitorPath, parseMonitorRouteContext, withMonitorReturn } from '../routing/monitorContext';
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
const statuses = ['', 'online', 'offline', 'driving', 'idle', 'no_location'];
const EMPTY_VEHICLES: VehicleRealtimeRow[] = [];
const MONITOR_VIEW_ITEMS = [
{ key: 'map', label: '地图', icon: <IconMapPin aria-hidden="true" /> },
{ key: 'list', label: '列表', icon: <IconList aria-hidden="true" /> }
] as const;
const VehicleDetailCard = lazy(() => import('./MonitorVehicleDetailCard'));
const MonitorCoverageWarning = lazy(() => import('./MonitorCoverageWarning'));
const BatchVehicleSearchDialog = lazy(() => import('./BatchVehicleSearchDialog'));
type AddressCoordinate = { longitude: number; latitude: number; key: string };
function hasRealtimeLocation(vehicle: VehicleRealtimeRow) {
if (vehicle.locationAvailable != null) return vehicle.locationAvailable;
return Number.isFinite(vehicle.longitude) && Number.isFinite(vehicle.latitude)
&& Math.abs(vehicle.longitude) <= 180 && Math.abs(vehicle.latitude) <= 90
&& (vehicle.longitude !== 0 || vehicle.latitude !== 0);
}
function hasRealtimeSpeed(vehicle: VehicleRealtimeRow) {
return vehicle.speedAvailable ?? hasRealtimeLocation(vehicle);
}
function hasRealtimeMileage(vehicle: VehicleRealtimeRow) {
return vehicle.mileageAvailable ?? hasRealtimeLocation(vehicle);
}
function hasTodayMileage(vehicle: VehicleRealtimeRow): vehicle is VehicleRealtimeRow & { todayMileageKm: number } {
return vehicle.todayMileageAvailable === true && vehicle.todayMileageKm != null;
}
function vehicleProtocols(vehicle: VehicleRealtimeRow): string[] {
const runtimeValue = (vehicle as VehicleRealtimeRow & { protocols?: unknown }).protocols;
const normalized: string[] = [];
const seen = new Set<string>();
if (Array.isArray(runtimeValue)) {
for (const value of runtimeValue) {
if (typeof value !== 'string') continue;
const protocol = value.trim();
if (!protocol || seen.has(protocol)) continue;
seen.add(protocol);
normalized.push(protocol);
}
}
const primaryProtocol = typeof vehicle.primaryProtocol === 'string' ? vehicle.primaryProtocol.trim() : '';
if (!normalized.length && primaryProtocol) normalized.push(primaryProtocol);
return normalized;
}
function vehiclePrimaryProtocol(vehicle: VehicleRealtimeRow, sourceProtocols = vehicleProtocols(vehicle)) {
const primaryProtocol = typeof vehicle.primaryProtocol === 'string' ? vehicle.primaryProtocol.trim() : '';
return primaryProtocol || sourceProtocols[0] || '';
}
function vehicleProtocolSignature(vehicle: VehicleRealtimeRow) {
return vehicleProtocols(vehicle).join('|');
}
function formatSupportCount(value: number) {
const absolute = Math.abs(value);
if (absolute >= 100_000_000) return `${Number((value / 100_000_000).toFixed(1))}亿`;
if (absolute >= 10_000) return `${Number((value / 10_000).toFixed(1))}万`;
return formatNumber(value);
}
function hasRealtimeSOC(vehicle: VehicleRealtimeRow) {
return vehicle.socAvailable ?? (vehicle.primaryProtocol === 'GB32960' || vehicle.primaryProtocol === 'YUTONG_MQTT');
}
function addressCoordinate(vehicle: VehicleRealtimeRow): AddressCoordinate | undefined {
if (!hasRealtimeLocation(vehicle) || !Number.isFinite(vehicle.longitude) || !Number.isFinite(vehicle.latitude)
|| Math.abs(vehicle.longitude) > 180 || Math.abs(vehicle.latitude) > 90
|| (vehicle.longitude === 0 && vehicle.latitude === 0)) return undefined;
const longitude = Number(vehicle.longitude.toFixed(4));
const latitude = Number(vehicle.latitude.toFixed(4));
return { longitude, latitude, key: `${longitude.toFixed(4)},${latitude.toFixed(4)}` };
}
const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehicle: VehicleRealtimeRow }) {
const current = addressCoordinate(vehicle);
const [requested, setRequested] = useState<AddressCoordinate>();
const addressQuery = useQuery({
queryKey: ['monitor', 'list-address', requested?.key],
queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({
longitude: requested!.longitude.toFixed(4),
latitude: requested!.latitude.toFixed(4)
}), signal),
enabled: Boolean(requested),
staleTime: 6 * 60 * 60_000,
gcTime: QUERY_MEMORY.highVolumeGcTime,
refetchOnWindowFocus: false,
retry: 1
});
const moved = Boolean(current && requested && current.key !== requested.key);
if (!current) return <span className="v2-monitor-address-empty">暂无实时位置</span>;
if (!requested) return <Button className="v2-monitor-address-action" size="small" theme="light" type="primary" icon={<IconMapPin />} aria-label={`解析${vehicle.plate || vehicle.vin}位置`} title="按需调用高德地址解析,不随实时数据刷新" onClick={() => setRequested(current)}>解析位置</Button>;
if (addressQuery.isFetching && !addressQuery.data) return <span className="v2-monitor-address-loading"><Spin size="small" />解析中</span>;
if (addressQuery.isError) return <Button className="v2-monitor-address-action is-error" size="small" theme="light" type="danger" aria-label={`${vehicle.plate || vehicle.vin}位置解析失败,重试`} onClick={() => void addressQuery.refetch()}>解析失败,重试</Button>;
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span><small>{addressQuery.dataUpdatedAt ? `解析于 ${new Date(addressQuery.dataUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })}` : ''}</small>{moved ? <Button className="v2-monitor-address-update" size="small" theme="borderless" type="warning" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}>位置已移动 · 更新</Button> : null}</div>;
});
type MonitorMobileVehicleCardProps = {
row: VehicleRealtimeRow;
selected: boolean;
onSelect: (vin: string) => void;
};
const MonitorMobileVehicleCard = memo(function MonitorMobileVehicleCard({ row, selected, onSelect }: MonitorMobileVehicleCardProps) {
const identity = row.plate || '未绑定车牌';
const location = hasRealtimeLocation(row);
const sourceProtocols = vehicleProtocols(row);
const primaryProtocol = vehiclePrimaryProtocol(row, sourceProtocols);
return <Card className={`v2-monitor-mobile-card${selected ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }} aria-label={`${identity} 实时数据${selected ? ',当前地图选中' : ''}`}>
<header className="v2-monitor-mobile-card-header">
<div className="v2-monitor-mobile-identity"><strong>{identity}</strong><span>{row.vin}</span></div>
<div className="v2-monitor-card-heading-actions">
<Button className="v2-monitor-card-locate" size="small" theme="light" type="primary" icon={<IconMapPin />} aria-label={`在地图中定位${identity}`} onClick={() => onSelect(row.vin)}>定位</Button>
</div>
</header>
<dl className="v2-monitor-mobile-primary">
<div><dt>速度</dt><dd>{hasRealtimeSpeed(row) ? <>{formatNumber(row.speedKmh, 1)}<small> km/h</small></> : '—'}</dd></div>
<div><dt>当日里程</dt><dd className="is-today">{hasTodayMileage(row) ? <>{formatNumber(row.todayMileageKm, 1)}<small> km</small></> : '—'}</dd></div>
<div><dt>总里程</dt><dd>{hasRealtimeMileage(row) ? <>{formatNumber(row.totalMileageKm, 1)}<small> km</small></> : '—'}</dd></div>
<div><dt>协议来源</dt><dd><span className="v2-monitor-mobile-protocol"><ProtocolTag protocol={primaryProtocol} compact unknownLabel="待识别" />{sourceProtocols.length > 1 ? <small>{sourceProtocols.length} </small> : null}</span></dd></div>
</dl>
<div className={`v2-monitor-mobile-location${location ? '' : ' is-unavailable'}`}>
<span className="v2-monitor-mobile-coordinate"><IconMapPin aria-hidden="true" />{location ? <code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code> : <small>暂无经纬度</small>}</span>
<MonitorAddressCell vehicle={row} />
</div>
</Card>;
}, (previous, next) => {
const before = previous.row;
const after = next.row;
return previous.onSelect === next.onSelect
&& previous.selected === next.selected
&& before.vin === after.vin
&& before.plate === after.plate
&& before.speedAvailable === after.speedAvailable
&& before.speedKmh === after.speedKmh
&& before.todayMileageAvailable === after.todayMileageAvailable
&& before.todayMileageKm === after.todayMileageKm
&& before.mileageAvailable === after.mileageAvailable
&& before.totalMileageKm === after.totalMileageKm
&& before.primaryProtocol === after.primaryProtocol
&& vehicleProtocolSignature(before) === vehicleProtocolSignature(after)
&& before.locationAvailable === after.locationAvailable
&& before.longitude === after.longitude
&& before.latitude === after.latitude;
});
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, error, mobile, selectedVin, mobileScrollTop, onMobileScroll, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; error: boolean; mobile: boolean; selectedVin: string; mobileScrollTop: number; onMobileScroll: (scrollTop: number) => void; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
const mobileCardsRef = useRef<HTMLDivElement>(null);
const columns = useMemo(() => [
{ title: '车辆', dataIndex: 'plate', width: 210, className: 'v2-monitor-table-vehicle', render: (_value: string, row: VehicleRealtimeRow) => <Button className={`v2-monitor-vehicle-action${row.vin === selectedVin ? ' is-selected' : ''}`} theme="borderless" type="tertiary" title={row.vin === selectedVin ? '当前地图选中,点击返回地图' : '在地图中定位'} onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></Button> },
{ title: '速度', dataIndex: 'speedKmh', width: 120, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value">{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}</strong>{hasRealtimeSpeed(row) ? <small className="v2-monitor-live-unit">km/h</small> : null}</> },
{ title: '当日里程', dataIndex: 'todayMileageKm', width: 140, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value is-today">{hasTodayMileage(row) ? formatNumber(row.todayMileageKm, 1) : '—'}</strong>{hasTodayMileage(row) ? <small className="v2-monitor-live-unit">km</small> : null}</> },
{ title: '总里程', dataIndex: 'totalMileageKm', width: 170, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value">{hasRealtimeMileage(row) ? formatNumber(row.totalMileageKm, 1) : '—'}</strong>{hasRealtimeMileage(row) ? <small className="v2-monitor-live-unit">km</small> : null}</> },
{ title: '协议来源', dataIndex: 'primaryProtocol', width: 150, render: (_value: string, row: VehicleRealtimeRow) => {
const sourceProtocols = vehicleProtocols(row);
return <div className="v2-monitor-protocol"><ProtocolTag protocol={vehiclePrimaryProtocol(row, sourceProtocols)} compact unknownLabel="待识别" />{sourceProtocols.length > 1 ? <small>+{sourceProtocols.length - 1} </small> : null}</div>;
} },
{ title: '经纬度', dataIndex: 'longitude', width: 190, render: (_value: number, row: VehicleRealtimeRow) => hasRealtimeLocation(row) ? <code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code> : <span className="v2-monitor-unavailable"></span> },
{ title: '地理位置', dataIndex: 'vin', render: (_value: string, row: VehicleRealtimeRow) => <MonitorAddressCell vehicle={row} /> }
], [onSelect, selectedVin]);
useLayoutEffect(() => {
if (!mobile || !mobileCardsRef.current) return;
mobileCardsRef.current.scrollTop = mobileScrollTop;
}, [mobile, mobileScrollTop, selectedVin]);
return <Card className="v2-monitor-table-panel" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
className="v2-monitor-list-header"
title="车辆实时列表"
description={mobile ? undefined : '覆盖全部授权车辆;缺失值显示“—”,地址按需解析'}
meta={`${total.toLocaleString('zh-CN')} 辆`}
/>
{!mobile ? <div className={`v2-monitor-table-scroll${error ? ' is-error' : ''}`}><Table className="v2-monitor-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} empty={null} onRow={(row) => row ? ({ className: row.vin === selectedVin ? 'is-selected' : '' }) : ({})} />{loading ? <PanelLoading className="v2-monitor-table-loading" compact title="正在更新车辆实时数据" description={rows.length ? '保留当前列表,完成后平滑替换。' : '首批实时车辆返回后会自动显示。'} /> : null}{!loading && !error && !rows.length ? <PanelEmpty className="v2-monitor-table-empty" tone="primary" icon={<IconSearch />} title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
{mobile ? <div ref={mobileCardsRef} className="v2-monitor-mobile-cards" tabIndex={0} aria-label="车辆实时列表" onScroll={(event) => onMobileScroll(event.currentTarget.scrollTop)}>{rows.map((row) => <MonitorMobileVehicleCard row={row} selected={row.vin === selectedVin} onSelect={onSelect} key={row.vin} />)}{loading && !rows.length ? <PanelLoading className="v2-monitor-table-loading" compact /> : null}{!loading && !error && !rows.length ? <PanelEmpty className="v2-monitor-table-empty" tone="primary" icon={<IconSearch />} title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
<footer><TablePagination page={page} totalPages={totalPages} info={`共 ${total.toLocaleString('zh-CN')} 辆车辆`} onPageChange={onPage} pageSize={limit} pageSizeLabel="每页车辆数" onPageSizeChange={onLimit} pageSizeOptions={[{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
</Card>;
}
function MobileEntry({ mobile, onClose }: { mobile: boolean; onClose: () => void }) {
const [qr, setQr] = useState('');
const [error, setError] = useState('');
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'error'>('idle');
const [attempt, setAttempt] = useState(0);
const url = `${window.location.origin}/monitor`;
useEffect(() => {
if (copyState === 'idle') return;
const timer = window.setTimeout(() => setCopyState('idle'), 1_800);
return () => window.clearTimeout(timer);
}, [copyState]);
useEffect(() => {
let cancelled = false;
setQr('');
setError('');
void import('qrcode')
.then(({ default: QRCode }) => QRCode.toDataURL(url, { width: 240, margin: 2, color: { dark: '#122033', light: '#ffffff' } }))
.then((value) => { if (!cancelled) setQr(value); })
.catch(() => { if (!cancelled) setError('二维码生成失败,请检查浏览器环境后重试'); });
return () => { cancelled = true; };
}, [attempt, url]);
const copyURL = async () => {
try {
if (!navigator.clipboard?.writeText) throw new Error('clipboard unavailable');
await navigator.clipboard.writeText(url);
setCopyState('copied');
} catch {
setCopyState('error');
}
};
const qrState = qr ? '已生成' : error ? '生成失败' : '生成中';
const copyLabel = copyState === 'copied' ? '已复制访问地址' : copyState === 'error' ? '复制失败,重试' : '复制访问地址';
return <WorkspaceSideSheet
className="v2-monitor-qr-sidesheet"
variant="action"
visible
ariaLabel="手机端全局监控"
closeLabel="关闭手机端入口"
dialogId="v2-monitor-mobile-entry"
placement={mobile ? 'bottom' : 'right'}
width={mobile ? undefined : 430}
height={mobile ? 'min(82dvh, 566px)' : undefined}
title="手机端全局监控"
description="扫码后使用现有账号进入全局监控"
icon={<IconQrCode />}
badge="账号鉴权"
badgeColor="blue"
summaryItems={[
{ label: '入口页面', value: '全局监控', detail: '移动端响应式布局', tone: 'primary' },
{ label: '凭证安全', value: '不含 Token', detail: '扫码后仍需账号鉴权', tone: 'success' },
{ label: '二维码状态', value: qrState, detail: error ? '可在下方重新生成' : '仅在打开入口时生成', tone: error ? 'danger' : qr ? 'success' : 'neutral' }
]}
footerNote={url}
secondaryActions={[{ label: '关闭', onClick: onClose }]}
primaryAction={{ label: copyLabel, loading: !qr && !error, disabled: !qr && !error, onClick: () => void copyURL() }}
onCancel={onClose}
>
<div className="v2-monitor-qr-content">
<p>扫码后使用现有账号鉴权,不在二维码中保存 Token</p>
{qr ? <img src={qr} alt="全局监控手机端二维码" /> : error ? <div className="v2-monitor-qr-error" role="alert"><span>{error}</span><Button theme="light" type="danger" onClick={() => setAttempt((value) => value + 1)}>重新生成</Button></div> : <span className="v2-spinner" role="status" aria-label="正在生成二维码" />}
<code>{url}</code>
<span className="v2-sr-only" aria-live="polite">{copyLabel}</span>
</div>
</WorkspaceSideSheet>;
}
const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { vehicle: VehicleRealtimeRow; selected: boolean; onSelect: (vin: string) => void }) {
const status = vehicleStatus(vehicle);
return (
<Button theme="borderless" type="tertiary" className={`v2-vehicle-row ${selected ? 'is-selected' : ''}`} onClick={() => onSelect(vehicle.vin)}>
<i className={`v2-status-dot is-${status}`} />
<span className="v2-vehicle-identity"><strong>{vehicle.plate || '未绑定车牌'}</strong><small>{vehicle.vin}</small></span>
<span className="v2-vehicle-motion"><strong>{formatNumber(vehicle.speedKmh, 1)} <small>km/h</small></strong><small>{statusLabel(status)}</small></span>
</Button>
);
});
const MemoFleetMap = memo(FleetMap);
export default function MonitorPage() {
const [routeParams, setRouteParams] = useSearchParams();
const navigate = useNavigate();
const pageRef = useRef<HTMLDivElement>(null);
const initialContextRef = useRef(parseMonitorRouteContext(routeParams));
const initialContext = initialContextRef.current;
const mobileLayout = useMobileLayout(760);
const [mode, setMode] = useState<'map' | 'list'>(initialContext.mode);
const [mobileEntryOpen, setMobileEntryOpen] = useState(false);
const [listOffset, setListOffset] = useState(initialContext.listOffset);
const [listLimit, setListLimit] = useState(initialContext.listLimit);
const [keyword, setKeyword] = useState(initialContext.keyword);
const [batchSearchOpen, setBatchSearchOpen] = useState(false);
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const deferredKeyword = useDeferredValue(keyword);
const searchTerms = useMemo(() => parseMonitorSearchTerms(keyword), [keyword]);
const [protocol, setProtocol] = useState(initialContext.protocol);
const [status, setStatus] = useState(initialContext.status);
const [selectedVin, setSelectedVin] = useState(initialContext.selectedVin);
const [detailOpen, setDetailOpen] = useState(initialContext.detailOpen);
const mobileAutoSelectionRef = useRef('');
const mobileInitialFleetReadyRef = useRef(false);
const listReturnPendingRef = useRef(false);
const listScrollTopRef = useRef(0);
const [viewport, setViewport] = useState<MonitorViewport>(initialContext.viewport);
const updateViewport = useCallback((next: MonitorViewport) => {
setViewport((current) => current.zoom === next.zoom && current.bounds === next.bounds ? current : next);
}, []);
const filters = useMemo(() => ({ keyword: deferredKeyword, protocol, status }), [deferredKeyword, protocol, status]);
const monitorReturn = useMemo(() => buildMonitorPath({
mode, keyword, protocol, status, selectedVin, detailOpen, viewport, listOffset, listLimit
}), [detailOpen, keyword, listLimit, listOffset, mode, protocol, selectedVin, status, viewport]);
useEffect(() => {
const next = new URL(monitorReturn, 'https://vehicle-platform.invalid').searchParams;
if (next.toString() !== routeParams.toString()) setRouteParams(next, { replace: true });
}, [monitorReturn, routeParams, setRouteParams]);
const listScope = useMemo(() => monitorFilterScope(filters), [filters]);
const listParams = useMemo(() => {
const params = monitorQueryParams(filters, listLimit);
params.set('offset', String(listOffset));
params.set('sort', 'identity');
return params;
}, [filters, listLimit, listOffset]);
const trackedVin = mode === 'map' ? selectedVin : '';
const { summary, vehicles, map, selectedVehicle } = useMonitorData(filters, viewport, trackedVin, mode === 'map', mode === 'map');
const realtimeListQuery = useQuery<Page<VehicleRealtimeRow>>({
queryKey: ['monitor', 'vehicle-list', listScope, listLimit, listOffset],
queryFn: ({ signal }) => api.vehicleRealtime(listParams, signal),
enabled: mode === 'list', placeholderData: retainPreviousPageWithinScope<Page<VehicleRealtimeRow>>(listScope, 2), refetchInterval: mode === 'list' ? MONITOR_REFRESH.fleet : false,
gcTime: QUERY_MEMORY.highVolumeGcTime,
...LIVE_QUERY_POLICY
});
const rows = useMemo(() => {
const data = vehicles.data?.items ?? [];
if (status === 'driving' || status === 'idle') return data.filter((vehicle) => vehicleStatus(vehicle) === status);
return data;
}, [status, vehicles.data?.items]);
const listRows = realtimeListQuery.data?.items ?? EMPTY_VEHICLES;
const activeBatchRows = mode === 'map' ? rows : listRows;
const activeBatchQuery = mode === 'map' ? vehicles : realtimeListQuery;
const filterTransitionPending = keyword !== deferredKeyword || activeBatchQuery.isLoading;
const batchSearchPending = searchTerms.length > 1 && filterTransitionPending;
const batchMatch = useMemo(() => {
const identities = new Set<string>();
for (const vehicle of activeBatchRows) {
identities.add(vehicle.vin.toLocaleUpperCase());
if (vehicle.plate) identities.add(vehicle.plate.toLocaleUpperCase());
}
const missing = searchTerms.filter((term) => !identities.has(term));
return { matched: searchTerms.length - missing.length, missing };
}, [activeBatchRows, searchTerms]);
const visibleRows = filterTransitionPending ? [] : rows;
const batchStatus = batchSearchPending
? `正在查找 ${searchTerms.length} 辆`
: batchMatch.missing.length
? `已找到 ${batchMatch.matched}/${searchTerms.length} · 未找到 ${batchMatch.missing.length} 辆`
: `已找到 ${batchMatch.matched}/${searchTerms.length}`;
const batchStatusTitle = batchMatch.missing.length
? `未找到:${batchMatch.missing.join('、')}`
: searchTerms.join('、');
const visibleListRows = filterTransitionPending ? EMPTY_VEHICLES : listRows;
const visibleListTotal = filterTransitionPending ? 0 : (realtimeListQuery.data?.total ?? 0);
const selected = selectedVin
? rows.find((vehicle) => vehicle.vin === selectedVin) ?? selectedVehicle.data?.items[0]
: undefined;
const resetMonitorScroll = useCallback(() => {
const scrollOwner = pageRef.current?.closest<HTMLElement>('.v2-content');
if (scrollOwner) scrollOwner.scrollTop = 0;
}, []);
useLayoutEffect(() => {
if (!mobileLayout) return;
resetMonitorScroll();
const frame = window.requestAnimationFrame(resetMonitorScroll);
return () => window.cancelAnimationFrame(frame);
}, [detailOpen, filtersCollapsed, mobileLayout, resetMonitorScroll, selectedVin]);
useLayoutEffect(() => {
if (!mobileLayout) {
mobileInitialFleetReadyRef.current = false;
return;
}
if (!vehicles.data || mobileInitialFleetReadyRef.current) return;
mobileInitialFleetReadyRef.current = true;
resetMonitorScroll();
const frame = window.requestAnimationFrame(resetMonitorScroll);
return () => window.cancelAnimationFrame(frame);
}, [mobileLayout, resetMonitorScroll, vehicles.data]);
useLayoutEffect(() => {
resetMonitorScroll();
}, [resetMonitorScroll, status]);
const selectVehicle = useCallback((vin: string, origin: 'map' | 'list' = 'map') => {
listReturnPendingRef.current = mobileLayout && origin === 'list';
resetMonitorScroll();
setSelectedVin(vin);
setDetailOpen(true);
setMode('map');
}, [mobileLayout, resetMonitorScroll]);
const selectListVehicle = useCallback((vin: string) => selectVehicle(vin, 'list'), [selectVehicle]);
const clearSelection = useCallback(() => {
listReturnPendingRef.current = false;
setSelectedVin('');
setDetailOpen(false);
}, []);
const applyStatusFilter = useCallback((nextStatus: string) => {
setStatus(nextStatus);
setListOffset(0);
clearSelection();
}, [clearSelection]);
const selectMapVehicle = useCallback((vehicle: VehicleRealtimeRow) => selectVehicle(vehicle.vin), [selectVehicle]);
const collapseDetail = useCallback(() => {
setDetailOpen(false);
if (mobileLayout && listReturnPendingRef.current) {
listReturnPendingRef.current = false;
resetMonitorScroll();
setMode('list');
}
}, [mobileLayout, resetMonitorScroll]);
const clearDetail = useCallback(() => {
const returnToList = mobileLayout && listReturnPendingRef.current;
clearSelection();
if (returnToList) {
resetMonitorScroll();
setMode('list');
}
}, [clearSelection, mobileLayout, resetMonitorScroll]);
const expandDetail = useCallback(() => setDetailOpen(true), []);
const changeMonitorMode = useCallback((nextMode: 'map' | 'list') => {
listReturnPendingRef.current = false;
resetMonitorScroll();
setMode(nextMode);
if (nextMode === 'list') setDetailOpen(false);
}, [resetMonitorScroll]);
useEffect(() => {
const term = searchTerms.length === 1 ? searchTerms[0] : '';
if (!mobileLayout || !term || filterTransitionPending) {
if (!term) mobileAutoSelectionRef.current = '';
return;
}
const exactVehicle = rows.find((vehicle) => vehicle.vin.toLocaleUpperCase() === term || vehicle.plate?.toLocaleUpperCase() === term);
if (!exactVehicle || mobileAutoSelectionRef.current === term) return;
mobileAutoSelectionRef.current = term;
setFiltersCollapsed(true);
selectVehicle(exactVehicle.vin);
}, [filterTransitionPending, mobileLayout, rows, searchTerms, selectVehicle]);
const openVehicleList = useCallback(() => changeMonitorMode('list'), [changeMonitorMode]);
const clearFilters = useCallback(() => {
setKeyword('');
setProtocol('');
setStatus('');
setListOffset(0);
clearSelection();
}, [clearSelection]);
const activeFilterCount = Number(Boolean(keyword.trim())) + Number(Boolean(protocol)) + Number(Boolean(status));
const driving = rows.filter((vehicle) => vehicleStatus(vehicle) === 'driving').length;
const idle = rows.filter((vehicle) => vehicleStatus(vehicle) === 'idle').length;
const offline = rows.filter((vehicle) => vehicleStatus(vehicle) === 'offline').length;
const totalVehicleCount = summary.data?.truncated
? vehicles.data?.total ?? summary.data.totalVehicles
: summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length;
const onlineVehicleCount = summary.data?.onlineVehicles ?? rows.length - offline;
const offlineVehicleCount = summary.data?.offlineVehicles ?? offline;
const drivingVehicleCount = summary.data?.drivingVehicles ?? driving;
const idleVehicleCount = summary.data?.idleVehicles ?? idle;
const alertVehicleCount = summary.data?.alertDataAvailable ? summary.data.alertVehicles : undefined;
const monitorCoverageTruncated = Boolean(summary.data?.truncated || map.data?.truncated);
const processedVehicleCount = summary.data?.totalVehicles ?? visibleRows.length;
const monitorMetrics: WorkspaceQueueMetricRailItem[] = [
{
label: activeFilterCount ? '筛选结果' : '车辆总数',
value: <>{formatNumber(totalVehicleCount)}<i className="v2-monitor-summary-unit"></i></>,
note: activeFilterCount ? `${activeFilterCount} 个条件` : '授权车辆',
tone: 'primary',
emphasis: 'primary',
active: !status,
ariaLabel: status ? '清除状态筛选' : '查看全部状态车辆',
onClick: () => applyStatusFilter('')
},
{
label: '当前在线',
value: <>{formatNumber(onlineVehicleCount)}<i className="v2-monitor-summary-unit"></i></>,
note: '当前可用',
tone: 'success',
emphasis: 'primary',
active: status === 'online',
ariaLabel: `筛选当前在线车辆,共 ${formatNumber(onlineVehicleCount)} 辆`,
onClick: () => applyStatusFilter('online')
},
{
label: '行驶车辆',
value: <>{formatNumber(drivingVehicleCount)}<i className="v2-monitor-summary-unit"></i></>,
note: '实时行驶',
tone: 'primary',
emphasis: 'primary',
active: status === 'driving',
ariaLabel: `筛选行驶车辆,共 ${formatNumber(drivingVehicleCount)} 辆`,
onClick: () => applyStatusFilter('driving')
},
{
label: '当前离线',
value: <>{formatNumber(offlineVehicleCount)}<i className="v2-monitor-summary-unit"></i></>,
note: '无在线来源',
tone: 'neutral',
emphasis: 'secondary',
active: status === 'offline',
ariaLabel: `筛选当前离线车辆,共 ${formatNumber(offlineVehicleCount)} 辆`,
onClick: () => applyStatusFilter('offline')
},
{
label: '静止车辆',
value: <>{formatNumber(idleVehicleCount)}<i className="v2-monitor-summary-unit"></i></>,
note: '在线静止',
tone: 'success',
emphasis: 'secondary',
active: status === 'idle',
ariaLabel: `筛选静止车辆,共 ${formatNumber(idleVehicleCount)} 辆`,
onClick: () => applyStatusFilter('idle')
},
{
label: '告警车辆',
value: <>{alertVehicleCount == null ? '—' : formatNumber(alertVehicleCount)}<i className="v2-monitor-summary-unit"></i></>,
note: alertVehicleCount == null ? '数据未开放' : '需要关注',
tone: alertVehicleCount ? 'danger' : 'neutral',
emphasis: 'primary',
ariaLabel: alertVehicleCount == null ? '打开事件中心' : `打开事件中心核对告警车辆,共 ${formatNumber(alertVehicleCount)} 辆`,
onClick: () => navigate(withMonitorReturn('/alerts?status=unprocessed', monitorReturn))
}
];
const mapCoverage = map.data?.mode === 'provinces'
? `${formatNumber(map.data.total)} / ${formatNumber(totalVehicleCount)} 辆有定位 · 省级聚合`
: map.data?.mode === 'clusters'
? `${formatNumber(map.data.total)} / ${formatNumber(totalVehicleCount)} 辆有定位 · ${map.data.clusters.length} 个聚合`
: `${formatNumber(map.data?.total ?? 0)} / ${formatNumber(totalVehicleCount)} 辆有定位 · ${map.data?.points.length ?? 0} 个车辆点`;
const viewportLoad = mode === 'map'
? mapCoverage
: `${realtimeListQuery.data?.items.length ?? 0} / ${realtimeListQuery.data?.total ?? 0} 辆`;
const lastSyncAt = vehicles.dataUpdatedAt ? new Date(vehicles.dataUpdatedAt) : undefined;
return (
<div ref={pageRef} className={`v2-monitor-page is-${mode}-mode`}>
<Card className="v2-filterbar" bodyStyle={{ padding: 0 }} aria-label="车辆筛选">
<div id="monitor-filter-fields" className={`v2-monitor-filter-fields${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} aria-label="筛选条件">
<div className={`v2-search-field${searchTerms.length > 1 ? ' is-batch' : ''}`}>
<Input
aria-label="搜索车辆"
prefix={<IconSearch />}
value={keyword}
onChange={(value) => { setKeyword(value); setListOffset(0); clearSelection(); }}
onPaste={(event) => {
const pastedTerms = parseMonitorSearchTerms(event.clipboardData.getData('text'));
if (pastedTerms.length <= 1) return;
event.preventDefault();
setKeyword(pastedTerms.join(''));
setListOffset(0);
clearSelection();
}}
placeholder="车牌 / VIN;可批量粘贴车牌"
suffix={<Button className="v2-search-batch-action" size="small" theme="borderless" onClick={() => setBatchSearchOpen(true)}>批量</Button>}
/>
{searchTerms.length > 1 ? <span className={`v2-search-batch-count${batchMatch.missing.length && !batchSearchPending ? ' has-missing' : ''}`} aria-live="polite" title={searchTerms.length === MAX_MONITOR_SEARCH_TERMS ? `最多支持 ${MAX_MONITOR_SEARCH_TERMS} 条;${batchStatusTitle}` : batchStatusTitle}>{batchSearchPending ? `已识别 ${searchTerms.length} 辆` : `已找到 ${batchMatch.matched}/${searchTerms.length}`}</span> : null}
</div>
<span className="v2-sr-only" id="monitor-protocol-filter-label">协议</span>
<Select value={protocol} onChange={(value) => { setProtocol(String(value)); setListOffset(0); clearSelection(); }} aria-labelledby="monitor-protocol-filter-label" optionList={protocols.map((item) => ({ value: item, label: item || '全部协议' }))} />
<span className="v2-sr-only" id="monitor-status-filter-label">在线状态</span>
<Select value={status} onChange={(value) => applyStatusFilter(String(value))} aria-labelledby="monitor-status-filter-label" optionList={statuses.map((item) => ({ value: item, label: item === 'no_location' ? '无实时位置' : item ? statusLabel(item as never) : '全部状态' }))} />
<Button className="v2-filter-reset" icon={<IconRefresh />} disabled={activeFilterCount === 0} onClick={clearFilters}>清空</Button>
</div>
<div className="v2-monitor-filter-actions" aria-label="监控操作">
{mobileLayout ? <Button
className={`v2-monitor-filter-toggle${activeFilterCount ? ' has-filters' : ''}`}
theme="light"
icon={<IconFilter />}
aria-controls="monitor-filter-fields"
aria-expanded={!filtersCollapsed}
aria-label={`${filtersCollapsed ? '展开' : '收起'}车辆筛选,当前 ${activeFilterCount} 个条件`}
onClick={() => setFiltersCollapsed((value) => !value)}
>
{filtersCollapsed ? '筛选' : '收起'}{activeFilterCount ? <b>{activeFilterCount}</b> : null}
</Button> : <span
className={`v2-filter-live-status${activeFilterCount ? ' has-filters' : ''}`}
role="status"
aria-label={`搜索和筛选条件修改后自动生效,已启用 ${activeFilterCount} 个条件`}
title="搜索和筛选条件修改后自动生效"
>
<IconFilter aria-hidden="true" />
<span className="v2-filter-live-status-label">自动生效</span>
{activeFilterCount ? <b>{activeFilterCount}</b> : null}
</span>}
<SegmentedTabs
ariaLabel="监控视图"
className="v2-monitor-mode"
value={mode}
items={MONITOR_VIEW_ITEMS}
onChange={changeMonitorMode}
variant="filled"
/>
{!mobileLayout ? <Button className="v2-monitor-mobile-entry" theme="light" icon={<IconQrCode />} aria-label="打开手机端入口" onClick={() => setMobileEntryOpen(true)}><span className="v2-monitor-mobile-entry-label">手机端</span></Button> : null}
</div>
</Card>
{batchSearchOpen ? <Suspense fallback={null}><BatchVehicleSearchDialog initialValue={searchTerms.join('\n')} mobile={mobileLayout} onClose={() => setBatchSearchOpen(false)} onApply={(value) => { setKeyword(value); setListOffset(0); clearSelection(); setBatchSearchOpen(false); }} /></Suspense> : null}
<WorkspaceMetricRail
variant="queue"
ariaLabel="车辆整体统计"
className="v2-monitor-summary-rail"
items={monitorMetrics}
context={<div className="v2-monitor-summary-support">
<span title={`今日上报 ${formatNumber(summary.data?.frameToday ?? 0)} 条`}><small>今日上报</small><strong>{formatSupportCount(summary.data?.frameToday ?? 0)}<i></i></strong><em>数据活跃度</em></span>
<Button className={`v2-monitor-summary-support-action${status === 'no_location' ? ' is-active' : ''}`} theme="borderless" type="tertiary" aria-label={`筛选无实时位置车辆,共 ${formatNumber(summary.data?.noLocationVehicles ?? 0)} 辆`} aria-pressed={status === 'no_location'} onClick={() => applyStatusFilter('no_location')}><small>无实时位置</small><strong>{formatNumber(summary.data?.noLocationVehicles ?? 0)}<i></i></strong><em>辅助排查</em></Button>
</div>}
/>
{vehicles.isError ? <InlineError message={vehicles.error instanceof Error ? vehicles.error.message : '车辆数据加载失败'} onRetry={() => vehicles.refetch()} /> : null}
{mode === 'list' && realtimeListQuery.isError ? <InlineError message={realtimeListQuery.error instanceof Error ? realtimeListQuery.error.message : '车辆列表加载失败'} onRetry={() => realtimeListQuery.refetch()} /> : null}
{mode === 'map' && monitorCoverageTruncated ? <Suspense fallback={null}><MonitorCoverageWarning processed={formatNumber(processedVehicleCount)} total={formatNumber(vehicles.data?.total ?? totalVehicleCount)} onOpenList={openVehicleList} /></Suspense> : null}
{mode === 'map' ? <section className={`v2-monitor-workspace${selected && detailOpen ? ' is-detail-open' : ''}${selected && !detailOpen ? ' is-detail-collapsed' : ''}`}>
<div className="v2-vehicle-rail">
<header><strong>车辆列表</strong><span>{batchSearchPending ? '查询中' : `${formatNumber(vehicles.data?.total ?? visibleRows.length)} 辆`}</span></header>
<div role="status" className={`v2-rail-search${batchMatch.missing.length && !batchSearchPending ? ' has-missing' : ''}`} title={searchTerms.length > 1 ? batchStatusTitle : undefined}><span>{searchTerms.length > 1 ? batchStatus : deferredKeyword ? `正在筛选“${deferredKeyword}”` : '最新上报优先'}</span></div>
<div className="v2-vehicle-scroll">
{vehicles.isLoading || batchSearchPending ? <div className="v2-list-loading"><span className="v2-spinner" />{batchSearchPending ? `正在查找 ${searchTerms.length} 辆车` : '加载车辆'}</div> : null}
{!vehicles.isLoading && !vehicles.isError && !batchSearchPending && visibleRows.length === 0 ? <EmptyState /> : null}
{visibleRows.map((vehicle) => <VehicleRow key={vehicle.vin} vehicle={vehicle} selected={vehicle.vin === selectedVin} onSelect={selectVehicle} />)}
</div>
<footer>{searchTerms.length > 1 && !batchSearchPending ? batchStatus : `当前载入 ${visibleRows.length} / ${vehicles.data?.total ?? visibleRows.length} 辆`}</footer>
</div>
<MemoFleetMap
vehicles={visibleRows}
monitorMap={filterTransitionPending ? undefined : map.data}
selectedVin={selectedVin || undefined}
onSelect={selectMapVehicle}
onSelectVin={selectVehicle}
onViewportChange={updateViewport}
initialViewport={initialContext.hasViewport ? initialContext.viewport : undefined}
onOpenList={openVehicleList}
/>
{selected && detailOpen ? (
<Suspense fallback={<Card className="v2-vehicle-detail v2-detail-loading-card" bodyStyle={{ padding: 0 }}><PanelLoading compact title="正在加载车辆详情" description="车辆状态和最新上报即将就绪。" /></Card>}>
<VehicleDetailCard
vehicle={selected}
monitorReturn={monitorReturn}
onCollapse={collapseDetail}
onClear={clearDetail}
collapseLabel={mobileLayout && listReturnPendingRef.current ? '返回车辆列表' : undefined}
/>
</Suspense>
) : null}
{selected && !detailOpen ? (
<Card className="v2-detail-peek" bodyStyle={{ padding: 0 }}>
<Button theme="borderless" type="tertiary" aria-label="展开车辆详情" title={`展开 ${selected.plate || selected.vin} 的车辆详情`} onClick={expandDetail}>
<IconChevronLeft />
<i className={`v2-status-dot is-${vehicleStatus(selected)}`} />
<span>{selected.plate || '未绑定车牌'}</span>
</Button>
</Card>
) : null}
</section> : <MonitorVehicleTable rows={visibleListRows} total={visibleListTotal} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil(visibleListTotal / listLimit))} limit={listLimit} loading={filterTransitionPending || realtimeListQuery.isFetching} error={realtimeListQuery.isError} mobile={mobileLayout} selectedVin={selectedVin} mobileScrollTop={listScrollTopRef.current} onMobileScroll={(scrollTop) => { listScrollTopRef.current = scrollTop; }} onSelect={selectListVehicle} onPage={(page) => { listScrollTopRef.current = 0; setListOffset((page - 1) * listLimit); }} onLimit={(next) => { listScrollTopRef.current = 0; setListLimit(next); setListOffset(0); }} />}
<Card className="v2-event-strip" bodyStyle={{ padding: 0 }} aria-label="实时数据状态">
<div className="v2-event-sync">
<Tag className="v2-monitor-live-tag" color="green" type="light" size="small"><IconRefresh aria-hidden="true" />{vehicles.isFetching ? '正在同步' : '数据已同步'}</Tag>
<span><strong>实时数据</strong><small>{visibleRows.length.toLocaleString('zh-CN')} 辆已载入</small></span>
</div>
<div className={`v2-event-load${monitorCoverageTruncated && mode === 'map' ? ' is-truncated' : ''}`}><small>{mode === 'map' ? '地图覆盖' : '列表载荷'}</small><span>{viewportLoad}</span></div>
<div className="v2-refresh-cadence">
<Tag color="blue" type="light" size="small"><IconRefresh aria-hidden="true" />智能刷新</Tag>
<span>重点 {MONITOR_REFRESH.selected / 1000}s · 车队 {MONITOR_REFRESH.fleet / 1000}s · 统计 {MONITOR_REFRESH.summary / 1000}s</span>
</div>
<time dateTime={lastSyncAt?.toISOString()}><span><small>最近同步</small><b>{lastSyncAt ? lastSyncAt.toLocaleTimeString('zh-CN', { hour12: false }) : '等待数据'}</b></span></time>
</Card>
{mobileEntryOpen ? <MobileEntry mobile={mobileLayout} onClose={() => setMobileEntryOpen(false)} /> : null}
</div>
);
}