refine Semi UI monitor inspector
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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"');
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user