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

@@ -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(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
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/);
}
});

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>;
}

View File

@@ -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; }