refine Semi UI telemetry details

This commit is contained in:
lingniu
2026-07-18 22:28:00 +08:00
parent 76d35f54c5
commit b465d1ff7a
5 changed files with 126 additions and 16 deletions

View File

@@ -16,6 +16,25 @@ export function telemetryQualityLabel(quality: string) {
return '异常'; return '异常';
} }
export function telemetryQualityColor(quality: string) {
return quality === 'good' ? 'green' : quality === 'stale' ? 'orange' : 'red';
}
export function telemetryValueTypeLabel(value?: string) {
const normalized = value?.trim().toLowerCase();
if (!normalized) return '—';
const labels: Record<string, string> = {
array: '列表',
boolean: '布尔值',
dynamic: '动态值',
number: '数值',
numeric: '数值',
object: '对象',
string: '文本'
};
return labels[normalized] ?? value!;
}
export function formatTelemetryTime(value?: string) { export function formatTelemetryTime(value?: string) {
if (!value) return '—'; if (!value) return '—';
const local = formatShanghaiDateTime(value); const local = formatShanghaiDateTime(value);

View File

@@ -484,7 +484,9 @@ test('keeps protocol telemetry independent and explains mileage semantics', asyn
expect(complexValue).toHaveTextContent('4 个值'); expect(complexValue).toHaveTextContent('4 个值');
expect(complexValue).toHaveTextContent('3.213.223.23'); expect(complexValue).toHaveTextContent('3.213.223.23');
fireEvent.click(complexValue); fireEvent.click(complexValue);
expect(await screen.findByRole('dialog', { name: '遥测字段完整数据' })).toBeInTheDocument(); expect(await screen.findByRole('dialog', { name: '遥测字段详情' })).toBeInTheDocument();
expect(document.querySelector('.v2-telemetry-detail-protocol')).toHaveClass('semi-tag-blue-light');
expect(document.querySelector('.v2-telemetry-value-meta')).toHaveTextContent('数据类型列表');
expect(document.querySelector('.v2-telemetry-value-detail pre')).toHaveTextContent('3.24'); 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'); expect(document.querySelector('.v2-telemetry-value-detail > footer strong')).toHaveTextContent('10.0.0.1:8080');
fireEvent.click(screen.getByRole('tab', { name: /JT\/T 808/ })); fireEvent.click(screen.getByRole('tab', { name: /JT\/T 808/ }));
@@ -515,6 +517,13 @@ test('uses compact Semi telemetry evidence cards on mobile', async () => {
expect(document.querySelector('.v2-telemetry-table')).not.toBeInTheDocument(); expect(document.querySelector('.v2-telemetry-table')).not.toBeInTheDocument();
expect(document.querySelector('.v2-telemetry-mobile-list.semi-list')).toBeInTheDocument(); expect(document.querySelector('.v2-telemetry-mobile-list.semi-list')).toBeInTheDocument();
expect(document.querySelector('.v2-telemetry-mobile-item.semi-list-item')).toHaveTextContent('36km/h正常'); expect(document.querySelector('.v2-telemetry-mobile-item.semi-list-item')).toHaveTextContent('36km/h正常');
expect(screen.getByRole('button', { name: '查看 GPS 速度 字段详情' })).toHaveClass('v2-telemetry-mobile-action');
fireEvent.click(screen.getByRole('button', { name: '查看 GPS 速度 字段详情' }));
expect(await screen.findByRole('dialog', { name: '遥测字段详情' })).toBeInTheDocument();
expect(document.querySelector('.v2-telemetry-value-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-telemetry-value-meta')).toHaveTextContent('数据类型数值');
expect(document.querySelector('.v2-telemetry-detail-protocol')).toHaveClass('semi-tag-cyan-light');
expect(screen.getByRole('button', { name: '关闭遥测字段详情' })).toBeInTheDocument();
expect(document.querySelector('.v2-vehicle-record-page')).toHaveClass('v2-vehicle-record-v3', 'is-mobile-layout'); expect(document.querySelector('.v2-vehicle-record-page')).toHaveClass('v2-vehicle-record-v3', 'is-mobile-layout');
expect(screen.getByRole('group', { name: '车辆快捷操作' }).querySelectorAll('.semi-button')).toHaveLength(5); expect(screen.getByRole('group', { name: '车辆快捷操作' }).querySelectorAll('.semi-button')).toHaveLength(5);
expect(screen.getByRole('group', { name: '车辆快捷操作' })).toHaveTextContent('切换车辆轨迹回放历史数据里程查询告警事件'); expect(screen.getByRole('group', { name: '车辆快捷操作' })).toHaveTextContent('切换车辆轨迹回放历史数据里程查询告警事件');

View File

@@ -11,7 +11,7 @@ import type { LatestTelemetryResponse, LatestTelemetryValue, QualityIssueRow, Ve
import { usePlatformSession } from '../auth/AuthGate'; import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister, hasMenu } from '../auth/session'; import { canAdminister, hasMenu } from '../auth/session';
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy'; import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from '../domain/telemetry'; import { formatTelemetryTime, formatTelemetryValue, telemetryQualityColor, telemetryQualityLabel, telemetryValueTypeLabel } from '../domain/telemetry';
import { formatZhNumber } from '../domain/formatters'; import { formatZhNumber } from '../domain/formatters';
import { protocolDisplayLabel, protocolSourceLabel } from '../domain/protocols'; import { protocolDisplayLabel, protocolSourceLabel } from '../domain/protocols';
import { isValidAMapCoordinate } from '../../integrations/amap'; import { isValidAMapCoordinate } from '../../integrations/amap';
@@ -201,6 +201,9 @@ function telemetryValueDetail(item: LatestTelemetryValue) {
function TelemetryValueCell({ item, onInspect }: { item: LatestTelemetryValue; onInspect?: (item: LatestTelemetryValue) => void }) { function TelemetryValueCell({ item, onInspect }: { item: LatestTelemetryValue; onInspect?: (item: LatestTelemetryValue) => void }) {
const presentation = telemetryValuePresentation(item); const presentation = telemetryValuePresentation(item);
if (presentation.complex) { if (presentation.complex) {
if (!onInspect) {
return <div className="v2-telemetry-value is-complex is-static"><strong>{presentation.summary}</strong><small>{presentation.preview}</small></div>;
}
return <div className="v2-telemetry-value is-complex"><Button return <div className="v2-telemetry-value is-complex"><Button
className="v2-telemetry-value-trigger" className="v2-telemetry-value-trigger"
theme="borderless" theme="borderless"
@@ -215,8 +218,7 @@ function TelemetryValueCell({ item, onInspect }: { item: LatestTelemetryValue; o
} }
function TelemetryQualityCell({ item }: { item: LatestTelemetryValue }) { function TelemetryQualityCell({ item }: { item: LatestTelemetryValue }) {
const color = item.quality === 'good' ? 'green' : item.quality === 'stale' ? 'orange' : 'red'; return <div className="v2-telemetry-quality"><Tag color={telemetryQualityColor(item.quality)} type="light" size="small">{telemetryQualityLabel(item.quality)}</Tag><small title={item.qualityReason}>{item.qualityReason || '未提供质量说明'} · {formatZhNumber(item.freshnessSeconds, 0)}s</small></div>;
return <div className="v2-telemetry-quality"><Tag color={color} type="light" size="small">{telemetryQualityLabel(item.quality)}</Tag><small title={item.qualityReason}>{item.qualityReason || '未提供质量说明'} · {formatZhNumber(item.freshnessSeconds, 0)}s</small></div>;
} }
function TelemetryTimeCell({ item }: { item: LatestTelemetryValue }) { function TelemetryTimeCell({ item }: { item: LatestTelemetryValue }) {
@@ -239,7 +241,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
const [copiedValue, setCopiedValue] = useState(false); const [copiedValue, setCopiedValue] = useState(false);
const inspectedPresentation = useMemo(() => inspectedValue ? telemetryValuePresentation(inspectedValue) : undefined, [inspectedValue]); const inspectedPresentation = useMemo(() => inspectedValue ? telemetryValuePresentation(inspectedValue) : undefined, [inspectedValue]);
const inspectedDetail = useMemo(() => inspectedValue ? telemetryValueDetail(inspectedValue) : '', [inspectedValue]); const inspectedDetail = useMemo(() => inspectedValue ? telemetryValueDetail(inspectedValue) : '', [inspectedValue]);
useSideSheetA11y(Boolean(inspectedValue), '.v2-telemetry-value-sidesheet', 'v2-telemetry-value-detail', '遥测字段完整数据', '关闭遥测字段完整数据'); useSideSheetA11y(Boolean(inspectedValue), '.v2-telemetry-value-sidesheet', 'v2-telemetry-value-detail', '遥测字段详情', '关闭遥测字段详情');
const indexed = useMemo(() => { const indexed = useMemo(() => {
const valuesByProtocolCategory = new Map<string, LatestTelemetryResponse['values']>(); const valuesByProtocolCategory = new Map<string, LatestTelemetryResponse['values']>();
const sources = new Map<string, { protocol: string; endpoint?: string }>(); const sources = new Map<string, { protocol: string; endpoint?: string }>();
@@ -277,9 +279,12 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
? <Empty className="v2-telemetry-empty" title="暂无可展示字段" description={`该协议最近 ${data?.scannedFrames ?? 0} 帧没有可展示的标量遥测。`} /> ? <Empty className="v2-telemetry-empty" title="暂无可展示字段" description={`该协议最近 ${data?.scannedFrames ?? 0} 帧没有可展示的标量遥测。`} />
: mobileLayout : mobileLayout
? <List className="v2-telemetry-mobile-list" dataSource={visibleMetrics} split={false} renderItem={(item) => <List.Item className="v2-telemetry-mobile-item" key={telemetryRowKey(item)}> ? <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} onInspect={setInspectedValue} /></header> <header><TelemetryFieldCell item={item} /><TelemetryValueCell item={item} /></header>
<div><TelemetryQualityCell item={item} /><TelemetryTimeCell item={item} /></div> <div><TelemetryQualityCell item={item} /><TelemetryTimeCell item={item} /></div>
<TelemetrySourceCell item={item} /> <footer className="v2-telemetry-mobile-source">
<TelemetrySourceCell item={item} />
<Button className="v2-telemetry-mobile-action" theme="borderless" type="primary" size="small" icon={<IconChevronRight />} iconPosition="right" aria-label={`查看 ${item.label} 字段详情`} onClick={() => setInspectedValue(item)}></Button>
</footer>
</List.Item>} /> </List.Item>} />
: <div className="v2-telemetry-table-wrap"><Table : <div className="v2-telemetry-table-wrap"><Table
className="v2-telemetry-table" className="v2-telemetry-table"
@@ -305,7 +310,10 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
<SideSheet <SideSheet
className="v2-telemetry-value-sidesheet" className="v2-telemetry-value-sidesheet"
visible={Boolean(inspectedValue)} visible={Boolean(inspectedValue)}
width={520} placement={mobileLayout ? 'bottom' : 'right'}
width={mobileLayout ? undefined : 520}
height={mobileLayout ? 'min(88dvh, 780px)' : undefined}
aria-label="遥测字段详情"
title={inspectedValue ? <div className="v2-telemetry-value-title"><strong>{inspectedValue.label}</strong><span>{inspectedValue.sourceField}</span></div> : null} title={inspectedValue ? <div className="v2-telemetry-value-title"><strong>{inspectedValue.label}</strong><span>{inspectedValue.sourceField}</span></div> : null}
onCancel={() => { setInspectedValue(undefined); setCopiedValue(false); }} onCancel={() => { setInspectedValue(undefined); setCopiedValue(false); }}
footer={<div className="v2-telemetry-value-footer"><span></span><Button theme="solid" onClick={() => { setInspectedValue(undefined); setCopiedValue(false); }}></Button></div>} footer={<div className="v2-telemetry-value-footer"><span></span><Button theme="solid" onClick={() => { setInspectedValue(undefined); setCopiedValue(false); }}></Button></div>}
@@ -316,10 +324,10 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
size="small" size="small"
row row
data={[ data={[
{ key: '协议', value: protocolDisplayLabel(inspectedValue.protocol) }, { key: '协议', value: <ProtocolTag className="v2-telemetry-detail-protocol" protocol={inspectedValue.protocol} compact /> },
{ key: '数据类型', value: inspectedValue.valueType || '—' }, { key: '数据类型', value: telemetryValueTypeLabel(inspectedValue.valueType) },
{ key: '单位', value: inspectedValue.unit || '—' }, { key: '单位', value: inspectedValue.unit || '—' },
{ key: '质量', value: telemetryQualityLabel(inspectedValue.quality) }, { key: '质量', value: <Tag className="v2-telemetry-detail-quality" color={telemetryQualityColor(inspectedValue.quality)} type="light" size="small">{telemetryQualityLabel(inspectedValue.quality)}</Tag> },
{ key: '设备时间', value: formatTelemetryTime(inspectedValue.deviceTime) }, { key: '设备时间', value: formatTelemetryTime(inspectedValue.deviceTime) },
{ key: '接收时间', value: formatTelemetryTime(inspectedValue.serverTime) } { key: '接收时间', value: formatTelemetryTime(inspectedValue.serverTime) }
]} ]}

View File

@@ -90,6 +90,8 @@ describe('V2 production entry', () => {
expect(corePageSources.VehiclePage).toContain('className="v2-telemetry-table"'); expect(corePageSources.VehiclePage).toContain('className="v2-telemetry-table"');
expect(corePageSources.VehiclePage).toContain('<List className="v2-telemetry-mobile-list"'); expect(corePageSources.VehiclePage).toContain('<List className="v2-telemetry-mobile-list"');
expect(corePageSources.VehiclePage).toContain('<List.Item className="v2-telemetry-mobile-item"'); expect(corePageSources.VehiclePage).toContain('<List.Item className="v2-telemetry-mobile-item"');
expect(corePageSources.VehiclePage).toContain('className="v2-telemetry-mobile-action"');
expect(corePageSources.VehiclePage).toContain("placement={mobileLayout ? 'bottom' : 'right'}");
expect(corePageSources.VehiclePage).toContain('<List'); expect(corePageSources.VehiclePage).toContain('<List');
expect(corePageSources.VehiclePage).toContain('className="v2-event-list"'); expect(corePageSources.VehiclePage).toContain('className="v2-event-list"');
expect(corePageSources.VehiclePage).toContain('useMobileLayout'); expect(corePageSources.VehiclePage).toContain('useMobileLayout');

View File

@@ -8611,6 +8611,24 @@ button, a { -webkit-tap-highlight-color: transparent; }
} }
.v2-telemetry-value span { color: #7d8b9e; font-size: 9px; } .v2-telemetry-value span { color: #7d8b9e; font-size: 9px; }
.v2-telemetry-value.is-complex { display: block; } .v2-telemetry-value.is-complex { display: block; }
.v2-telemetry-value.is-complex.is-static {
display: grid;
max-width: 150px;
justify-items: end;
gap: 3px;
text-align: right;
}
.v2-telemetry-value.is-complex.is-static strong,
.v2-telemetry-value.is-complex.is-static small {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-telemetry-value.is-complex.is-static small {
color: #8492a4;
font-size: 8px;
}
.v2-telemetry-value-trigger.semi-button { .v2-telemetry-value-trigger.semi-button {
width: 100%; width: 100%;
height: auto; height: auto;
@@ -8698,6 +8716,12 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-telemetry-value-meta .semi-descriptions-item { min-width: 130px; padding-block: 5px; } .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-key { color: #8a97a8; font-size: 9px; }
.v2-telemetry-value-meta .semi-descriptions-value { color: #3d5067; font-size: 10px; font-weight: 650; } .v2-telemetry-value-meta .semi-descriptions-value { color: #3d5067; font-size: 10px; font-weight: 650; }
.v2-telemetry-detail-protocol.semi-tag,
.v2-telemetry-detail-quality.semi-tag {
min-height: 22px;
margin: 0;
border-radius: 999px;
}
.v2-telemetry-value-detail > section { .v2-telemetry-value-detail > section {
overflow: hidden; overflow: hidden;
border: 1px solid #dfe6ef; border: 1px solid #dfe6ef;
@@ -8791,13 +8815,61 @@ button, a { -webkit-tap-highlight-color: transparent; }
border-top: 1px solid #edf1f5; border-top: 1px solid #edf1f5;
padding-top: 9px; padding-top: 9px;
} }
.v2-telemetry-mobile-item > .v2-telemetry-source { display: flex; align-items: center; gap: 8px; } .v2-telemetry-mobile-source {
.v2-telemetry-mobile-item > .v2-telemetry-source small { min-width: 0; } display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
border-top: 1px solid #edf1f5;
padding-top: 8px;
}
.v2-telemetry-mobile-source > .v2-telemetry-source {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
}
.v2-telemetry-mobile-source > .v2-telemetry-source small {
min-width: 0;
}
.v2-telemetry-mobile-action.semi-button {
min-width: 76px;
min-height: 30px;
flex: 0 0 auto;
justify-content: flex-end;
border-radius: 7px;
padding-inline: 7px;
font-size: 9px;
font-weight: 700;
}
.v2-telemetry-card > .semi-card-body > footer { flex-wrap: wrap; gap: 5px; margin: 3px 9px 9px; } .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-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-inner {
.v2-telemetry-value-sidesheet .semi-sidesheet-header { padding: 15px 16px 13px; } width: 100vw !important;
.v2-telemetry-value-sidesheet .semi-sidesheet-body { padding: 12px; } overflow: hidden;
border-radius: 16px 16px 0 0;
background: #f4f7fb;
box-shadow: 0 -18px 48px rgba(25,45,72,.18);
}
.v2-telemetry-value-sidesheet .semi-sidesheet-header {
min-height: 68px;
border-bottom-color: #dfe7f0;
background: rgba(255,255,255,.98);
padding: 11px 14px;
}
.v2-telemetry-value-sidesheet .semi-sidesheet-body {
overflow: auto;
background: #f4f7fb;
padding: 10px;
overscroll-behavior: contain;
}
.v2-telemetry-value-sidesheet .semi-sidesheet-footer {
border-top: 1px solid #dfe7f0;
background: rgba(255,255,255,.98);
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
}
.v2-telemetry-value-title strong { font-size: 15px; }
.v2-telemetry-value-meta .semi-descriptions-item { min-width: 108px; } .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 { align-items: flex-start; flex-direction: column; }
.v2-telemetry-value-detail > section > header .semi-button { width: 100%; justify-content: center; } .v2-telemetry-value-detail > section > header .semi-button { width: 100%; justify-content: center; }