refine Semi UI telemetry evidence

This commit is contained in:
lingniu
2026-07-18 07:00:07 +08:00
parent 4b3bb3b3dd
commit b54d5ba077
3 changed files with 252 additions and 7 deletions

View File

@@ -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.213.223.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();

View File

@@ -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 <div className="v2-telemetry-field"><strong title={item.description}>{item.label}</strong><small title={item.sourceField}>{item.sourceField}</small></div>;
}
function TelemetryValueCell({ item }: { item: LatestTelemetryValue }) {
return <div className="v2-telemetry-value"><strong>{formatTelemetryValue(item.value, item.displayValue)}</strong>{item.unit ? <span>{item.unit}</span> : null}</div>;
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 <div className="v2-telemetry-value is-complex"><Button
className="v2-telemetry-value-trigger"
theme="borderless"
type="tertiary"
aria-label={`查看 ${item.label} 完整数据`}
onClick={() => onInspect?.(item)}
>
<span><strong>{presentation.summary}</strong><small>{presentation.preview}</small></span><IconChevronRight />
</Button></div>;
}
return <div className="v2-telemetry-value"><strong>{presentation.summary}</strong>{item.unit ? <span>{item.unit}</span> : null}</div>;
}
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<LatestTelemetryValue>();
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<string, LatestTelemetryResponse['values']>();
const sources = new Map<string, { protocol: string; endpoint?: string }>();
@@ -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) => <TelemetryFieldCell item={item} /> },
{ title: '当前值', dataIndex: 'value', width: 150, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryValueCell item={item} /> },
{ title: '当前值', dataIndex: 'value', width: 170, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryValueCell item={item} onInspect={setInspectedValue} /> },
{ title: '质量 / 新鲜度', dataIndex: 'quality', width: 170, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryQualityCell item={item} /> },
{ title: '设备 / 接收时间', dataIndex: 'deviceTime', width: 230, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryTimeCell item={item} /> },
{ title: '数据来源', dataIndex: 'protocol', width: 180, render: (_: unknown, item: LatestTelemetryValue) => <TelemetrySourceCell item={item} /> }
@@ -185,7 +255,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
? <Empty className="v2-telemetry-empty" title="暂无可展示字段" description={`该协议最近 ${data?.scannedFrames ?? 0} 帧没有可展示的标量遥测。`} />
: mobileLayout
? <List className="v2-telemetry-mobile-list" dataSource={visibleMetrics} split={false} renderItem={(item) => <List.Item className="v2-telemetry-mobile-item" key={telemetryRowKey(item)}>
<header><TelemetryFieldCell item={item} /><TelemetryValueCell item={item} /></header>
<header><TelemetryFieldCell item={item} /><TelemetryValueCell item={item} onInspect={setInspectedValue} /></header>
<div><TelemetryQualityCell item={item} /><TelemetryTimeCell item={item} /></div>
<TelemetrySourceCell item={item} />
</List.Item>} />
@@ -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}
/></div>;
return <Card className="v2-record-card v2-telemetry-card" bodyStyle={{ padding: 0 }}>
@@ -210,6 +280,42 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
</div>
{state}
<footer><b></b>{indexed.sources.map((source) => <span key={`${source.protocol}-${source.endpoint ?? ''}`} title={source.endpoint}>{source.protocol}</span>)}<small> {data?.scannedFrames ?? 0} · {formatTelemetryTime(data?.asOf)}</small></footer>
<SideSheet
className="v2-telemetry-value-sidesheet"
visible={Boolean(inspectedValue)}
width={520}
title={inspectedValue ? <div className="v2-telemetry-value-title"><strong>{inspectedValue.label}</strong><span>{inspectedValue.sourceField}</span></div> : null}
onCancel={() => { setInspectedValue(undefined); setCopiedValue(false); }}
footer={<div className="v2-telemetry-value-footer"><span></span><Button theme="solid" onClick={() => { setInspectedValue(undefined); setCopiedValue(false); }}></Button></div>}
>
{inspectedValue ? <div className="v2-telemetry-value-detail">
<Descriptions
className="v2-telemetry-value-meta"
size="small"
row
data={[
{ key: '协议', value: inspectedValue.protocol },
{ key: '数据类型', value: inspectedValue.valueType || '—' },
{ key: '单位', value: inspectedValue.unit || '—' },
{ key: '质量', value: telemetryQualityLabel(inspectedValue.quality) },
{ key: '设备时间', value: formatTelemetryTime(inspectedValue.deviceTime) },
{ key: '接收时间', value: formatTelemetryTime(inspectedValue.serverTime) }
]}
/>
<section>
<header><div><strong></strong><small>{inspectedPresentation?.summary}</small></div><Button
theme="light"
icon={<IconCopy />}
onClick={() => {
void navigator.clipboard?.writeText(inspectedDetail);
setCopiedValue(true);
}}
>{copiedValue ? '已复制' : '复制完整值'}</Button></header>
<pre>{inspectedDetail}</pre>
</section>
<footer><span></span><strong>{inspectedValue.sourceEndpoint || '协议默认来源'}</strong></footer>
</div> : null}
</SideSheet>
</Card>;
}

View File

@@ -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. */