refine Semi UI monitor vehicle inspector

This commit is contained in:
lingniu
2026-07-18 05:40:47 +08:00
parent 45820daa46
commit 32d97a0ddc
3 changed files with 408 additions and 17 deletions

View File

@@ -128,6 +128,12 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(kpis?.querySelector('.v2-kpi:last-child')).toHaveClass('is-missing', 'is-support');
expect(view.container.querySelector('.v2-event-strip')).toHaveClass('semi-card');
expect(view.container.querySelector('.v2-monitor-live-tag')).toHaveClass('semi-tag');
const liveStatus = screen.getByLabelText('实时数据状态');
expect(liveStatus).toHaveTextContent('数据已同步');
expect(liveStatus).toHaveTextContent('实时数据2 辆已载入');
expect(liveStatus).toHaveTextContent('地图载荷0 个车辆点');
expect(liveStatus).toHaveTextContent('智能刷新重点 10s · 车队 15s · 统计 30s');
expect(liveStatus).toHaveTextContent('最近同步等待数据');
expect(workspace).not.toHaveClass('is-detail-open');
expect(workspace).not.toHaveClass('is-detail-collapsed');
expect(firstVehicle).not.toHaveClass('is-selected');
@@ -151,6 +157,7 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(screen.getByRole('heading', { name, level: 5 })).toBeInTheDocument();
}
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-metric-grid > div:first-child')).toHaveTextContent('速度42');

View File

@@ -266,19 +266,20 @@ function VehicleDetailCard({
<small>{vehicle.vin}</small>
</div>
<nav className="v2-detail-actions" aria-label="车辆快捷操作">
{workflowLinks.map(([label, path]) => <Link key={label} to={withMonitorReturn(path, monitorReturn)}>{label}</Link>)}
{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"><small></small><strong>{vehicle.lastSeen || '暂无上报'}</strong></span>
<span><small></small><b>{vehicle.primaryProtocol || '未知'}</b></span>
<span><small></small><b>{accessSource || '待补充'}</b></span>
<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>
@@ -293,12 +294,12 @@ function VehicleDetailCard({
meta={<Tag color="blue" type="light" size="small">{vehicle.primaryProtocol || '未知协议'}</Tag>}
/>
<div className="v2-metric-grid">
<div><small></small><strong>{hasRealtimeSpeed(vehicle) ? formatNumber(vehicle.speedKmh, 1) : '—'}<em>{hasRealtimeSpeed(vehicle) ? 'km/h' : ''}</em></strong></div>
<div><small>SOC</small><strong>{hasRealtimeSOC(vehicle) ? formatNumber(vehicle.socPercent, 1) : '—'}<em>{hasRealtimeSOC(vehicle) ? '%' : ''}</em></strong></div>
<div><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><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><small></small><strong>{statusLabel(status)}</strong></div>
<div className={(activeAlerts?.total ?? 0) > 0 ? 'has-alerts' : ''}><small></small><strong>{formatNumber(activeAlerts?.total ?? 0)}<em></em></strong></div>
<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>
@@ -307,7 +308,7 @@ function VehicleDetailCard({
variant="compact"
title="车辆信息"
description="身份、车型与来源覆盖"
meta={`${vehicle.onlineSourceCount}/${vehicle.sourceCount} 在线`}
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>
@@ -426,6 +427,12 @@ export default function MonitorPage() {
const driving = rows.filter((vehicle) => vehicleStatus(vehicle) === 'driving').length;
const idle = rows.filter((vehicle) => vehicleStatus(vehicle) === 'idle').length;
const offline = rows.filter((vehicle) => vehicleStatus(vehicle) === 'offline').length;
const viewportLoad = mode === 'map'
? (map.data?.clusters.length
? `${map.data.clusters.length} 个聚合 · ${map.data.points.length} 个车辆点`
: `${map.data?.points.length ?? 0} 个车辆点`)
: `${realtimeListQuery.data?.items.length ?? 0} / ${realtimeListQuery.data?.total ?? 0}`;
const lastSyncAt = vehicles.dataUpdatedAt ? new Date(vehicles.dataUpdatedAt) : undefined;
return (
<div className="v2-monitor-page">
@@ -533,12 +540,17 @@ export default function MonitorPage() {
) : null}
</section> : <MonitorVehicleTable rows={visibleListRows} total={visibleListTotal} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil(visibleListTotal / listLimit))} limit={listLimit} loading={filterTransitionPending || realtimeListQuery.isFetching} onSelect={selectVehicle} onPage={(page) => setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
<Card className="v2-event-strip" bodyStyle={{ padding: 0 }}>
<strong></strong>
<Tag className="v2-monitor-live-tag" color="green" type="light" size="small"><i className="is-online" />{vehicles.isFetching ? '正在刷新' : '实时车辆已同步'}</Tag>
<span>{mode === 'map' ? `列表 ${visibleRows.length} 条 · 地图 ${map.data?.clusters.length ? `${map.data.clusters.length} 个聚合 + ${map.data.points.length} 个车辆点` : `${map.data?.points.length ?? 0} 个点`}` : `实时列表 ${realtimeListQuery.data?.items.length ?? 0} / ${realtimeListQuery.data?.total ?? 0}`}</span>
<span className="v2-refresh-cadence"><Tag color="blue" type="light" size="small"></Tag> {MONITOR_REFRESH.selected / 1000} · {MONITOR_REFRESH.fleet / 1000} · {MONITOR_REFRESH.summary / 1000} </span>
<time>{new Date().toLocaleString('zh-CN', { hour12: false })}</time>
<Card className="v2-event-strip" bodyStyle={{ padding: 0 }} aria-label="实时数据状态">
<div className="v2-event-sync">
<Tag className="v2-monitor-live-tag" color="green" type="light" size="small"><IconRefresh aria-hidden="true" />{vehicles.isFetching ? '正在同步' : '数据已同步'}</Tag>
<span><strong></strong><small>{visibleRows.length.toLocaleString('zh-CN')} </small></span>
</div>
<div className="v2-event-load"><small>{mode === 'map' ? '地图载荷' : '列表载荷'}</small><span>{viewportLoad}</span></div>
<div className="v2-refresh-cadence">
<Tag color="blue" type="light" size="small"><IconRefresh aria-hidden="true" /></Tag>
<span> {MONITOR_REFRESH.selected / 1000}s · {MONITOR_REFRESH.fleet / 1000}s · {MONITOR_REFRESH.summary / 1000}s</span>
</div>
<time dateTime={lastSyncAt?.toISOString()}><span><small></small><b>{lastSyncAt ? lastSyncAt.toLocaleTimeString('zh-CN', { hour12: false }) : '等待数据'}</b></span></time>
</Card>
{mobileEntryOpen ? <MobileEntry onClose={() => setMobileEntryOpen(false)} /> : null}
</div>

View File

@@ -9192,3 +9192,375 @@
margin-top: 7px;
}
}
/* Semi UI workspace migration: monitor vehicle inspector and live status rail. */
.v2-vehicle-detail.semi-card {
background: #f6f8fb;
padding: 12px;
scrollbar-color: #a8b7c9 transparent;
scrollbar-width: thin;
}
.v2-vehicle-detail.semi-card::-webkit-scrollbar {
width: 8px;
}
.v2-vehicle-detail.semi-card::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background: #a8b7c9;
background-clip: padding-box;
}
.v2-detail-shell-header {
top: -12px;
margin: -12px -12px 8px;
border-bottom-color: #dfe7f0;
background: rgba(255, 255, 255, .96);
padding: 13px 12px 10px;
box-shadow: 0 6px 18px rgba(31, 53, 80, .045);
backdrop-filter: blur(10px);
}
.v2-detail-shell-header .v2-detail-controls {
top: 12px;
right: 12px;
}
.v2-detail-shell-header .v2-detail-title {
padding-bottom: 11px;
}
.v2-detail-shell-header .v2-detail-title strong {
font-size: 19px;
font-weight: 780;
letter-spacing: -.035em;
}
.v2-detail-actions {
gap: 5px;
}
.v2-detail-actions a {
justify-content: space-between;
padding: 0 9px;
border-color: #dce5ef;
background: #f9fbfd;
}
.v2-detail-actions a > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-detail-actions a > .semi-icon {
color: #94a3b8;
font-size: 11px;
}
.v2-detail-actions a:first-child > .semi-icon,
.v2-detail-actions a:hover > .semi-icon,
.v2-detail-actions a:focus-visible > .semi-icon {
color: currentColor;
}
.v2-vehicle-detail .v2-detail-section {
border-color: #dce5ef;
border-radius: 11px;
box-shadow: 0 6px 20px rgba(31, 53, 80, .045);
}
.v2-detail-section > .v2-workspace-panel-header {
min-height: 50px;
padding: 9px 11px;
}
.v2-detail-section > .v2-workspace-panel-header .v2-workspace-panel-copy {
gap: 3px;
}
.v2-detail-section > .v2-workspace-panel-header .v2-workspace-panel-copy > h5 {
color: #263a52;
font-size: 12px;
font-weight: 730;
}
.v2-detail-section > .v2-workspace-panel-header .v2-workspace-panel-copy > .semi-typography {
color: #8794a6;
}
.v2-vehicle-detail .v2-detail-report {
border-color: #cfdff3;
}
.v2-detail-report > .v2-workspace-panel-header {
background: linear-gradient(100deg, #f1f7ff 0%, #f8fbff 64%, #fff 100%);
}
.v2-report-summary {
border-color: #d5e2f1;
background: #fafcff;
}
.v2-report-summary > .is-time {
background: linear-gradient(135deg, #f2f7ff 0%, #fafcff 100%);
}
.v2-report-summary strong {
color: #174d8d;
font-weight: 750;
}
.v2-report-summary b {
color: #3b5879;
font-weight: 700;
}
.v2-detail-section > .v2-metric-grid {
overflow: hidden;
border-color: #dce5ef;
border-radius: 10px;
background: #fbfcfe;
}
.v2-detail-section > .v2-metric-grid > div {
min-height: 70px;
background: #fff;
}
.v2-detail-section > .v2-metric-grid > div:hover {
background: #fbfdff;
}
.v2-detail-section > .v2-metric-grid > .is-speed::after { background: #78a9ef; }
.v2-detail-section > .v2-metric-grid > .is-soc::after { background: #65bf94; }
.v2-detail-section > .v2-metric-grid > .is-mileage::after { background: #79a7ea; }
.v2-detail-section > .v2-metric-grid > .is-today::after { background: #2f7de9; }
.v2-detail-section > .v2-metric-grid > .is-status::after { background: #43b77d; }
.v2-detail-section > .v2-metric-grid > .is-alert::after { background: #d8e0ea; }
.v2-detail-section > .v2-metric-grid > .is-today strong,
.v2-detail-section > .v2-metric-grid > .is-today .v2-metric-source-link { color: #1268df; }
.v2-detail-section > .v2-metric-grid > .has-alerts::after { background: #eb6d70; }
.v2-vehicle-detail .v2-source-evidence.is-compact.semi-card {
margin-top: 8px;
overflow: hidden;
border: 1px solid #dce5ef;
border-radius: 11px;
background: #fff;
box-shadow: 0 6px 20px rgba(31, 53, 80, .04);
}
.v2-vehicle-detail .v2-source-evidence.is-compact .v2-source-evidence-trigger {
min-height: 56px;
padding: 10px 11px;
}
.v2-vehicle-detail .v2-source-evidence.is-compact .v2-source-evidence-body {
padding: 10px 11px 12px;
}
.v2-event-strip.semi-card {
min-height: 52px;
overflow: hidden;
border-color: #dce5ef;
border-radius: 11px;
background: rgba(255, 255, 255, .98);
box-shadow: 0 6px 20px rgba(31, 53, 80, .045);
}
.v2-event-strip > .semi-card-body {
display: grid;
min-height: 50px;
grid-template-columns: auto minmax(150px, 1fr) auto auto;
align-items: center;
gap: 0;
padding: 0 13px;
}
.v2-event-sync {
display: flex;
min-width: 0;
align-items: center;
gap: 9px;
padding-right: 14px;
}
.v2-event-sync > span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
line-height: 1.15;
}
.v2-event-sync > span strong {
color: #2a3c53;
font-size: 10px;
}
.v2-event-sync > span small,
.v2-event-load > small,
.v2-event-strip time small {
color: #8a97a8;
font-size: 8px;
font-weight: 550;
white-space: nowrap;
}
.v2-monitor-live-tag.semi-tag {
height: 26px;
gap: 5px;
padding-inline: 8px;
border-radius: 13px;
font-size: 9px;
font-weight: 700;
}
.v2-monitor-live-tag .semi-tag-content,
.v2-refresh-cadence .semi-tag-content {
display: inline-flex;
align-items: center;
gap: 4px;
}
.v2-event-load {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
border-left: 1px solid #e5ebf2;
padding: 0 14px;
line-height: 1.15;
}
.v2-event-load > span {
overflow: hidden;
color: #4f6075;
font-size: 9px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-refresh-cadence {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
border-left: 1px solid #e5ebf2;
padding: 0 14px;
}
.v2-refresh-cadence > span {
color: #64748b;
font-size: 9px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.v2-refresh-cadence .semi-tag {
height: 24px;
border-radius: 12px;
font-size: 8px;
font-weight: 700;
}
.v2-event-strip time {
display: flex;
min-width: 92px;
align-items: center;
justify-content: flex-end;
gap: 7px;
border-left: 1px solid #e5ebf2;
padding-left: 14px;
color: #8290a3;
}
.v2-event-strip time > span {
display: flex;
align-items: flex-end;
flex-direction: column;
gap: 2px;
line-height: 1.1;
}
.v2-event-strip time b {
color: #4c5e73;
font-size: 9px;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
@media (max-width: 1180px) {
.v2-event-strip > .semi-card-body {
grid-template-columns: auto minmax(120px, 1fr) auto;
}
.v2-event-load {
display: none;
}
}
@media (max-width: 760px) {
.v2-vehicle-detail.semi-card {
padding: 16px 10px 14px;
}
.v2-detail-shell-header {
top: -16px;
margin: -12px -10px 8px;
padding: 17px 10px 10px;
}
.v2-detail-shell-header .v2-detail-controls {
top: 16px;
right: 10px;
}
.v2-detail-actions a {
padding-inline: 11px;
}
.v2-detail-section > .v2-workspace-panel-header {
min-height: 54px;
}
.v2-event-strip > .semi-card-body {
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 0;
padding: 9px 10px;
}
.v2-event-sync {
padding-right: 8px;
}
.v2-event-load {
display: none;
}
.v2-refresh-cadence {
display: flex;
grid-column: 1 / -1;
grid-row: 2;
align-items: center;
justify-content: space-between;
border-top: 1px solid #edf1f5;
border-left: 0;
padding: 8px 0 0;
}
.v2-refresh-cadence > span {
overflow: hidden;
text-overflow: ellipsis;
}
.v2-event-strip time {
min-width: 82px;
border-left: 0;
padding-left: 8px;
}
}