Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
2026-07-18 18:07:15 +08:00

557 lines
40 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, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin,
IconQrCode, IconRefresh, IconSearch
} from '@douyinfe/semi-icons';
import { Button, Card, Empty, Input, Modal, Select, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
import { memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Link, 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 } from '../shared/AsyncState';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { TablePagination } from '../shared/TablePagination';
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
import { formatTelemetryValue } from '../domain/telemetry';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, 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;
function BatchVehicleSearchDialog({ initialValue, onApply, onClose }: { initialValue: string; onApply: (value: string) => void; onClose: () => void }) {
const [draft, setDraft] = useState(initialValue);
const terms = useMemo(() => parseMonitorSearchTerms(draft), [draft]);
return <Modal
className="v2-batch-search-modal"
visible
centered
closeOnEsc
maskClosable
width={520}
title={<div className="v2-batch-search-title"><strong id="batch-search-title"></strong><span> Excel</span></div>}
onCancel={onClose}
footer={<div className="v2-batch-search-actions"><Button onClick={onClose}></Button><Button theme="solid" disabled={!terms.length} onClick={() => onApply(terms.join(''))}>{terms.length}</Button></div>}
>
<div className="v2-batch-search-dialog">
<label htmlFor="batch-vehicle-search"></label>
<TextArea id="batch-vehicle-search" autoFocus value={draft} onChange={setDraft} autosize={{ minRows: 8, maxRows: 14 }} resize="vertical" placeholder={'粤A12345\n粤B67890\n粤C24680'} />
<div className="v2-batch-search-summary"><span> <strong>{terms.length}</strong> </span><em> {MAX_MONITOR_SEARCH_TERMS} </em></div>
</div>
</Modal>;
}
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 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>;
});
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, mobile, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; mobile: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
const columns = useMemo(() => [
{ title: '车辆', dataIndex: 'plate', width: 210, className: 'v2-monitor-table-vehicle', render: (_value: string, row: VehicleRealtimeRow) => <Button className="v2-monitor-vehicle-action" theme="borderless" type="tertiary" title="在地图中定位" 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) => <div className="v2-monitor-protocol"><Tag color={row.primaryProtocol ? 'blue' : 'grey'} type="light" size="small">{row.primaryProtocol || '未知'}</Tag>{row.protocols.length > 1 ? <small>+{row.protocols.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]);
return <Card className="v2-monitor-table-panel" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="车辆实时列表"
description="覆盖全部授权车辆;缺失值显示“—”,地址按需解析"
meta={`${total.toLocaleString('zh-CN')} 辆车辆`}
/>
{!mobile ? <div className="v2-monitor-table-scroll"><Table className="v2-monitor-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} empty={null} />{loading ? <div className="v2-monitor-table-loading" role="status"><Spin size="small" tip="正在更新车辆实时数据…" /></div> : null}{!loading && !rows.length ? <Empty className="v2-monitor-table-empty" title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
{mobile ? <div className="v2-monitor-mobile-cards">{rows.map((row) => <Card className="v2-monitor-mobile-card" key={row.vin}>
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><div className="v2-monitor-card-heading-actions"><b>{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? <small>km/h</small> : null}</b><Button className="v2-monitor-card-locate" size="small" theme="light" type="primary" icon={<IconMapPin />} aria-label={`在地图中定位${row.plate || row.vin}`} onClick={() => onSelect(row.vin)}></Button></div></header>
<dl><div><dt></dt><dd className="is-today">{hasTodayMileage(row) ? `${formatNumber(row.todayMileageKm, 1)} km` : '—'}</dd></div><div><dt></dt><dd>{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}</dd></div><div><dt></dt><dd><span className="v2-monitor-mobile-protocol">{row.primaryProtocol || '未知'}{row.protocols.length > 1 ? ` · ${row.protocols.length}` : ''}</span></dd></div><div><dt></dt><dd>{hasRealtimeLocation(row) ? <code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code> : '—'}</dd></div><div className="is-address"><dt></dt><dd><MonitorAddressCell vehicle={row} /></dd></div></dl>
</Card>)}{loading ? <div className="v2-monitor-table-loading" role="status"><Spin size="small" tip="正在更新车辆实时数据…" /></div> : null}{!loading && !rows.length ? <Empty className="v2-monitor-table-empty" 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({ onClose }: { 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`;
useLayoutEffect(() => {
document.querySelector('.v2-monitor-qr-modal .semi-modal-close')?.setAttribute('aria-label', '关闭手机端入口');
}, []);
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');
}
};
return <Modal
className="v2-monitor-qr-modal"
visible
centered
width={390}
title="手机端全局监控"
closeIcon={<IconClose aria-label="关闭手机端入口" />}
closeOnEsc
maskClosable
onCancel={onClose}
footer={<Button block theme="solid" aria-live="polite" onClick={() => void copyURL()}>{copyState === 'copied' ? '已复制访问地址' : copyState === 'error' ? '复制失败,重试' : '复制访问地址'}</Button>}
>
<div className="v2-monitor-qr-content">
<span className="v2-monitor-qr-icon"><IconQrCode /></span>
<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>
</div>
</Modal>;
}
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);
function VehicleDetailCard({
vehicle,
monitorReturn,
onCollapse,
onClear
}: {
vehicle: VehicleRealtimeRow;
monitorReturn: string;
onCollapse: () => void;
onClear: () => void;
}) {
const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
const card = useMonitorVehicleCard(vehicle.vin, vehicle, true);
const detail = card.detail.data;
const activeAlerts = card.activeAlerts.data;
const telemetry = card.telemetry?.data;
const address = card.address.data;
const status = vehicleStatus(vehicle);
// The realtime row is joined to vehicle_daily_mileage with stat_date = CURDATE().
// Vehicle detail mileage is ordered by recency and may start with yesterday, so it
// must never be used as a fallback for a metric explicitly labelled "今日里程".
const dailyMileage = vehicle.todayMileageAvailable === true ? vehicle.todayMileageKm : undefined;
const latestAlert = activeAlerts?.items[0];
const mileageLabel = vehicle.primaryProtocol === 'JT808' ? 'GPS 总里程' : '仪表盘总里程';
const encodedVin = encodeURIComponent(vehicle.vin);
const accessSource = detail?.profile?.accessProvider || vehicle.locationSource;
const workflowLinks = [
['单车详情', `/vehicles/${encodedVin}`],
['轨迹回放', `/tracks?vin=${encodedVin}`],
['历史数据', `/history?vin=${encodedVin}`],
['里程查询', `/statistics?vins=${encodedVin}`]
] as const;
const gbHighlights = (telemetry?.values ?? []).filter((item) => item.protocol === 'GB32960' && [
'fuel_cell_voltage_v', 'fuel_cell_current_a', 'hydrogen_consumption_kg_per_100km',
'hydrogen_concentration_percent', 'hydrogen_pressure_mpa', 'hydrogen_temperature_c',
'engine_speed_rpm', 'total_voltage_v', 'total_current_a'
].includes(item.key)).slice(0, 9);
return (
<Card className="v2-vehicle-detail" bodyStyle={{ padding: 0 }}>
<div className="v2-detail-body" role="region" aria-label={`${vehicle.plate || vehicle.vin}车辆详情`}>
<div className="v2-detail-shell-header">
<div className="v2-detail-controls">
<Button size="small" theme="light" type="tertiary" icon={<IconChevronRight />} aria-label="收起车辆详情" title="收起到地图右侧" onClick={onCollapse} />
<Button size="small" theme="light" type="danger" icon={<IconClose />} aria-label="取消选择车辆" title="取消选择车辆" onClick={onClear} />
</div>
<div className="v2-detail-title">
<div><strong>{vehicle.plate || '未绑定车牌'}</strong><Tag className={`v2-status-text is-${status}`} color={status === 'driving' ? 'blue' : status === 'idle' || status === 'online' ? 'green' : status === 'alert' ? 'red' : 'grey'} type="light" size="small">{statusLabel(status)}</Tag></div>
<small>{vehicle.vin}</small>
</div>
<nav className="v2-detail-actions" aria-label="车辆快捷操作">
{workflowLinks.map(([label, path]) => <Link key={label} to={withMonitorReturn(path, monitorReturn)}><span>{label}</span><IconChevronRight aria-hidden="true" /></Link>)}
</nav>
</div>
<section className="v2-detail-section v2-detail-report">
<WorkspacePanelHeader
variant="compact"
title="最新上报"
description="平台最新接收证据"
meta={<Tag color={vehicle.online ? 'green' : 'grey'} type="light" size="small">{relativeFreshness(vehicle.lastSeen)}</Tag>}
/>
<div className="v2-report-summary">
<span className="is-time" title={vehicle.lastSeen || '暂无上报'}><small></small><strong>{vehicle.lastSeen || '暂无上报'}</strong></span>
<span title={vehicle.primaryProtocol || '未知'}><small></small><b>{vehicle.primaryProtocol || '未知'}</b></span>
<span title={accessSource || '待补充'}><small></small><b>{accessSource || '待补充'}</b></span>
</div>
<dl className="v2-detail-list">
<div><dt></dt><dd><Button theme="borderless" type="tertiary" className="v2-detail-source-link" aria-label="查看全部位置来源" title="展开全部位置来源" onClick={() => setSourceEvidenceOpen(true)}>{hasRealtimeLocation(vehicle) ? `${vehicle.longitude.toFixed(6)}, ${vehicle.latitude.toFixed(6)}` : '—'}</Button></dd></div>
<div><dt></dt><dd>{hasRealtimeLocation(vehicle) ? address?.formattedAddress || '位置解析中' : '暂无实时位置'}</dd></div>
</dl>
</section>
<section className="v2-detail-section">
<WorkspacePanelHeader
variant="compact"
title="实时状态"
description={accessSource || '来源待补充'}
meta={<Tag color="blue" type="light" size="small">{vehicle.primaryProtocol || '未知协议'}</Tag>}
/>
<div className="v2-metric-grid">
<div className="is-speed"><small></small><strong>{hasRealtimeSpeed(vehicle) ? formatNumber(vehicle.speedKmh, 1) : '—'}<em>{hasRealtimeSpeed(vehicle) ? 'km/h' : ''}</em></strong></div>
<div className="is-soc"><small>SOC</small><strong>{hasRealtimeSOC(vehicle) ? formatNumber(vehicle.socPercent, 1) : '—'}<em>{hasRealtimeSOC(vehicle) ? '%' : ''}</em></strong></div>
<div className="is-mileage"><small>{mileageLabel}</small><Button theme="borderless" type="tertiary" className="v2-metric-source-link" aria-label="查看总里程全部来源" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}><span>{hasRealtimeMileage(vehicle) ? formatNumber(vehicle.totalMileageKm, 1) : '—'}<em>{hasRealtimeMileage(vehicle) ? 'km' : ''}</em></span><IconChevronRight /></Button></div>
<div className="is-today"><small></small><Button theme="borderless" type="tertiary" className="v2-metric-source-link" aria-label="查看今日里程全部来源" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}><span>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></span><IconChevronRight /></Button></div>
<div className="is-status"><small></small><strong>{statusLabel(status)}</strong></div>
<div className={`is-alert${(activeAlerts?.total ?? 0) > 0 ? ' has-alerts' : ''}`}><small></small><strong>{formatNumber(activeAlerts?.total ?? 0)}<em></em></strong></div>
</div>
{gbHighlights.length ? <div className="v2-gb-highlight-grid">{gbHighlights.map((item) => <div key={`${item.key}-${item.sourceField}`} title={item.description}><small>{item.label}</small><strong>{formatTelemetryValue(item.value, item.displayValue)}<em>{item.unit}</em></strong></div>)}</div> : null}
</section>
<section className="v2-detail-section">
<WorkspacePanelHeader
variant="compact"
title="车辆信息"
description="身份、车型与来源覆盖"
meta={<Tag color={vehicle.onlineSourceCount ? 'green' : 'grey'} type="light" size="small">{vehicle.onlineSourceCount}/{vehicle.sourceCount} 线</Tag>}
/>
<dl className="v2-detail-list">
<div><dt>VIN</dt><dd>{vehicle.vin}</dd></div>
<div><dt> / </dt><dd>{[detail?.profile?.brandName, detail?.profile?.modelName].filter(Boolean).join(' / ') || vehicle.oem || '待补充'}</dd></div>
<div><dt></dt><dd>{detail?.sources.join('、') || vehicle.protocols.join('、') || '未知'}</dd></div>
<div><dt></dt><dd>{detail?.profile?.accessProvider || '待补充'}</dd></div>
<div><dt></dt><dd>{vehicle.onlineSourceCount}/{vehicle.sourceCount}</dd></div>
<div><dt></dt><dd>{latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}</dd></div>
</dl>
</section>
<VehicleSourceEvidencePanel vin={vehicle.vin} compact open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
</div>
</Card>
);
}
export default function MonitorPage() {
const [routeParams, setRouteParams] = useSearchParams();
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 [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));
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 selectVehicle = useCallback((vin: string) => {
setSelectedVin(vin);
setDetailOpen(true);
setMode('map');
}, []);
const clearSelection = useCallback(() => {
setSelectedVin('');
setDetailOpen(false);
}, []);
const selectMapVehicle = useCallback((vehicle: VehicleRealtimeRow) => selectVehicle(vehicle.vin), [selectVehicle]);
const collapseDetail = useCallback(() => setDetailOpen(false), []);
const expandDetail = useCallback(() => setDetailOpen(true), []);
const changeMonitorMode = useCallback((nextMode: 'map' | 'list') => {
setMode(nextMode);
if (nextMode === 'list') setDetailOpen(false);
}, []);
const clearFilters = useCallback(() => {
setKeyword('');
setProtocol('');
setStatus('');
setListOffset(0);
}, []);
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 viewportLoad = mode === 'map'
? (map.data?.clusters.length
? `${map.data.clusters.length} 个聚合 · ${map.data.points.length} 个车辆点`
: `${map.data?.points.length ?? 0} 个车辆点`)
: `${realtimeListQuery.data?.items.length ?? 0} / ${realtimeListQuery.data?.total ?? 0}`;
const lastSyncAt = vehicles.dataUpdatedAt ? new Date(vehicles.dataUpdatedAt) : undefined;
return (
<div className="v2-monitor-page">
<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); }}
onPaste={(event) => {
const pastedTerms = parseMonitorSearchTerms(event.clipboardData.getData('text'));
if (pastedTerms.length <= 1) return;
event.preventDefault();
setKeyword(pastedTerms.join(''));
setListOffset(0);
}}
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); }} 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) => { setStatus(String(value)); setListOffset(0); }} 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"
/>
<Button className="v2-monitor-mobile-entry" theme="light" icon={<IconQrCode />} aria-label="打开手机端入口" onClick={() => setMobileEntryOpen(true)}><span className="v2-monitor-mobile-entry-label"></span></Button>
</div>
</Card>
{batchSearchOpen ? <BatchVehicleSearchDialog initialValue={searchTerms.join('\n')} onClose={() => setBatchSearchOpen(false)} onApply={(value) => { setKeyword(value); setListOffset(0); setBatchSearchOpen(false); }} /> : null}
<Card className="v2-kpis" bodyStyle={{ padding: 0 }} aria-label="车辆整体统计">
{[
{ label: '车辆总数', value: formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), tone: 'fleet', priority: 'primary', unit: '辆' },
{ label: '当前在线', value: formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), tone: 'online', priority: 'primary', unit: '辆' },
{ label: '当前离线', value: formatNumber(summary.data?.offlineVehicles ?? offline), tone: 'offline', priority: 'standard', unit: '辆' },
{ label: '行驶车辆', value: formatNumber(summary.data?.drivingVehicles ?? driving), tone: 'driving', priority: 'standard', unit: '辆' },
{ label: '静止车辆', value: formatNumber(summary.data?.idleVehicles ?? idle), tone: 'idle', priority: 'standard', unit: '辆' },
{ label: '告警车辆', value: summary.data?.alertDataAvailable ? formatNumber(summary.data.alertVehicles) : '—', tone: 'alert', priority: 'standard', unit: '辆' },
{ label: '今日上报', value: formatNumber(summary.data?.frameToday ?? 0), tone: 'today', priority: 'support', unit: '条' },
{ label: '无实时位置', value: formatNumber(summary.data?.noLocationVehicles ?? 0), tone: 'missing', priority: 'support', unit: '辆' }
].map(({ label, value, tone, priority, unit }) => <div key={label} className={`v2-kpi is-${tone} is-${priority}`}><small>{label}</small><strong>{value}<em>{unit}</em></strong></div>)}
</Card>
{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' ? <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 className={`v2-rail-search${batchMatch.missing.length && !batchSearchPending ? ' has-missing' : ''}`} title={searchTerms.length > 1 ? batchStatusTitle : undefined}><IconSearch /><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 && !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}
/>
{selected && detailOpen ? (
<VehicleDetailCard
vehicle={selected}
monitorReturn={monitorReturn}
onCollapse={collapseDetail}
onClear={clearSelection}
/>
) : 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} mobile={mobileLayout} onSelect={selectVehicle} onPage={(page) => setListOffset((page - 1) * listLimit)} onLimit={(next) => { 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"><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 onClose={() => setMobileEntryOpen(false)} /> : null}
</div>
);
}