refine Semi UI monitor inspector

This commit is contained in:
lingniu
2026-07-18 21:53:35 +08:00
parent 911addd891
commit e40eabac92
5 changed files with 231 additions and 148 deletions

View File

@@ -87,7 +87,7 @@ afterEach(() => {
vi.restoreAllMocks();
});
test('never labels the latest historical mileage row as today when today has no mileage evidence', () => {
test('never labels the latest historical mileage row as today when today has no mileage evidence', async () => {
vehicleCardFixture.detail = {
sources: ['JT808'],
sourceStatus: [],
@@ -103,12 +103,12 @@ test('never labels the latest historical mileage row as today when today has no
fireEvent.click(screen.getByRole('button', { name: /粤B67890 LTEST000000000002/ }));
const todayMetric = screen.getByText('今日里程').parentElement;
const todayMetric = (await screen.findByText('今日里程')).parentElement;
expect(todayMetric).toHaveTextContent('今日里程—');
expect(todayMetric).not.toHaveTextContent('134');
});
test('starts without a selection and supports expand, collapse, reselection, and clear', () => {
test('starts without a selection and supports expand, collapse, reselection, and clear', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
const workspace = view.container.querySelector('.v2-monitor-workspace')!;
@@ -147,6 +147,7 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(screen.queryByText('接入车辆')).not.toBeInTheDocument();
fireEvent.click(firstVehicle);
expect(await screen.findByRole('region', { name: '粤A12345车辆详情' })).toBeInTheDocument();
expect(workspace).toHaveClass('is-detail-open');
expect(firstVehicle).toHaveClass('is-selected');
expect(screen.getByRole('button', { name: '取消选择车辆' })).toBeInTheDocument();
@@ -159,7 +160,13 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(screen.getByText('身份、车型与来源覆盖')).toBeInTheDocument();
expect(screen.getByText('1/1 在线').closest('.semi-tag')).toBeInTheDocument();
expect(view.container.querySelector('.v2-report-summary')).toHaveTextContent('平台接收时间');
expect(view.container.querySelector('.v2-report-summary')).toHaveTextContent('主协议JT808');
expect(view.container.querySelector('.v2-report-summary')).toHaveTextContent('主协议JT/T 808');
expect(view.container.querySelector('.v2-report-summary .v2-protocol-tag')).toHaveClass('semi-tag-cyan-light');
expect(view.container.querySelectorAll('.v2-detail-actions a')).toHaveLength(4);
expect(Array.from(view.container.querySelectorAll('.v2-detail-actions a')).map((item) => item.textContent)).toEqual([
'单车详情', '轨迹回放', '历史数据', '里程查询'
]);
expect(view.container.querySelector('.v2-detail-protocols .v2-protocol-tag')).toHaveTextContent('JT/T 808');
expect(view.container.querySelector('.v2-metric-grid > div:first-child')).toHaveTextContent('速度42');
expect(view.container.querySelector('.v2-metric-grid > div:nth-child(4)')).toHaveTextContent('今日里程18.6km');
expect(view.container.querySelector('.v2-status-text')).toHaveClass('semi-tag');
@@ -196,7 +203,7 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(secondVehicle).toHaveClass('is-selected');
});
test('restores URL-backed monitor context and carries it into every vehicle workflow', () => {
test('restores URL-backed monitor context and carries it into every vehicle workflow', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const entry = '/monitor?keyword=%E7%B2%A4A12345&protocol=JT808&status=online&selectedVin=LTEST000000000001&detail=collapsed&zoom=13&bounds=113.100000%2C23.000000%2C113.400000%2C23.300000';
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[entry]}><MonitorPage /></MemoryRouter></QueryClientProvider>);
@@ -209,6 +216,7 @@ test('restores URL-backed monitor context and carries it into every vehicle work
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-initial-bounds', '113.100000,23.000000,113.400000,23.300000');
fireEvent.click(screen.getByRole('button', { name: '展开车辆详情' }));
expect(await screen.findByRole('navigation', { name: '车辆快捷操作' })).toBeInTheDocument();
for (const name of ['单车详情', '轨迹回放', '历史数据', '里程查询']) {
const target = new URL(screen.getByRole('link', { name }).getAttribute('href')!, 'https://vehicle-platform.invalid');
const monitorReturn = target.searchParams.get('monitorReturn');
@@ -261,7 +269,8 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
expect(screen.getAllByText('42')).toHaveLength(1);
expect(screen.getAllByText(/18\.6/)).toHaveLength(1);
expect(screen.getAllByText(/1,234/)).toHaveLength(1);
expect(screen.getByText('JT808')).toBeInTheDocument();
expect(screen.getByText('JT/T 808')).toBeInTheDocument();
expect(view.container.querySelector('.v2-monitor-protocol .v2-protocol-tag')).toHaveClass('semi-tag-cyan-light');
expect(screen.getAllByText(/113\.260000/)).toHaveLength(1);
expect(screen.queryByText('推荐来源')).not.toBeInTheDocument();
expect(screen.getByText('当日里程')).toBeInTheDocument();

View File

@@ -1,25 +1,24 @@
import {
IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin,
IconChevronLeft, 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 { lazy, memo, Suspense, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { 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 { ProtocolTag } from '../shared/ProtocolTag';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
import { formatTelemetryValue } from '../domain/telemetry';
import { formatNumber, statusLabel, vehicleStatus } from '../domain/monitor';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
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';
import { buildMonitorPath, parseMonitorRouteContext } from '../routing/monitorContext';
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
const statuses = ['', 'online', 'offline', 'driving', 'idle', 'no_location'];
@@ -28,6 +27,7 @@ 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'));
function BatchVehicleSearchDialog({ initialValue, onApply, onClose }: { initialValue: string; onApply: (value: string) => void; onClose: () => void }) {
const [draft, setDraft] = useState(initialValue);
@@ -116,7 +116,7 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, mo
{ 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: 'primaryProtocol', width: 150, render: (_value: string, row: VehicleRealtimeRow) => <div className="v2-monitor-protocol"><ProtocolTag protocol={row.primaryProtocol} compact unknownLabel="未知协议" />{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]);
@@ -129,7 +129,7 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, mo
{!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>
<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"><ProtocolTag protocol={row.primaryProtocol} compact unknownLabel="未知协议" />{row.protocols.length > 1 ? <small>{row.protocols.length} </small> : null}</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>;
@@ -202,115 +202,6 @@ const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { v
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));
@@ -520,12 +411,14 @@ export default function MonitorPage() {
initialViewport={initialContext.hasViewport ? initialContext.viewport : undefined}
/>
{selected && detailOpen ? (
<VehicleDetailCard
vehicle={selected}
monitorReturn={monitorReturn}
onCollapse={collapseDetail}
onClear={clearSelection}
/>
<Suspense fallback={<Card className="v2-vehicle-detail v2-detail-loading-card" bodyStyle={{ padding: 0 }}><div role="status"><Spin size="small" /></div></Card>}>
<VehicleDetailCard
vehicle={selected}
monitorReturn={monitorReturn}
onCollapse={collapseDetail}
onClear={clearSelection}
/>
</Suspense>
) : null}
{selected && !detailOpen ? (
<Card className="v2-detail-peek" bodyStyle={{ padding: 0 }}>

View File

@@ -0,0 +1,142 @@
import { IconChevronRight, IconClose } from '@douyinfe/semi-icons';
import { Button, Card, Tag } from '@douyinfe/semi-ui';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import type { VehicleRealtimeRow } from '../../api/types';
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
import { protocolSourceLabel } from '../domain/protocols';
import { formatTelemetryValue } from '../domain/telemetry';
import { useMonitorVehicleCard } from '../hooks/useMonitorData';
import { withMonitorReturn } from '../routing/monitorContext';
import { ProtocolTag } from '../shared/ProtocolTag';
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
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 hasRealtimeSOC(vehicle: VehicleRealtimeRow) {
return vehicle.socAvailable ?? (vehicle.primaryProtocol === 'GB32960' || vehicle.primaryProtocol === 'YUTONG_MQTT');
}
export default function MonitorVehicleDetailCard({
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 accessSourceLabel = protocolSourceLabel(accessSource) || '来源待补充';
const availableProtocols = detail?.sources?.length ? detail.sources : vehicle.protocols;
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 className="is-protocol" title={vehicle.primaryProtocol || '未知协议'}><small></small><ProtocolTag protocol={vehicle.primaryProtocol} compact unknownLabel="未知协议" /></span>
<span className="is-source" title={accessSource || '来源待补充'}><small></small><b>{accessSourceLabel}</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={accessSourceLabel}
meta={<ProtocolTag protocol={vehicle.primaryProtocol} compact unknownLabel="未知协议" />}
/>
<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 className="v2-detail-protocols">{availableProtocols.length ? availableProtocols.map((protocol) => <ProtocolTag key={protocol} protocol={protocol} compact />) : <ProtocolTag compact unknownLabel="未知协议" />}</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>;
}

View File

@@ -12,6 +12,7 @@ const corePageSources = Object.fromEntries(['MonitorPage', 'VehiclePage', 'Track
readFileSync(resolve(process.cwd(), `src/v2/pages/${name}.tsx`), 'utf8')
]));
const vehicleSearchSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleSearchWorkspace.tsx'), 'utf8');
const monitorVehicleDetailSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/MonitorVehicleDetailCard.tsx'), 'utf8');
const reconciliationSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/ReconciliationCenter.tsx'), 'utf8');
const profileSyncPanelSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleProfileSyncPanel.tsx'), 'utf8');
const sourceEvidenceSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/VehicleSourceEvidencePanel.tsx'), 'utf8');
@@ -103,10 +104,11 @@ describe('V2 production entry', () => {
expect(profileSyncPanelSource).not.toContain('<input');
expect(profileSyncPanelSource).not.toContain('<section className="v2-profile-sync-panel"');
expect(vehicleSearchSource).toContain('v2-profile-sync-panel');
expect(corePageSources.MonitorPage).toContain('<Card className="v2-vehicle-detail"');
expect(corePageSources.MonitorPage).toContain("lazy(() => import('./MonitorVehicleDetailCard'))");
expect(monitorVehicleDetailSource).toContain('<Card className="v2-vehicle-detail"');
expect(corePageSources.MonitorPage).toContain('<Card className="v2-detail-peek"');
expect(corePageSources.MonitorPage).toContain('<Card className="v2-event-strip"');
expect(corePageSources.MonitorPage).toContain('<Tag className={`v2-status-text');
expect(monitorVehicleDetailSource).toContain('<Tag className={`v2-status-text');
expect(corePageSources.MonitorPage).toContain('<Tag className="v2-monitor-live-tag"');
expect(corePageSources.MonitorPage).not.toContain('<aside className="v2-vehicle-detail"');
expect(corePageSources.MonitorPage).not.toContain('<aside className="v2-detail-peek"');

View File

@@ -9013,6 +9013,19 @@ button, a { -webkit-tap-highlight-color: transparent; }
min-width: 0;
min-height: 100%;
}
.v2-detail-loading-card > .semi-card-body {
display: grid;
height: 100%;
min-height: 220px;
place-items: center;
}
.v2-detail-loading-card [role="status"] {
display: flex;
align-items: center;
gap: 8px;
color: #718197;
font-size: 11px;
}
.v2-detail-shell-header {
position: sticky;
z-index: 5;
@@ -9054,19 +9067,27 @@ button, a { -webkit-tap-highlight-color: transparent; }
font-size: 10px;
}
.v2-detail-actions {
grid-template-columns: repeat(4,minmax(0,1fr));
gap: 4px;
grid-template-columns: repeat(2,minmax(0,1fr));
gap: 6px;
padding: 0;
}
.v2-detail-actions a {
height: 38px;
height: 40px;
min-width: 0;
gap: 5px;
justify-content: space-between;
gap: 8px;
border-color: #e1e8f1;
border-radius: 8px;
background: #fff;
padding-inline: 11px;
color: #55667c;
font-size: 10px;
font-size: 11px;
}
.v2-detail-actions a > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-detail-actions a > svg {
flex: 0 0 auto;
@@ -9127,7 +9148,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
}
.v2-report-summary {
display: grid;
grid-template-columns: minmax(0,1.45fr) minmax(0,.8fr) minmax(0,1fr);
grid-template-columns: minmax(0,.82fr) minmax(0,1.18fr);
gap: 0;
overflow: hidden;
margin: 9px 10px 0;
@@ -9139,7 +9160,11 @@ button, a { -webkit-tap-highlight-color: transparent; }
min-width: 0;
padding: 8px 9px;
}
.v2-report-summary > span + span { border-left: 1px solid #e3eaf3; }
.v2-report-summary > .is-time {
grid-column: 1 / -1;
border-bottom: 1px solid #e3eaf3;
}
.v2-report-summary > .is-source { border-left: 1px solid #e3eaf3; }
.v2-report-summary small,
.v2-report-summary strong,
.v2-report-summary b {
@@ -9152,10 +9177,11 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-report-summary strong {
margin-top: 4px;
color: #153f73;
font-size: 12px;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.v2-report-summary b { margin-top: 4px; color: #345679; font-size: 10px; }
.v2-report-summary .v2-protocol-tag { max-width: 100%; margin-top: 4px; }
.v2-detail-report .v2-detail-list { margin: 5px 10px 8px; }
.v2-detail-report .v2-detail-list > div { min-height: 24px; grid-template-columns: 44px minmax(0,1fr); align-items: center; padding-block: 3px; }
.v2-detail-report .v2-detail-list dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -9205,6 +9231,13 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-detail-section > .v2-detail-list > div:last-child {
border-bottom: 0;
}
.v2-detail-protocols {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.v2-detail-protocols .v2-protocol-tag { max-width: 100%; }
@media (max-width: 760px) {
.v2-detail-shell-header {
@@ -9217,12 +9250,8 @@ button, a { -webkit-tap-highlight-color: transparent; }
right: 15px;
}
.v2-detail-actions {
overflow-x: auto;
grid-template-columns: repeat(4,minmax(92px,1fr));
overscroll-behavior-x: contain;
scrollbar-width: none;
grid-template-columns: repeat(2,minmax(0,1fr));
}
.v2-detail-actions::-webkit-scrollbar { display: none; }
.v2-detail-actions a {
height: 42px;
font-size: 11px;
@@ -9238,7 +9267,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-detail-section > .v2-workspace-panel-header .v2-workspace-panel-meta {
font-size: 10px;
}
.v2-report-summary { grid-template-columns: minmax(0,1.35fr) minmax(0,.8fr) minmax(0,1fr); }
.v2-report-summary { grid-template-columns: minmax(0,.82fr) minmax(0,1.18fr); }
.v2-report-summary strong { font-size: 13px; }
.v2-detail-list > div { font-size: 10px; }
.v2-metric-grid small { font-size: 9px; }
@@ -9361,9 +9390,17 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-monitor-mobile-cards dd code { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-monitor-mobile-cards dd.is-today,
.v2-monitor-mobile-protocol { color: #1268df; font-weight: 750; }
.v2-monitor-mobile-protocol {
display: inline-flex;
max-width: 100%;
align-items: center;
justify-content: flex-end;
gap: 5px;
}
.v2-monitor-mobile-protocol > small { color: #77869a; font-size: 9px; white-space: nowrap; }
.v2-monitor-mobile-cards > .v2-monitor-mobile-card.semi-card { contain-intrinsic-size: auto 190px; }
.v2-report-summary {
grid-template-columns: minmax(0,1.3fr) minmax(0,.85fr) minmax(0,1fr);
grid-template-columns: minmax(0,.82fr) minmax(0,1.18fr);
}
.v2-report-summary strong { font-size: 13px; }
.v2-report-summary b { font-size: 11px; }