feat(vehicle): prioritize live status

This commit is contained in:
lingniu
2026-07-16 17:14:34 +08:00
parent 3e5c5772df
commit acc29c3873
3 changed files with 140 additions and 17 deletions

View File

@@ -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
</section>;
}
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 <div className="v2-current-location">
<span><IconMapPin /><small></small><strong>{coordinate ? `${vehicle!.longitude.toFixed(6)}, ${vehicle!.latitude.toFixed(6)}` : '—'}</strong></span>
<span className="v2-current-address"><small></small>{!coordinate
? <strong></strong>
: !requestedKey
? <button type="button" onClick={() => setRequestedKey(coordinateKey)}></button>
: address.isFetching && !address.data
? <strong></strong>
: address.isError
? <button type="button" className="is-error" onClick={() => void address.refetch()}></button>
: <strong title={address.data?.formattedAddress}>{address.data?.formattedAddress || fallback || '暂无地址结果'}</strong>}
{moved ? <button type="button" onClick={() => setRequestedKey(coordinateKey)}> · </button> : null}
</span>
</div>;
}
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 <div className="v2-vehicle-record-page">
<MonitorReturnBar />
<section className="v2-identity-band">
<div className="v2-identity-primary"><span className="v2-plate"><IconBox />{fmt(identity?.plate || realtime?.plate)}</span><span className={`v2-online-label ${realtime?.online ? 'is-online' : ''}`}><i />{realtime?.online ? '在线' : '离线'}</span><small>VIN</small><b>{detail.vin}</b><button type="button" title="复制 VIN" onClick={() => navigator.clipboard?.writeText(detail.vin)}><IconCopy /></button></div>
<div className="v2-identity-meta"><div><small></small><p>{detail.sources.map((source) => <span key={source}>{source}</span>)}</p></div><div><small></small><strong>{fmt(realtime?.lastSeen || identity?.lastSeen)}</strong></div></div>
<div className="v2-identity-actions"><Link to={withMonitorReturn(`/tracks?vin=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconMapPin /></Link><Link to={withMonitorReturn(`/history?vin=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconCalendar /></Link><Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><IconAlarm /></Link></div>
<div className="v2-identity-meta"><div><small></small><strong>{fmt(identity?.oem || realtime?.oem)}</strong></div><div><small></small><p>{detail.sources.length ? detail.sources.map((source) => <span key={source}>{source}</span>) : <span></span>}</p></div></div>
<div className="v2-identity-actions">
{hasMenu(session, 'tracks') ? <Link to={withMonitorReturn(`/tracks?vin=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconMapPin /></Link> : null}
{hasMenu(session, 'history') ? <Link to={withMonitorReturn(`/history?vin=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconCalendar /></Link> : null}
{hasMenu(session, 'statistics') ? <Link to={withMonitorReturn(`/statistics?vins=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconClock /></Link> : null}
{hasMenu(session, 'alerts') ? <Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><IconAlarm /></Link> : null}
</div>
</section>
<section className="v2-record-card v2-live-card v2-live-overview">
<header><strong></strong><span><i className={realtime?.online ? 'is-online' : ''} />{realtime?.online ? '实时在线' : '当前离线'} · <IconClock />{fmt(realtime?.lastSeen || identity?.lastSeen)}</span></header>
<div className="v2-live-grid">
<div><small></small><strong>{availableMetric(realtime?.speedKmh, realtime?.speedAvailable)}<em>km/h</em></strong></div>
<div><small>SOC</small><strong>{availableMetric(realtime?.socPercent, realtime?.socAvailable)}<em>%</em></strong></div>
<div><small></small><button type="button" className="v2-live-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{availableMetric(realtime?.totalMileageKm, realtime?.mileageAvailable)}<em>km</em></button></div>
<div><small></small><button type="button" className="v2-live-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{availableMetric(realtime?.todayMileageKm ?? lastMileage?.dailyMileageKm, realtime?.todayMileageAvailable)}<em>km</em></button></div>
<div><small></small><strong className="is-text">{fmt(realtime?.primaryProtocol)}<em>{realtime?.locationSource ? ` · ${realtime.locationSource}` : ''}</em></strong></div>
<div><small></small><strong>{realtime?.onlineSourceCount ?? 0}<em> 线 / {detail.sourceStatus.length} </em></strong></div>
</div>
<CurrentVehicleAddress vehicle={realtime} fallback={identity?.locationText} />
</section>
<VehicleSourceEvidencePanel vin={detail.vin} open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
<div className="v2-record-grid">
<section className="v2-single-map-card"><FleetMap vehicles={mapVehicles} selectedVin={detail.vin} onSelect={() => undefined} /><footer><button type="button" className="v2-map-source-link" title="展开全部位置来源" onClick={() => setSourceEvidenceOpen(true)}><IconMapPin />{realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</button><time>{fmt(realtime?.lastSeen)}</time></footer></section>
<Archive detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} />
<section className="v2-record-card v2-live-card"><header><strong></strong><span><IconClock />{timeOnly(realtime?.lastSeen)}</span></header><div className="v2-live-grid">
<div><small></small><strong>{metric(realtime?.speedKmh)}<em>km/h</em></strong></div><div><small>SOC</small><strong>{metric(realtime?.socPercent)}<em>%</em></strong></div><div><small></small><button type="button" className="v2-live-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{metric(realtime?.totalMileageKm)}<em>km</em></button></div><div><small></small><button type="button" className="v2-live-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{metric(lastMileage?.dailyMileageKm)}<em>km</em></button></div><div><small>线</small><strong>{realtime?.onlineSourceCount ?? 0}<em></em></strong></div><div><small></small><strong>{detail.sourceStatus.length}<em></em></strong></div>
</div></section>
<VehicleSourceEvidencePanel vin={detail.vin} open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
<TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} />
<section className="v2-single-map-card"><FleetMap vehicles={mapVehicles} selectedVin={hasLocation ? detail.vin : undefined} onSelect={() => undefined} /><footer><button type="button" className="v2-map-source-link" title="展开全部位置来源" onClick={() => setSourceEvidenceOpen(true)}><IconMapPin />{hasLocation && realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</button><time>{fmt(realtime?.lastSeen)}</time></footer></section>
<Events detail={detail} />
<TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} />
<Archive detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} />
</div>
</div>;
}