679 lines
44 KiB
TypeScript
679 lines
44 KiB
TypeScript
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>
|
||
);
|
||
}
|