339 lines
24 KiB
TypeScript
339 lines
24 KiB
TypeScript
import { IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin, IconQrCode, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import QRCode from 'qrcode';
|
||
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import { api } from '../../api/client';
|
||
import type { AlertEvent, MapReverseGeocode, VehicleDetail as VehicleDetailData, VehicleRealtimeRow } from '../../api/types';
|
||
import { FleetMap } from '../map/FleetMap';
|
||
import { EmptyState, InlineError } from '../shared/AsyncState';
|
||
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
|
||
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
|
||
|
||
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
|
||
const statuses = ['', 'online', 'offline', 'driving', 'idle'];
|
||
const EMPTY_VEHICLES: VehicleRealtimeRow[] = [];
|
||
|
||
type AddressCoordinate = { longitude: number; latitude: number; key: string };
|
||
|
||
function addressCoordinate(vehicle: VehicleRealtimeRow): AddressCoordinate | undefined {
|
||
if (!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: () => api.reverseGeocode(new URLSearchParams({
|
||
longitude: requested!.longitude.toFixed(4),
|
||
latitude: requested!.latitude.toFixed(4)
|
||
})),
|
||
enabled: Boolean(requested),
|
||
staleTime: 6 * 60 * 60_000,
|
||
gcTime: 24 * 60 * 60_000,
|
||
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 type="button" className="v2-monitor-address-action" aria-label={`解析${vehicle.plate || vehicle.vin}位置`} title="按需调用高德逆地理编码,不随实时数据刷新重复请求" onClick={() => setRequested(current)}><IconMapPin />解析位置</button>;
|
||
if (addressQuery.isFetching && !addressQuery.data) return <span className="v2-monitor-address-loading"><i className="v2-spinner" />解析中</span>;
|
||
if (addressQuery.isError) return <button type="button" className="v2-monitor-address-action is-error" onClick={() => void addressQuery.refetch()}>解析失败,重试</button>;
|
||
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span>{moved ? <button type="button" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}>位置已移动 · 更新</button> : null}</div>;
|
||
});
|
||
|
||
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
|
||
return <section className="v2-monitor-table-panel">
|
||
<header><div><strong>车辆实时列表</strong><span>速度、里程和坐标实时刷新,文字地址按需解析</span></div><b>{total.toLocaleString('zh-CN')} 辆车辆</b></header>
|
||
<div className="v2-monitor-table-scroll"><table><thead><tr><th>车辆</th><th>速度</th><th>总里程</th><th>经纬度</th><th>地理位置</th></tr></thead>
|
||
<tbody>{rows.map((row) => <tr key={row.vin}>
|
||
<td className="v2-monitor-table-vehicle"><button type="button" title="在地图中定位" onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></button></td>
|
||
<td><strong className="v2-monitor-live-value">{formatNumber(row.speedKmh, 1)}</strong><small className="v2-monitor-live-unit">km/h</small></td>
|
||
<td><strong className="v2-monitor-live-value">{formatNumber(row.totalMileageKm, 1)}</strong><small className="v2-monitor-live-unit">km</small></td>
|
||
<td><code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code></td>
|
||
<td><MonitorAddressCell vehicle={row} /></td>
|
||
</tr>)}</tbody></table>{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" />正在更新车辆实时数据…</div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div>
|
||
<div className="v2-monitor-mobile-cards">{rows.map((row) => <article key={row.vin}>
|
||
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><b>{formatNumber(row.speedKmh, 1)}<small>km/h</small></b></header>
|
||
<dl><div><dt>总里程</dt><dd>{formatNumber(row.totalMileageKm, 1)} km</dd></div><div><dt>经纬度</dt><dd><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>
|
||
<footer><button type="button" onClick={() => onSelect(row.vin)}><IconMapPin />地图定位</button></footer>
|
||
</article>)}{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" />正在更新车辆实时数据…</div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div>
|
||
<footer><span>第 {page} / {totalPages} 页</span><div><button type="button" disabled={page <= 1} onClick={() => onPage(page - 1)}>上一页</button><button type="button" disabled={page >= totalPages} onClick={() => onPage(page + 1)}>下一页</button><select aria-label="每页车辆数" value={limit} onChange={(event) => onLimit(Number(event.target.value))}><option value="20">20 辆/页</option><option value="50">50 辆/页</option><option value="100">100 辆/页</option></select></div></footer>
|
||
</section>;
|
||
}
|
||
|
||
function MobileEntry({ onClose }: { onClose: () => void }) {
|
||
const [qr, setQr] = useState('');
|
||
const url = `${window.location.origin}/monitor`;
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
void QRCode.toDataURL(url, { width: 240, margin: 2, color: { dark: '#122033', light: '#ffffff' } }).then((value) => { if (!cancelled) setQr(value); });
|
||
return () => { cancelled = true; };
|
||
}, [url]);
|
||
return <div className="v2-monitor-qr-backdrop" role="dialog" aria-modal="true" aria-label="手机端入口"><section><button type="button" onClick={onClose} aria-label="关闭手机端入口"><IconClose /></button><IconQrCode /><h3>手机端全局监控</h3><p>扫码后使用现有账号鉴权,不在二维码中保存 Token</p>{qr ? <img src={qr} alt="全局监控手机端二维码" /> : <span className="v2-spinner" />}<code>{url}</code><button type="button" onClick={() => void navigator.clipboard?.writeText(url)}>复制访问地址</button></section></div>;
|
||
}
|
||
|
||
const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { vehicle: VehicleRealtimeRow; selected: boolean; onSelect: (vin: string) => void }) {
|
||
const status = vehicleStatus(vehicle);
|
||
return (
|
||
<button type="button" 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,
|
||
detail,
|
||
activeAlerts,
|
||
address,
|
||
onCollapse,
|
||
onClear
|
||
}: {
|
||
vehicle: VehicleRealtimeRow;
|
||
detail?: VehicleDetailData;
|
||
activeAlerts?: { items: AlertEvent[]; total: number };
|
||
address?: MapReverseGeocode;
|
||
onCollapse: () => void;
|
||
onClear: () => void;
|
||
}) {
|
||
const status = vehicleStatus(vehicle);
|
||
const dailyMileage = detail?.mileage.items[0]?.dailyMileageKm;
|
||
const latestAlert = activeAlerts?.items[0];
|
||
return (
|
||
<aside className="v2-vehicle-detail">
|
||
<div className="v2-detail-controls">
|
||
<button type="button" aria-label="收起车辆详情" title="收起到地图右侧" onClick={onCollapse}><IconChevronRight /></button>
|
||
<button type="button" aria-label="取消选择车辆" title="取消选择车辆" onClick={onClear}><IconClose /></button>
|
||
</div>
|
||
<div className="v2-detail-title">
|
||
<div><strong>{vehicle.plate || '未绑定车牌'}</strong><span className={`v2-status-text is-${status}`}>{statusLabel(status)}</span></div>
|
||
<small>{vehicle.vin}</small>
|
||
</div>
|
||
<div className="v2-detail-actions">
|
||
<Link to={`/vehicles/${encodeURIComponent(vehicle.vin)}`}>单车详情</Link>
|
||
<Link to={`/tracks?vin=${encodeURIComponent(vehicle.vin)}`}>轨迹回放</Link>
|
||
<Link to={`/history?vin=${encodeURIComponent(vehicle.vin)}`}>历史数据</Link>
|
||
</div>
|
||
<section>
|
||
<h3>车辆信息</h3>
|
||
<dl className="v2-detail-list">
|
||
<div><dt>VIN</dt><dd>{vehicle.vin}</dd></div>
|
||
<div><dt>厂家</dt><dd>{vehicle.oem || '待补充'}</dd></div>
|
||
<div><dt>协议</dt><dd>{vehicle.primaryProtocol || vehicle.protocols.join('、') || '未知'}</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>
|
||
</dl>
|
||
</section>
|
||
<section>
|
||
<h3>实时状态</h3>
|
||
<div className="v2-metric-grid">
|
||
<div><small>速度</small><strong>{formatNumber(vehicle.speedKmh, 1)}<em>km/h</em></strong></div>
|
||
<div><small>SOC</small><strong>{formatNumber(vehicle.socPercent, 1)}<em>%</em></strong></div>
|
||
<div><small>总里程</small><strong>{formatNumber(vehicle.totalMileageKm, 1)}<em>km</em></strong></div>
|
||
<div><small>今日里程</small><strong>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></strong></div>
|
||
<div><small>状态</small><strong>{statusLabel(status)}</strong></div>
|
||
<div><small>当前告警</small><strong>{formatNumber(activeAlerts?.total ?? 0)}<em>条</em></strong></div>
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<h3>最新上报</h3>
|
||
<dl className="v2-detail-list">
|
||
<div><dt>时间</dt><dd>{vehicle.lastSeen || '暂无'}</dd></div>
|
||
<div><dt>新鲜度</dt><dd>{relativeFreshness(vehicle.lastSeen)}</dd></div>
|
||
<div><dt>坐标</dt><dd>{vehicle.longitude.toFixed(6)}, {vehicle.latitude.toFixed(6)}</dd></div>
|
||
<div><dt>位置</dt><dd>{address?.formattedAddress || '位置解析中'}</dd></div>
|
||
<div><dt>告警状态</dt><dd>{latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}</dd></div>
|
||
</dl>
|
||
</section>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
export default function MonitorPage() {
|
||
const [mode, setMode] = useState<'map' | 'list'>('map');
|
||
const [mobileEntryOpen, setMobileEntryOpen] = useState(false);
|
||
const [listOffset, setListOffset] = useState(0);
|
||
const [listLimit, setListLimit] = useState(50);
|
||
const [keyword, setKeyword] = useState('');
|
||
const deferredKeyword = useDeferredValue(keyword);
|
||
const searchTerms = useMemo(() => parseMonitorSearchTerms(keyword), [keyword]);
|
||
const [protocol, setProtocol] = useState('');
|
||
const [status, setStatus] = useState('');
|
||
const [selectedVin, setSelectedVin] = useState('');
|
||
const [detailOpen, setDetailOpen] = useState(false);
|
||
const [viewport, setViewport] = useState<MonitorViewport>({ zoom: 5, bounds: '' });
|
||
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 listParams = useMemo(() => {
|
||
const params = monitorQueryParams(filters, listLimit);
|
||
params.set('offset', String(listOffset));
|
||
return params;
|
||
}, [filters, listLimit, listOffset]);
|
||
const { summary, vehicles, map, selectedVehicle } = useMonitorData(filters, viewport, selectedVin, mode === 'map', mode === 'map');
|
||
const realtimeListQuery = useQuery({
|
||
queryKey: ['monitor', 'vehicle-list', listParams.toString()],
|
||
queryFn: ({ signal }) => api.vehicleRealtime(listParams, signal),
|
||
enabled: mode === 'list', placeholderData: (previous) => previous, refetchInterval: mode === 'list' ? MONITOR_REFRESH.fleet : false
|
||
});
|
||
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 batchSearchPending = searchTerms.length > 1 && (
|
||
keyword !== deferredKeyword || activeBatchQuery.isLoading || activeBatchQuery.isPlaceholderData
|
||
);
|
||
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 = batchSearchPending ? [] : 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 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 card = useMonitorVehicleCard(selected?.vin ?? '', selected, Boolean(selectedVin));
|
||
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;
|
||
|
||
return (
|
||
<div className="v2-monitor-page">
|
||
<section className="v2-filterbar" aria-label="车辆筛选">
|
||
<label className={`v2-search-field${searchTerms.length > 1 ? ' is-batch' : ''}`}>
|
||
<IconSearch />
|
||
<input
|
||
aria-label="搜索车辆"
|
||
value={keyword}
|
||
onChange={(event) => { setKeyword(event.target.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;可批量粘贴车牌"
|
||
/>
|
||
{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}
|
||
</label>
|
||
<select value={protocol} onChange={(event) => { setProtocol(event.target.value); setListOffset(0); }} aria-label="协议">
|
||
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
|
||
</select>
|
||
<select value={status} onChange={(event) => { setStatus(event.target.value); setListOffset(0); }} aria-label="在线状态">
|
||
{statuses.map((item) => <option key={item} value={item}>{item ? statusLabel(item as never) : '全部状态'}</option>)}
|
||
</select>
|
||
<button type="button" className="v2-secondary-button" onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); setListOffset(0); }}><IconRefresh />清空</button>
|
||
<button type="button" className="v2-primary-button"><IconFilter />筛选</button>
|
||
<div className="v2-monitor-mode" aria-label="监控视图"><button type="button" className={mode === 'map' ? 'is-active' : ''} onClick={() => setMode('map')}><IconMapPin />地图</button><button type="button" className={mode === 'list' ? 'is-active' : ''} onClick={() => { setMode('list'); setDetailOpen(false); }}><IconList />列表</button></div>
|
||
<button type="button" className="v2-monitor-mobile-entry" onClick={() => setMobileEntryOpen(true)}><IconQrCode />手机端</button>
|
||
</section>
|
||
|
||
<section className="v2-kpis" aria-label="车辆整体统计">
|
||
{[
|
||
['有实时位置', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'],
|
||
['当前在线', formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), 'online'],
|
||
['当前离线', formatNumber(summary.data?.offlineVehicles ?? offline), 'offline'],
|
||
['行驶车辆', formatNumber(summary.data?.drivingVehicles ?? driving), 'driving'],
|
||
['静止车辆', formatNumber(summary.data?.idleVehicles ?? idle), 'idle'],
|
||
['告警车辆', summary.data?.alertDataAvailable ? formatNumber(summary.data.alertVehicles) : '—', 'alert'],
|
||
['今日上报', formatNumber(summary.data?.frameToday ?? 0), 'today']
|
||
].map(([label, value, tone]) => <div key={label} className={`v2-kpi is-${tone}`}><small>{label}</small><strong>{value}</strong></div>)}
|
||
</section>
|
||
|
||
{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={searchTerms.length > 1 && (batchSearchPending || map.isPlaceholderData) ? undefined : map.data}
|
||
selectedVin={selectedVin || undefined}
|
||
onSelect={selectMapVehicle}
|
||
onSelectVin={selectVehicle}
|
||
onViewportChange={updateViewport}
|
||
/>
|
||
{selected && detailOpen ? (
|
||
<VehicleDetailCard
|
||
vehicle={selected}
|
||
detail={card.detail.data}
|
||
activeAlerts={card.activeAlerts.data}
|
||
address={card.address.data}
|
||
onCollapse={collapseDetail}
|
||
onClear={clearSelection}
|
||
/>
|
||
) : null}
|
||
{selected && !detailOpen ? (
|
||
<aside className="v2-detail-peek" aria-label="已收起的车辆详情">
|
||
<button type="button" aria-label="展开车辆详情" title={`展开 ${selected.plate || selected.vin} 的车辆详情`} onClick={expandDetail}>
|
||
<IconChevronLeft />
|
||
<i className={`v2-status-dot is-${vehicleStatus(selected)}`} />
|
||
<span>{selected.plate || '未绑定车牌'}</span>
|
||
</button>
|
||
</aside>
|
||
) : null}
|
||
</section> : <MonitorVehicleTable rows={realtimeListQuery.data?.items ?? []} total={realtimeListQuery.data?.total ?? 0} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil((realtimeListQuery.data?.total ?? 0) / listLimit))} limit={listLimit} loading={realtimeListQuery.isFetching} onSelect={selectVehicle} onPage={(page) => setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
|
||
|
||
<section className="v2-event-strip">
|
||
<strong>实时数据状态</strong>
|
||
<span><i className="is-online" />{vehicles.isFetching ? '正在刷新' : '实时车辆已同步'}</span>
|
||
<span>{mode === 'map' ? `列表 ${visibleRows.length} 条 · 地图 ${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} 辆`}</span>
|
||
<span className="v2-refresh-cadence"><b>智能刷新</b> 重点车辆 {MONITOR_REFRESH.selected / 1000} 秒 · 车队 {MONITOR_REFRESH.fleet / 1000} 秒 · 统计 {MONITOR_REFRESH.summary / 1000} 秒</span>
|
||
<time>{new Date().toLocaleString('zh-CN', { hour12: false })}</time>
|
||
</section>
|
||
{mobileEntryOpen ? <MobileEntry onClose={() => setMobileEntryOpen(false)} /> : null}
|
||
</div>
|
||
);
|
||
}
|