From b54d5ba077a1a3358af0841b10722c4758d6ea3e Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 18 Jul 2026 07:00:07 +0800 Subject: [PATCH] refine Semi UI telemetry evidence --- .../web/src/v2/pages/VehiclePage.test.tsx | 15 ++- .../apps/web/src/v2/pages/VehiclePage.tsx | 118 +++++++++++++++- .../apps/web/src/v2/styles/v2.css | 126 ++++++++++++++++++ 3 files changed, 252 insertions(+), 7 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx index 2a0722aa..c8285c09 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx @@ -356,13 +356,18 @@ test('keeps protocol telemetry independent and explains mileage semantics', asyn vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [{ ...initialRealtime, protocols: ['GB32960', 'JT808'] }], total: 1, limit: 1, offset: 0 }); vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ vin: initialRealtime.vin, - categories: [{ key: 'vehicle', label: '整车数据', count: 2 }], + categories: [{ key: 'vehicle', label: '整车数据', count: 3 }], values: [ { key: 'total_mileage_km', sourceField: 'gb32960.vehicle.total_mileage_km', label: '仪表盘总里程', unit: 'km', category: 'vehicle', valueType: 'numeric', value: 1200, protocol: 'GB32960', frameId: 'gb', deviceTime: '', serverTime: '2026-07-16 10:00:00', quality: 'good', qualityReason: '正常', freshnessSeconds: 1 }, + { + key: 'cell_voltages', sourceField: 'gb32960.extended.cell_voltages', label: '单体电压列表', unit: 'V', + category: 'vehicle', valueType: 'array', value: [3.21, 3.22, 3.23, 3.24], protocol: 'GB32960', frameId: 'gb-array', deviceTime: '', serverTime: '2026-07-16 10:00:00', + quality: 'good', qualityReason: '正常', freshnessSeconds: 1, sourceEndpoint: '10.0.0.1:8080' + }, { key: 'total_mileage_km', sourceField: 'jt808.location.total_mileage_km', label: 'GPS 总里程', unit: 'km', category: 'vehicle', valueType: 'numeric', value: 1180, protocol: 'JT808', frameId: 'jt', deviceTime: '', serverTime: '2026-07-16 10:00:00', @@ -383,6 +388,14 @@ test('keeps protocol telemetry independent and explains mileage semantics', asyn expect(document.querySelector('.v2-event-empty.semi-empty')).toBeInTheDocument(); expect(screen.getByText('仪表盘总里程')).toBeInTheDocument(); expect(screen.queryByText('GPS 总里程')).not.toBeInTheDocument(); + const complexValue = screen.getByRole('button', { name: '查看 单体电压列表 完整数据' }); + expect(complexValue).toHaveClass('semi-button', 'v2-telemetry-value-trigger'); + expect(complexValue).toHaveTextContent('4 个值'); + expect(complexValue).toHaveTextContent('3.21,3.22,3.23'); + fireEvent.click(complexValue); + expect(await screen.findByRole('dialog', { name: '遥测字段完整数据' })).toBeInTheDocument(); + expect(document.querySelector('.v2-telemetry-value-detail pre')).toHaveTextContent('3.24'); + expect(document.querySelector('.v2-telemetry-value-detail > footer strong')).toHaveTextContent('10.0.0.1:8080'); fireEvent.click(screen.getByRole('tab', { name: /JT\/T 808/ })); expect(screen.getByRole('tab', { name: /JT\/T 808/, selected: true })).toBeInTheDocument(); expect(screen.getByText('GPS 总里程')).toBeInTheDocument(); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx index d8f68cc7..57375304 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx @@ -3,7 +3,7 @@ import { IconAlarm, IconBox, IconCalendar, IconClock, IconCopy, IconChevronRight, IconMapPin, IconSearch, IconTickCircle } from '@douyinfe/semi-icons'; -import { Button, Card, Descriptions, Empty, Input, List, Select, Table, Tag } from '@douyinfe/semi-ui'; +import { Button, Card, Descriptions, Empty, Input, List, Select, SideSheet, Table, Tag } from '@douyinfe/semi-ui'; import { FormEvent, lazy, Suspense, useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; @@ -22,6 +22,7 @@ import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { monitorReturnFromParams, withMonitorReturn } from '../routing/monitorContext'; import { useMobileLayout } from '../hooks/useMobileLayout'; +import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; function fmt(value?: string) { return value?.trim() || '—'; } function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? formatZhNumber(value, 1) : fallback; } @@ -122,8 +123,72 @@ function TelemetryFieldCell({ item }: { item: LatestTelemetryValue }) { return
{item.label}{item.sourceField}
; } -function TelemetryValueCell({ item }: { item: LatestTelemetryValue }) { - return
{formatTelemetryValue(item.value, item.displayValue)}{item.unit ? {item.unit} : null}
; +type TelemetryValuePresentation = { + complex: boolean; + preview: string; + summary: string; +}; + +function tryParseTelemetryJSON(value: string) { + const trimmed = value.trim(); + if (!(trimmed.startsWith('[') && trimmed.endsWith(']')) && !(trimmed.startsWith('{') && trimmed.endsWith('}'))) return undefined; + try { return JSON.parse(trimmed) as unknown; } catch { return undefined; } +} + +function telemetryValuePresentation(item: LatestTelemetryValue): TelemetryValuePresentation { + const formatted = formatTelemetryValue(item.value, item.displayValue); + const parsed = typeof item.value === 'string' + ? tryParseTelemetryJSON(item.value) + : typeof item.displayValue === 'string' + ? tryParseTelemetryJSON(item.displayValue) + : item.value; + if (Array.isArray(parsed)) { + return { + complex: true, + preview: parsed.slice(0, 3).map((value) => String(value)).join(',') || '空数组', + summary: `${formatZhNumber(parsed.length)} 个值` + }; + } + if (parsed && typeof parsed === 'object') { + const entries = Object.entries(parsed); + return { + complex: true, + preview: entries.slice(0, 2).map(([key]) => key).join(',') || '空对象', + summary: `${formatZhNumber(entries.length)} 个字段` + }; + } + const detail = String(formatted); + return { + complex: detail.length > 42, + preview: detail.length > 54 ? `${detail.slice(0, 54)}…` : detail, + summary: detail.length > 42 ? `${formatZhNumber(detail.length)} 个字符` : detail + }; +} + +function telemetryValueDetail(item: LatestTelemetryValue) { + const parsed = typeof item.value === 'string' + ? tryParseTelemetryJSON(item.value) + : typeof item.displayValue === 'string' + ? tryParseTelemetryJSON(item.displayValue) + : item.value; + if (parsed && typeof parsed === 'object') return JSON.stringify(parsed, null, 2); + return String(formatTelemetryValue(item.value, item.displayValue)); +} + +function TelemetryValueCell({ item, onInspect }: { item: LatestTelemetryValue; onInspect?: (item: LatestTelemetryValue) => void }) { + const presentation = telemetryValuePresentation(item); + if (presentation.complex) { + return
; + } + return
{presentation.summary}{item.unit ? {item.unit} : null}
; } function TelemetryQualityCell({ item }: { item: LatestTelemetryValue }) { @@ -147,6 +212,11 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon const mobileLayout = useMobileLayout(); const [selectedProtocol, setSelectedProtocol] = useState(''); const [selectedCategory, setSelectedCategory] = useState('vehicle'); + const [inspectedValue, setInspectedValue] = useState(); + const [copiedValue, setCopiedValue] = useState(false); + const inspectedPresentation = useMemo(() => inspectedValue ? telemetryValuePresentation(inspectedValue) : undefined, [inspectedValue]); + const inspectedDetail = useMemo(() => inspectedValue ? telemetryValueDetail(inspectedValue) : '', [inspectedValue]); + useSideSheetA11y(Boolean(inspectedValue), '.v2-telemetry-value-sidesheet', 'v2-telemetry-value-detail', '遥测字段完整数据', '关闭遥测字段完整数据'); const indexed = useMemo(() => { const valuesByProtocolCategory = new Map(); const sources = new Map(); @@ -172,7 +242,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon const protocolLabel = (protocol: string) => protocol === 'GB32960' ? 'GB/T 32960' : protocol === 'JT808' ? 'JT/T 808' : protocol === 'YUTONG_MQTT' ? '宇通 MQTT' : protocol; const columns = [ { title: '字段 / 协议映射', dataIndex: 'label', width: 260, render: (_: unknown, item: LatestTelemetryValue) => }, - { title: '当前值', dataIndex: 'value', width: 150, render: (_: unknown, item: LatestTelemetryValue) => }, + { title: '当前值', dataIndex: 'value', width: 170, render: (_: unknown, item: LatestTelemetryValue) => }, { title: '质量 / 新鲜度', dataIndex: 'quality', width: 170, render: (_: unknown, item: LatestTelemetryValue) => }, { title: '设备 / 接收时间', dataIndex: 'deviceTime', width: 230, render: (_: unknown, item: LatestTelemetryValue) => }, { title: '数据来源', dataIndex: 'protocol', width: 180, render: (_: unknown, item: LatestTelemetryValue) => } @@ -185,7 +255,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon ? : mobileLayout ? -
+
} /> @@ -195,7 +265,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon dataSource={visibleMetrics} rowKey={telemetryRowKey} pagination={false} - scroll={{ x: 990 }} + scroll={{ x: 1010 }} empty={null} />; return @@ -210,6 +280,42 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon {state}
来源{indexed.sources.map((source) => {source.protocol})}扫描 {data?.scannedFrames ?? 0} 帧 · 截至 {formatTelemetryTime(data?.asOf)}
+ {inspectedValue.label}{inspectedValue.sourceField} : null} + onCancel={() => { setInspectedValue(undefined); setCopiedValue(false); }} + footer={
完整值仅按需渲染,不影响实时遥测刷新。
} + > + {inspectedValue ?
+ +
+
完整数据{inspectedPresentation?.summary}
+
{inspectedDetail}
+
+ +
: null} +
; } diff --git a/vehicle-data-platform/apps/web/src/v2/styles/v2.css b/vehicle-data-platform/apps/web/src/v2/styles/v2.css index 59879529..58961cbf 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -8427,6 +8427,40 @@ button, a { -webkit-tap-highlight-color: transparent; } font-variant-numeric: tabular-nums; } .v2-telemetry-value span { color: #7d8b9e; font-size: 9px; } +.v2-telemetry-value.is-complex { display: block; } +.v2-telemetry-value-trigger.semi-button { + width: 100%; + height: auto; + min-height: 36px; + justify-content: flex-start; + border-radius: 7px; + padding: 4px 6px; + color: #315f9f; + text-align: left; +} +.v2-telemetry-value-trigger .semi-button-content { + display: grid; + width: 100%; + min-width: 0; + grid-template-columns: minmax(0,1fr) auto; + align-items: center; + gap: 7px; +} +.v2-telemetry-value-trigger .semi-button-content > span { + display: grid; + min-width: 0; + gap: 3px; +} +.v2-telemetry-value-trigger .semi-button-content strong, +.v2-telemetry-value-trigger .semi-button-content small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.v2-telemetry-value-trigger .semi-button-content strong { color: #245f9f; font-size: 11px; } +.v2-telemetry-value-trigger .semi-button-content small { color: #8492a4; font-size: 8px; font-weight: 500; } +.v2-telemetry-value-trigger .semi-icon { flex: 0 0 auto; color: #7d9ac2; font-size: 12px; } +.v2-telemetry-value-trigger:hover { background: #edf5ff; } .v2-telemetry-quality { justify-items: start; } .v2-telemetry-quality .semi-tag { min-height: 22px; border-radius: 999px; font-size: 9px; font-weight: 650; } .v2-telemetry-quality small, @@ -8453,6 +8487,87 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-telemetry-empty .semi-empty-description { color: #8492a4; font-size: 10px; } .v2-telemetry-mobile-list { display: none; } .v2-telemetry-card > .semi-card-body > footer { min-height: 40px; margin-top: 10px; } +.v2-telemetry-value-sidesheet .semi-sidesheet-inner { max-width: 100vw; } +.v2-telemetry-value-sidesheet .semi-sidesheet-header { border-bottom: 1px solid #e3e9f1; padding: 18px 20px 15px; } +.v2-telemetry-value-title { display: grid; min-width: 0; gap: 4px; } +.v2-telemetry-value-title strong { + overflow: hidden; + color: #243a53; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} +.v2-telemetry-value-title span { + overflow: hidden; + color: #8391a3; + font: 9px ui-monospace,SFMono-Regular,Menlo,monospace; + text-overflow: ellipsis; + white-space: nowrap; +} +.v2-telemetry-value-sidesheet .semi-sidesheet-body { background: #f6f8fb; padding: 16px; } +.v2-telemetry-value-detail { display: grid; gap: 12px; } +.v2-telemetry-value-meta.semi-descriptions { + border: 1px solid #dfe6ef; + border-radius: 10px; + background: #fff; + padding: 12px 14px; +} +.v2-telemetry-value-meta .semi-descriptions-item { min-width: 130px; padding-block: 5px; } +.v2-telemetry-value-meta .semi-descriptions-key { color: #8a97a8; font-size: 9px; } +.v2-telemetry-value-meta .semi-descriptions-value { color: #3d5067; font-size: 10px; font-weight: 650; } +.v2-telemetry-value-detail > section { + overflow: hidden; + border: 1px solid #dfe6ef; + border-radius: 10px; + background: #fff; +} +.v2-telemetry-value-detail > section > header { + display: flex; + min-height: 54px; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid #e4e9f0; + padding: 9px 12px 9px 15px; +} +.v2-telemetry-value-detail > section > header > div { display: grid; min-width: 0; gap: 3px; } +.v2-telemetry-value-detail > section > header strong { color: #30445c; font-size: 12px; } +.v2-telemetry-value-detail > section > header small { color: #8a97a8; font-size: 9px; } +.v2-telemetry-value-detail > section > header .semi-button { flex: 0 0 auto; } +.v2-telemetry-value-detail pre { + max-height: min(54vh,560px); + margin: 0; + overflow: auto; + background: #f8fafc; + padding: 15px; + color: #31465e; + font: 10px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace; + tab-size: 2; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.v2-telemetry-value-detail > footer { + display: grid; + min-width: 0; + grid-template-columns: auto minmax(0,1fr); + align-items: center; + gap: 9px; + border: 1px solid #dfe6ef; + border-radius: 9px; + background: #fff; + padding: 10px 12px; +} +.v2-telemetry-value-detail > footer span { color: #8a97a8; font-size: 9px; } +.v2-telemetry-value-detail > footer strong { + overflow: hidden; + color: #52667d; + font: 9px ui-monospace,SFMono-Regular,Menlo,monospace; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} +.v2-telemetry-value-footer { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 14px; } +.v2-telemetry-value-footer span { color: #7e8d9f; font-size: 9px; } @media (max-width: 680px) { .v2-record-grid { grid-template-rows: none; } @@ -8483,6 +8598,8 @@ button, a { -webkit-tap-highlight-color: transparent; } } .v2-telemetry-mobile-item > header .v2-telemetry-value { justify-content: end; } .v2-telemetry-mobile-item > header .v2-telemetry-value strong { font-size: 13px; } + .v2-telemetry-mobile-item > header .v2-telemetry-value.is-complex { width: min(142px,42vw); } + .v2-telemetry-mobile-item > header .v2-telemetry-value-trigger.semi-button { padding: 2px 4px; } .v2-telemetry-mobile-item > div { display: grid; grid-template-columns: minmax(0,.8fr) minmax(0,1.2fr); @@ -8495,6 +8612,15 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-telemetry-mobile-item > .v2-telemetry-source small { min-width: 0; } .v2-telemetry-card > .semi-card-body > footer { flex-wrap: wrap; gap: 5px; margin: 3px 9px 9px; } .v2-telemetry-card > .semi-card-body > footer small { width: 100%; margin: 3px 0 0; } + .v2-telemetry-value-sidesheet .semi-sidesheet-inner { width: 100vw !important; } + .v2-telemetry-value-sidesheet .semi-sidesheet-header { padding: 15px 16px 13px; } + .v2-telemetry-value-sidesheet .semi-sidesheet-body { padding: 12px; } + .v2-telemetry-value-meta .semi-descriptions-item { min-width: 108px; } + .v2-telemetry-value-detail > section > header { align-items: flex-start; flex-direction: column; } + .v2-telemetry-value-detail > section > header .semi-button { width: 100%; justify-content: center; } + .v2-telemetry-value-detail pre { max-height: 46vh; font-size: 9px; } + .v2-telemetry-value-footer { align-items: stretch; flex-direction: column; } + .v2-telemetry-value-footer .semi-button { width: 100%; justify-content: center; } } /* Semi UI track evidence: one list language for stops, events and source provenance. */