From acc29c38739f6772e870328dbad641d2d5534297 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 17:14:34 +0800 Subject: [PATCH] feat(vehicle): prioritize live status --- .../web/src/v2/pages/VehiclePage.test.tsx | 41 ++++++++++ .../apps/web/src/v2/pages/VehiclePage.tsx | 79 ++++++++++++++++--- .../apps/web/src/v2/styles/v2.css | 37 +++++++-- 3 files changed, 140 insertions(+), 17 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 afc37895..1effd5f0 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 @@ -83,6 +83,7 @@ test('keeps the monitor return on the vehicle page and its nested investigation vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail); vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [initialRealtime], total: 1, limit: 1, offset: 0 }); vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never); + const addressSpy = vi.spyOn(api, 'reverseGeocode').mockResolvedValue({ provider: 'amap', longitude: 113.26, latitude: 23.13, formattedAddress: '广东省广州市测试道路' }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const monitorPath = buildMonitorPath({ mode: 'map', keyword: '', protocol: '', status: '', selectedVin: 'LTEST000000000001', detailOpen: true, @@ -95,6 +96,46 @@ test('keeps the monitor return on the vehicle page and its nested investigation expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath); const trackLink = screen.getByRole('link', { name: /轨迹回放/ }); const historyLink = screen.getByRole('link', { name: /历史数据/ }); + const mileageLink = screen.getByRole('link', { name: /里程查询/ }); expect(new URL(trackLink.getAttribute('href')!, 'http://localhost').searchParams.get('monitorReturn')).toBe(monitorPath); expect(new URL(historyLink.getAttribute('href')!, 'http://localhost').searchParams.get('monitorReturn')).toBe(monitorPath); + expect(new URL(mileageLink.getAttribute('href')!, 'http://localhost').searchParams.get('monitorReturn')).toBe(monitorPath); + + const liveOverview = screen.getByText('最新上报').closest('section'); + const archive = screen.getByText('车辆主档').closest('section'); + expect(liveOverview).not.toBeNull(); + expect(archive).not.toBeNull(); + expect(liveOverview!.compareDocumentPosition(archive!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + + expect(addressSpy).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '解析当前位置' })); + expect(await screen.findByText('广东省广州市测试道路')).toBeInTheDocument(); + expect(addressSpy).toHaveBeenCalledTimes(1); +}); + +test('shows unavailable live fields as dashes instead of fabricated zeroes', async () => { + const unavailable = { + ...initialRealtime, + speedAvailable: false, + speedKmh: 0, + socAvailable: false, + socPercent: 0, + mileageAvailable: false, + totalMileageKm: 0, + todayMileageAvailable: false, + todayMileageKm: 0 + }; + vi.spyOn(api, 'vehicleDetail').mockResolvedValue({ ...detail, realtimeSummary: unavailable }); + vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [unavailable], total: 1, limit: 1, offset: 0 }); + vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + render(} />); + + const liveOverview = (await screen.findByText('最新上报')).closest('section')!; + for (const label of ['速度', 'SOC', '总里程', '当日里程']) { + const metric = Array.from(liveOverview.querySelectorAll('.v2-live-grid > div')).find((item) => item.querySelector('small')?.textContent === label); + expect(metric).toHaveTextContent('—'); + expect(metric).not.toHaveTextContent(/^0/); + } }); 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 79f1bcdf..bf30378a 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx @@ -8,11 +8,12 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { api } from '../../api/client'; import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult, VehicleRealtimeRow } from '../../api/types'; import { usePlatformSession } from '../auth/AuthGate'; -import { canAdminister } from '../auth/session'; +import { canAdminister, hasMenu } from '../auth/session'; import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy'; import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from '../domain/telemetry'; import { formatZhNumber } from '../domain/formatters'; import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync'; +import { isValidAMapCoordinate } from '../../integrations/amap'; import { FleetMap } from '../map/FleetMap'; import { InlineError, PageLoading } from '../shared/AsyncState'; import { MonitorReturnBar } from '../shared/MonitorReturnBar'; @@ -21,7 +22,7 @@ import { monitorReturnFromParams, withMonitorReturn } from '../routing/monitorCo function fmt(value?: string) { return value?.trim() || '—'; } function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? formatZhNumber(value, 1) : fallback; } -function timeOnly(value?: string) { if (!value) return '—'; const parts = value.split(' '); return parts[parts.length - 1] || value; } +function availableMetric(value: number | undefined, available?: boolean) { return available === false ? '—' : metric(value); } function issueTone(issue: QualityIssueRow) { return issue.severity === 'error' ? 'error' : 'warning'; } function durationHours(seconds?: number | null) { return seconds == null ? '—' : `${formatZhNumber(seconds / 3600, 1)} 小时`; } function localDateTime(value?: string) { return value ? value.slice(0, 16) : ''; } @@ -174,30 +175,84 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon ; } +function CurrentVehicleAddress({ vehicle, fallback }: { vehicle?: VehicleRealtimeRow; fallback?: string }) { + const coordinate = vehicle && vehicle.locationAvailable !== false && isValidAMapCoordinate(vehicle.longitude, vehicle.latitude) + ? { longitude: Number(vehicle.longitude.toFixed(4)), latitude: Number(vehicle.latitude.toFixed(4)) } + : undefined; + const coordinateKey = coordinate ? `${coordinate.longitude.toFixed(4)},${coordinate.latitude.toFixed(4)}` : ''; + const [requestedKey, setRequestedKey] = useState(''); + const requested = requestedKey ? requestedKey.split(',').map(Number) : []; + const address = useQuery({ + queryKey: ['vehicle-detail-address', requestedKey], + enabled: requested.length === 2, + queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({ + longitude: requested[0].toFixed(4), + latitude: requested[1].toFixed(4) + }), signal), + staleTime: 6 * 60 * 60_000, + gcTime: QUERY_MEMORY.highVolumeGcTime, + refetchOnWindowFocus: false, + retry: 1 + }); + const moved = Boolean(coordinateKey && requestedKey && coordinateKey !== requestedKey); + + return
+ 当前位置{coordinate ? `${vehicle!.longitude.toFixed(6)}, ${vehicle!.latitude.toFixed(6)}` : '—'} + 地理位置{!coordinate + ? 暂无有效实时坐标 + : !requestedKey + ? + : address.isFetching && !address.data + ? 地址解析中… + : address.isError + ? + : {address.data?.formattedAddress || fallback || '暂无地址结果'}} + {moved ? : null} + +
; +} + function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, telemetryError, monitorReturn, onUpdated }: { detail: VehicleDetail; liveRealtime?: VehicleRealtimeRow; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; monitorReturn: string; onUpdated: () => void }) { const { session } = usePlatformSession(); const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false); const realtime = liveRealtime ?? detail.realtimeSummary; const identity = detail.identity; - const mapVehicles = realtime ? [realtime] : []; + const hasLocation = Boolean(realtime && realtime.locationAvailable !== false && isValidAMapCoordinate(realtime.longitude, realtime.latitude)); + const mapVehicles = hasLocation && realtime ? [realtime] : []; const lastMileage = detail.mileage.items[0]; return
{fmt(identity?.plate || realtime?.plate)}{realtime?.online ? '在线' : '离线'}VIN{detail.vin}
-
接入来源

{detail.sources.map((source) => {source})}

最后上报时间{fmt(realtime?.lastSeen || identity?.lastSeen)}
-
轨迹回放历史数据告警事件
+
统一车辆身份{fmt(identity?.oem || realtime?.oem)}
可用数据来源

{detail.sources.length ? detail.sources.map((source) => {source}) : 暂无来源}

+
+ {hasMenu(session, 'tracks') ? 轨迹回放 : null} + {hasMenu(session, 'history') ? 历史数据 : null} + {hasMenu(session, 'statistics') ? 里程查询 : null} + {hasMenu(session, 'alerts') ? 告警事件 : null} +
+
+
最新上报{realtime?.online ? '实时在线' : '当前离线'} · {fmt(realtime?.lastSeen || identity?.lastSeen)}
+
+
速度{availableMetric(realtime?.speedKmh, realtime?.speedAvailable)}km/h
+
SOC{availableMetric(realtime?.socPercent, realtime?.socAvailable)}%
+
总里程
+
当日里程
+
推荐来源{fmt(realtime?.primaryProtocol)}{realtime?.locationSource ? ` · ${realtime.locationSource}` : ''}
+
来源状态{realtime?.onlineSourceCount ?? 0} 在线 / {detail.sourceStatus.length} 个
+
+ +
+ + +
-
undefined} />
- -
实时指标{timeOnly(realtime?.lastSeen)}
-
速度{metric(realtime?.speedKmh)}km/h
SOC{metric(realtime?.socPercent)}%
总里程
当日里程
在线来源{realtime?.onlineSourceCount ?? 0}
数据源总数{detail.sourceStatus.length}
-
- - +
undefined} />
+ +
; } 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 37811b9c..2e1dcd98 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -435,7 +435,7 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-identity-actions a { display: inline-flex; height: 38px; align-items: center; gap: 7px; border: 1px solid #dce4ef; border-radius: 7px; padding: 0 14px; color: #536177; text-decoration: none; white-space: nowrap; font-size: 10px; font-weight: 700; } .v2-identity-actions a:hover { border-color: #a8c7f9; color: var(--v2-blue); } -.v2-record-grid { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(520px, 1.75fr) minmax(300px, 1fr); grid-template-rows: minmax(300px, 1.05fr) auto minmax(270px, .95fr); gap: 12px; } +.v2-record-grid { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(520px, 1.75fr) minmax(300px, 1fr); grid-template-rows: minmax(300px, 1.05fr) minmax(270px, .95fr); gap: 12px; } .v2-record-card, .v2-single-map-card { min-width: 0; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); } .v2-record-card > header { display: flex; height: 40px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 14px; } .v2-record-card > header strong { font-size: 12px; } @@ -447,7 +447,7 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-single-map-card footer span { display: inline-flex; min-width: 0; align-items: center; gap: 7px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .v2-map-source-link { display: inline-flex; min-width: 0; align-items: center; gap: 7px; overflow: hidden; border: 0; background: transparent; padding: 0; color: #64748b; cursor: pointer; text-align: left; text-overflow: ellipsis; white-space: nowrap; font: inherit; } .v2-single-map-card footer time { white-space: nowrap; font-variant-numeric: tabular-nums; } -.v2-archive-card { grid-column: 2; grid-row: 1; } +.v2-archive-card { grid-column: 2; grid-row: 2; } .v2-record-list { margin: 0; padding: 8px 14px 4px; } .v2-record-list > div { display: grid; grid-template-columns: 92px minmax(0, 1fr); padding: 6px 0; font-size: 10px; } .v2-record-list dt { color: var(--v2-muted); } @@ -456,18 +456,32 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-record-list dd i.is-online { background: var(--v2-green); } .v2-record-note { margin: 3px 14px 12px; border-radius: 6px; background: #f7f9fc; padding: 8px 10px; color: #79869a; font-size: 8px; line-height: 1.5; } .v2-profile-heading { display: flex; align-items: center; gap: 8px; }.v2-profile-heading button { border: 0; border-radius: 5px; background: var(--v2-blue-soft); padding: 4px 7px; color: var(--v2-blue); font-size: 9px; cursor: pointer; }.v2-profile-form { display: grid; grid-template-columns: 1fr 1fr; gap: 7px 10px; padding: 10px 14px 12px; }.v2-profile-form label { display: grid; gap: 3px; color: var(--v2-muted); font-size: 8px; }.v2-profile-form input, .v2-profile-form select { min-width: 0; height: 29px; border: 1px solid var(--v2-border); border-radius: 5px; background: #fff; padding: 0 7px; color: var(--v2-text); font-size: 9px; }.v2-profile-form > p { grid-column: 1 / -1; margin: 0; color: var(--v2-red); font-size: 8px; }.v2-profile-form footer { display: flex; grid-column: 1 / -1; justify-content: flex-end; gap: 7px; }.v2-profile-form footer button { height: 28px; border: 1px solid var(--v2-border); border-radius: 5px; background: #fff; padding: 0 10px; font-size: 9px; cursor: pointer; }.v2-profile-form footer button.is-primary { border-color: var(--v2-blue); background: var(--v2-blue); color: #fff; } -.v2-live-card { grid-column: 2; grid-row: 2; } .v2-live-card > header span { display: inline-flex; align-items: center; gap: 5px; } +.v2-live-card > header span > i { width: 7px; height: 7px; border-radius: 50%; background: #aab3c0; } +.v2-live-card > header span > i.is-online { background: var(--v2-green); box-shadow: 0 0 0 4px rgba(26,160,109,.1); } +.v2-live-overview { border-color: #cfddf0; background: linear-gradient(135deg,#fff 0%,#fbfdff 72%,#f3f8ff 100%); } +.v2-live-overview .v2-live-grid { grid-template-columns: repeat(6,minmax(0,1fr)); } .v2-live-grid { display: grid; grid-template-columns: repeat(3, 1fr); } .v2-live-grid > div { min-width: 0; padding: 12px 14px; } .v2-live-grid > div + div { border-left: 1px solid var(--v2-border); } .v2-live-grid > div:nth-child(4) { border-left: 0; } .v2-live-grid > div:nth-child(n+4) { border-top: 1px solid var(--v2-border); } +.v2-live-overview .v2-live-grid > div:nth-child(4) { border-left: 1px solid var(--v2-border); } +.v2-live-overview .v2-live-grid > div:nth-child(n+4) { border-top: 0; } .v2-live-grid small { display: block; color: var(--v2-muted); font-size: 9px; } .v2-live-grid strong { display: block; margin-top: 5px; overflow: hidden; font-size: 17px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; } +.v2-live-grid strong.is-text { font-size: 14px; } .v2-live-source-link { margin-top: 5px; font-size: 17px; font-variant-numeric: tabular-nums; } .v2-live-grid em { margin-left: 3px; color: var(--v2-muted); font-size: 8px; font-style: normal; font-weight: 500; } -.v2-telemetry-card { grid-column: 1; grid-row: 2 / span 2; } +.v2-current-location { display: grid; min-height: 52px; grid-template-columns: minmax(280px,.8fr) minmax(420px,1.2fr); border-top: 1px solid var(--v2-border); } +.v2-current-location > span { display: grid; min-width: 0; grid-template-columns: auto auto minmax(0,1fr); align-items: center; gap: 8px; padding: 8px 14px; color: #64748b; } +.v2-current-location > span + span { border-left: 1px solid var(--v2-border); } +.v2-current-location small { color: var(--v2-muted); font-size: 9px; white-space: nowrap; } +.v2-current-location strong { min-width: 0; overflow: hidden; color: #3d4c62; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; } +.v2-current-location button { width: max-content; max-width: 100%; height: 28px; overflow: hidden; border: 1px solid #bfd4f6; border-radius: 6px; background: #f3f7ff; padding: 0 9px; color: var(--v2-blue); cursor: pointer; text-overflow: ellipsis; white-space: nowrap; font-size: 9px; } +.v2-current-location button.is-error { border-color: #edb5b0; background: #fff; color: var(--v2-red); } +.v2-current-address { grid-template-columns: auto minmax(0,1fr) auto !important; } +.v2-telemetry-card { grid-column: 1; grid-row: 2; } .v2-telemetry-card nav { display: flex; height: 42px; align-items: stretch; border-bottom: 1px solid var(--v2-border); padding: 0 8px; overflow-x: auto; } .v2-telemetry-card nav button { position: relative; min-width: 78px; border: 0; background: transparent; color: #64748b; cursor: pointer; font-size: 10px; } .v2-telemetry-card nav button.is-active { color: var(--v2-blue); font-weight: 700; } @@ -490,7 +504,7 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-telemetry-card footer { display: flex; align-items: center; gap: 6px; margin: 8px 10px 10px; border: 1px solid var(--v2-border); border-radius: 7px; padding: 8px 10px; color: #718096; font-size: 9px; } .v2-telemetry-card footer b { color: #526176; font-size: 9px; } .v2-telemetry-card footer small { margin-left: auto; color: #8a96a8; font-size: 8px; } -.v2-events-card { grid-column: 2; grid-row: 3; } +.v2-events-card { grid-column: 2; grid-row: 1; } .v2-events-card > header a { color: var(--v2-blue); } .v2-event-list { padding: 2px 14px 8px; } .v2-event-row { position: relative; display: grid; min-height: 40px; grid-template-columns: 23px minmax(0, 1fr) auto; align-items: center; gap: 7px; } @@ -1020,6 +1034,10 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-identity-band { grid-template-columns: minmax(240px, 1fr) minmax(300px, 1fr); } .v2-identity-actions { grid-column: 1 / -1; justify-content: flex-end; border-top: 1px solid var(--v2-border); } .v2-record-grid { grid-template-columns: minmax(460px, 1.5fr) minmax(280px, 1fr); } + .v2-live-overview .v2-live-grid { grid-template-columns: repeat(3,minmax(0,1fr)); } + .v2-live-overview .v2-live-grid > div:nth-child(4) { border-left: 0; } + .v2-live-overview .v2-live-grid > div:nth-child(n+4) { border-top: 1px solid var(--v2-border); } + .v2-current-location { grid-template-columns: minmax(260px,.8fr) minmax(320px,1.2fr); } .v2-track-toolbar { grid-template-columns: minmax(210px, 1fr) repeat(3, minmax(140px, .7fr)); } .v2-track-toolbar > button { min-width: 100px; } .v2-track-workspace { grid-template-columns: minmax(500px, 1fr) 290px; } @@ -1058,6 +1076,11 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-identity-actions { grid-column: auto; justify-content: stretch; border: 0; } .v2-identity-actions a { flex: 1; justify-content: center; } .v2-record-grid { display: flex; flex-direction: column; } + .v2-live-overview .v2-live-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } + .v2-live-overview .v2-live-grid > div:nth-child(odd) { border-left: 0; } + .v2-live-overview .v2-live-grid > div:nth-child(n+3) { border-top: 1px solid var(--v2-border); } + .v2-current-location { grid-template-columns: 1fr; } + .v2-current-location > span + span { border-top: 1px solid var(--v2-border); border-left: 0; } .v2-single-map-card { min-height: 360px; } .v2-archive-card, .v2-live-card, .v2-telemetry-card, .v2-events-card { grid-area: auto; } .v2-track-page { height: auto; min-height: 100%; overflow: auto; } @@ -1340,8 +1363,12 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-telemetry-list { grid-template-columns: 1fr; } .v2-telemetry-list > div:nth-child(odd) { border-right: 0; } .v2-live-grid { grid-template-columns: repeat(2, 1fr); } + .v2-live-overview .v2-live-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } .v2-live-grid > div:nth-child(3), .v2-live-grid > div:nth-child(5) { border-left: 0; } .v2-live-grid > div:nth-child(n+3) { border-top: 1px solid var(--v2-border); } + .v2-current-location > span { grid-template-columns: auto auto minmax(0,1fr); padding: 10px 12px; } + .v2-current-address { grid-template-columns: auto minmax(0,1fr) !important; } + .v2-current-address > button:last-child { grid-column: 2; } .v2-track-page { padding: 8px; } .v2-track-toolbar { grid-template-columns: 1fr; } .v2-track-vehicle-input { grid-column: auto; }