feat: unify monitor vehicle metric rail

This commit is contained in:
lingniu
2026-07-19 10:28:17 +08:00
parent add47b7efc
commit dcc6b223d9
4 changed files with 229 additions and 37 deletions

View File

@@ -170,11 +170,15 @@ test('starts without a selection and supports expand, collapse, reselection, and
'单车详情', '轨迹回放', '历史数据', '里程查询'
]);
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(screen.getByRole('list', { name: '车辆实时状态指标' })).toHaveClass('v2-workspace-metric-list');
expect(view.container.querySelector('.v2-monitor-detail-metric-rail')).toHaveClass('semi-card', 'v2-workspace-metric-rail', 'is-queue');
expect(view.container.querySelectorAll('.v2-monitor-detail-metric-rail .v2-workspace-metric-list > [role="listitem"]')).toHaveLength(4);
expect(view.container.querySelector('.v2-monitor-detail-metric-rail .v2-workspace-metric-list > span:first-child')).toHaveTextContent('速度42');
expect(view.container.querySelector('.v2-monitor-detail-metric-rail .v2-workspace-metric-list > span:nth-child(2)')).toHaveTextContent('今日里程18.6km');
expect(view.container.querySelector('.v2-monitor-detail-state')).toHaveTextContent('车辆状态行驶当前告警0条');
expect(view.container.querySelector('.v2-status-text')).toHaveClass('semi-tag');
expect(screen.getByRole('button', { name: '取消选择车辆' })).toHaveClass('semi-button');
expect(screen.getByRole('button', { name: '查看总里程全部来源' })).toHaveClass('semi-button', 'v2-metric-source-link');
expect(screen.getByRole('button', { name: '查看总里程全部来源' })).toHaveClass('semi-button', 'v2-workspace-metric-action');
expect(screen.getByRole('button', { name: '查看全部位置来源' })).toHaveClass('semi-button', 'v2-detail-source-link');
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', 'LTEST000000000001');
expect(vehicleCardArgsSpy).toHaveBeenLastCalledWith('LTEST000000000001', vehicles[0], true);

View File

@@ -10,6 +10,7 @@ import { useMonitorVehicleCard } from '../hooks/useMonitorData';
import { withMonitorReturn } from '../routing/monitorContext';
import { ProtocolTag } from '../shared/ProtocolTag';
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
function hasRealtimeLocation(vehicle: VehicleRealtimeRow) {
@@ -70,6 +71,40 @@ export default function MonitorVehicleDetailCard({
'hydrogen_concentration_percent', 'hydrogen_pressure_mpa', 'hydrogen_temperature_c',
'engine_speed_rpm', 'total_voltage_v', 'total_current_a'
].includes(item.key)).slice(0, 9);
const alertCount = activeAlerts?.total ?? 0;
const metricItems: WorkspaceQueueMetricRailItem[] = [
{
label: '速度',
value: <>{hasRealtimeSpeed(vehicle) ? formatNumber(vehicle.speedKmh, 1) : '—'}<em className="v2-monitor-detail-unit">{hasRealtimeSpeed(vehicle) ? 'km/h' : ''}</em></>,
note: '实时',
tone: 'primary',
emphasis: 'primary'
},
{
label: '今日里程',
value: <>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em className="v2-monitor-detail-unit">{dailyMileage == null ? '' : 'km'}</em></>,
note: <span className="v2-monitor-detail-action-note"><IconChevronRight /></span>,
tone: 'primary',
emphasis: 'primary',
ariaLabel: '查看今日里程全部来源',
onClick: () => setSourceEvidenceOpen(true)
},
{
label: '总里程',
value: <>{hasRealtimeMileage(vehicle) ? formatNumber(vehicle.totalMileageKm, 1) : '—'}<em className="v2-monitor-detail-unit">{hasRealtimeMileage(vehicle) ? 'km' : ''}</em></>,
note: <span className="v2-monitor-detail-action-note" title={mileageLabel}>{mileageLabel}<IconChevronRight /></span>,
emphasis: 'secondary',
ariaLabel: '查看总里程全部来源',
onClick: () => setSourceEvidenceOpen(true)
},
{
label: 'SOC',
value: <>{hasRealtimeSOC(vehicle) ? formatNumber(vehicle.socPercent, 1) : '—'}<em className="v2-monitor-detail-unit">{hasRealtimeSOC(vehicle) ? '%' : ''}</em></>,
note: '剩余电量',
tone: 'success',
emphasis: 'secondary'
}
];
return <Card className="v2-vehicle-detail" bodyStyle={{ padding: 0 }}>
<div className="v2-detail-body" role="region" aria-label={`${vehicle.plate || vehicle.vin}车辆详情`}>
@@ -110,14 +145,22 @@ export default function MonitorVehicleDetailCard({
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>
<WorkspaceMetricRail
variant="queue"
className="v2-monitor-detail-metric-rail"
ariaLabel="车辆实时状态指标"
items={metricItems}
context={<div className="v2-monitor-detail-state">
<span>
<small></small>
<Tag color={status === 'driving' ? 'blue' : status === 'idle' || status === 'online' ? 'green' : status === 'alert' ? 'red' : 'grey'} type="light" size="small">{statusLabel(status)}</Tag>
</span>
<span className={alertCount > 0 ? 'has-alerts' : ''}>
<small></small>
<strong>{formatNumber(alertCount)}<em></em></strong>
</span>
</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">

View File

@@ -122,6 +122,10 @@ describe('V2 production entry', () => {
expect(vehicleSearchSource).toContain('v2-profile-sync-panel');
expect(corePageSources.MonitorPage).toContain("lazy(() => import('./MonitorVehicleDetailCard'))");
expect(monitorVehicleDetailSource).toContain('<Card className="v2-vehicle-detail"');
expect(monitorVehicleDetailSource).toContain("from '../shared/WorkspaceMetricRail'");
expect(monitorVehicleDetailSource).toContain('<WorkspaceMetricRail');
expect(monitorVehicleDetailSource).toContain('className="v2-monitor-detail-metric-rail"');
expect(monitorVehicleDetailSource).not.toContain('className="v2-metric-grid"');
expect(corePageSources.MonitorPage).toContain('<Card className="v2-detail-peek"');
expect(corePageSources.MonitorPage).toContain('<Card className="v2-event-strip"');
expect(monitorVehicleDetailSource).toContain('<Tag className={`v2-status-text');

View File

@@ -14095,32 +14095,6 @@
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;
@@ -22949,3 +22923,170 @@
font-size: 7px;
}
}
/*
* Global-monitor vehicle inspector.
* Reuse the shared Semi metric rail inside the narrow inspector, keeping the
* four decision metrics in a 2 × 2 matrix and moving state/alerts into one
* compact evidence footer.
*/
.v2-monitor-detail-metric-rail.is-queue.semi-card {
width: calc(100% - 20px);
margin: 10px;
overflow: hidden;
border: 1px solid #dce5ef;
border-radius: 10px;
background: #fff;
}
.v2-monitor-detail-metric-rail.is-queue > .semi-card-body {
min-height: 0;
grid-template-columns: minmax(0, 1fr);
}
.v2-monitor-detail-metric-rail .v2-workspace-metric-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.v2-monitor-detail-metric-rail .v2-workspace-metric-list > span,
.v2-monitor-detail-metric-rail .v2-workspace-metric-action.semi-button,
.v2-monitor-detail-metric-rail .v2-workspace-metric-action .semi-button-content {
min-height: 58px;
}
.v2-monitor-detail-metric-rail .v2-workspace-metric-list > span,
.v2-monitor-detail-metric-rail .v2-workspace-metric-action .semi-button-content {
column-gap: 5px;
padding: 6px 10px;
}
.v2-monitor-detail-metric-rail .v2-workspace-metric-list > span.is-action {
display: block;
padding: 0;
}
.v2-monitor-detail-metric-rail .v2-workspace-metric-list > span:nth-child(odd) {
border-left: 0;
}
.v2-monitor-detail-metric-rail .v2-workspace-metric-list > span:nth-child(n + 3) {
border-top: 1px solid #e5ebf2;
}
.v2-monitor-detail-metric-rail .v2-workspace-metric-list > span > strong,
.v2-monitor-detail-metric-rail .v2-workspace-metric-action .semi-button-content > strong,
.v2-monitor-detail-metric-rail .v2-workspace-metric-list > span.is-secondary > strong,
.v2-monitor-detail-metric-rail .v2-workspace-metric-list > span.is-secondary .v2-workspace-metric-action strong {
overflow: hidden;
font-size: 16px;
line-height: 20px;
text-overflow: ellipsis;
}
.v2-monitor-detail-metric-rail .v2-workspace-metric-list > span > em,
.v2-monitor-detail-metric-rail .v2-workspace-metric-action .semi-button-content > em {
max-width: 62px;
font-size: 8px;
}
.v2-monitor-detail-unit {
margin-left: 3px;
color: #8795a7;
font-size: 7px;
font-style: normal;
font-weight: 550;
letter-spacing: 0;
}
.v2-monitor-detail-metric-rail .v2-workspace-metric-list > span.is-primary .v2-monitor-detail-unit {
color: #7092c0;
}
.v2-monitor-detail-action-note {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 2px;
overflow: hidden;
text-overflow: ellipsis;
}
.v2-monitor-detail-action-note > .semi-icon {
flex: 0 0 auto;
font-size: 8px;
}
.v2-monitor-detail-metric-rail .v2-workspace-metric-context {
display: flex;
min-height: 40px;
border-top: 1px solid #e5ebf2;
border-left: 0;
padding: 0;
}
.v2-monitor-detail-state {
display: grid;
width: 100%;
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.v2-monitor-detail-state > span {
display: flex;
min-width: 0;
min-height: 40px;
align-items: center;
justify-content: space-between;
gap: 5px;
padding: 6px 10px;
}
.v2-monitor-detail-state > span + span {
border-left: 1px solid #e5ebf2;
}
.v2-monitor-detail-state small {
color: #78879a;
font-size: 8px;
font-weight: 650;
white-space: nowrap;
}
.v2-monitor-detail-state .semi-tag {
min-width: 0;
max-width: 70px;
overflow: hidden;
text-overflow: ellipsis;
}
.v2-monitor-detail-state strong {
color: #30465e;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.v2-monitor-detail-state strong > em {
margin-left: 2px;
color: #8492a4;
font-size: 7px;
font-style: normal;
font-weight: 550;
}
.v2-monitor-detail-state > .has-alerts strong {
color: #c2413d;
}
@media (max-width: 680px) {
.v2-monitor-detail-metric-rail .v2-workspace-metric-list > span.is-secondary {
display: grid;
}
.v2-monitor-detail-metric-rail .v2-workspace-metric-list > span.is-secondary.is-action {
display: block;
}
.v2-monitor-detail-metric-rail .v2-workspace-metric-context {
display: flex;
}
}