feat(platform): consolidate production vehicle data workflows

This commit is contained in:
lingniu
2026-07-15 23:26:29 +08:00
parent 6cddc0a43d
commit 3fabcf181a
59 changed files with 6849 additions and 3532 deletions

View File

@@ -17,9 +17,10 @@ describe('access domain helpers', () => {
});
it('exports explicit state and evidence fields', () => {
const csv = accessRowsToCSV([{ vin: 'VIN1', plate: '粤A1', oem: '', model: '', company: '示范企业', protocol: 'JT808', provider: '', source: '', firstSeenAt: '', latestEventAt: '', latestReceivedAt: '', reportIntervalSec: null, dataDelaySec: 2, freshnessSec: 3, onlineState: 'online', thresholdSec: 60, latestMessageType: '位置,数据', latestEventId: '', latestError: '', delayAbnormal: false, firstSeenEvidence: '', firstSeenSource: '', reportIntervalEvidence: '', reportSampleCount: 2 }]);
const csv = accessRowsToCSV([{ vin: 'VIN1', plate: '粤A1', oem: '', model: '', company: '示范企业', protocol: 'JT808', provider: '', source: '', firstSeenAt: '', latestEventAt: '', latestReceivedAt: '', reportIntervalSec: null, dataDelaySec: 2, freshnessSec: 3, onlineState: 'online', thresholdSec: 60, latestMessageType: '位置,数据', latestEventId: '', latestError: '', delayAbnormal: false, firstSeenEvidence: '', firstSeenSource: '', reportIntervalEvidence: '', reportSampleCount: 2, expectedProtocols: ['GB32960', 'JT808', 'YUTONG_MQTT'], actualProtocols: ['JT808'], missingProtocols: ['GB32960', 'YUTONG_MQTT'], protocolStatuses: [{ protocol: 'JT808', expected: true, connected: true, provider: 'G7', firstSeenAt: '2026-07-01T00:00:00+08:00', latestEventAt: '', latestReceivedAt: '2026-07-15T09:00:00+08:00', reportIntervalSec: 10, dataDelaySec: 2, freshnessSec: 3, onlineState: 'online', thresholdSec: 60, delayAbnormal: false, firstSeenEvidence: '网关首次观测', reportIntervalEvidence: '连续样本' }], connectionState: 'incomplete', expectationEvidence: '平台标准接入基线' }]);
expect(csv).toContain('在线');
expect(csv).toContain('"位置,数据"');
expect(csv).toContain('"GB32960/YUTONG_MQTT"');
expect(csv).toContain('"2026-07-15T09:00:00+08:00"');
expect(csv).toContain('"示范企业"');
});
});

View File

@@ -38,8 +38,15 @@ export function updateProtocolThreshold(items: AccessProtocolThreshold[], protoc
}
export function accessRowsToCSV(rows: AccessVehicleRow[]) {
const columns = ['在线状态', '车牌', 'VIN', '厂家', '车型', '企业', '协议', '接入厂家', '首次接入', '首次接入证据', '最新事件时间', '最新接收时间', '上报间隔(秒)', '持久样本数', '上报间隔证据', '数据延迟(秒)', '动态阈值(秒)', '最新消息类型', '最近错误'];
const protocolColumns = ['GB32960', 'JT808', 'YUTONG_MQTT'].flatMap((protocol) => [`${protocol}接入状态`, `${protocol}接入厂家`, `${protocol}首次接入`, `${protocol}最新上报`, `${protocol}离线秒数`]);
const columns = ['综合状态', '车牌', 'VIN', '品牌', '车型', '企业', '应接协议', '实际接入协议', '缺失协议', ...protocolColumns];
const quote = (value: unknown) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const lines = rows.map((row) => [accessStateLabels[row.onlineState], row.plate, row.vin, row.oem, row.model, row.company, row.protocol, row.provider, row.firstSeenAt, row.firstSeenEvidence, row.latestEventAt, row.latestReceivedAt, row.reportIntervalSec, row.reportSampleCount, row.reportIntervalEvidence, row.dataDelaySec, row.thresholdSec, row.latestMessageType, row.latestError].map(quote).join(','));
const lines = rows.map((row) => {
const protocolValues = ['GB32960', 'JT808', 'YUTONG_MQTT'].flatMap((protocol) => {
const status = row.protocolStatuses.find((item) => item.protocol === protocol);
return [status?.connected ? accessStateLabels[status.onlineState] : '未接入', status?.provider, status?.firstSeenAt, status?.latestReceivedAt, status?.freshnessSec];
});
return [row.connectionState, row.plate, row.vin, row.oem, row.model, row.company, row.expectedProtocols.join('/'), row.actualProtocols.join('/'), row.missingProtocols.join('/'), ...protocolValues].map(quote).join(',');
});
return `\uFEFF${columns.map(quote).join(',')}\n${lines.join('\n')}`;
}

View File

@@ -0,0 +1,38 @@
import { expect, test } from 'vitest';
import { createMileageWorkbook } from './mileageExport';
test('creates a styled numeric mileage workbook with formulas and frozen panes', async () => {
const workbook = await createMileageWorkbook({
dateFrom: '2026-07-13',
dateTo: '2026-07-14',
dates: ['2026-07-13', '2026-07-14'],
vehicles: [
{ vin: 'LTEST000000000001', plate: '粤A12345' },
{ vin: 'LTEST000000000002', plate: '粤A54321' }
],
mileageRows: [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 188.7, endMileageKm: 293.3, dailyMileageKm: 104.6, source: 'GB32960' }
],
sources: [
{ protocol: 'GB32960', label: '国标 GB32960', mileageType: '仪表盘里程' },
{ protocol: 'JT808', label: '交通部 JT/T 808', mileageType: 'GPS 里程' }
],
exportedAt: new Date('2026-07-15T08:00:00+08:00')
});
const sheet = workbook.getWorksheet('里程查询')!;
expect(sheet.getCell('A1').value).toBe('车辆里程查询明细');
expect(sheet.getCell('C6').value).toBeInstanceOf(Date);
expect(sheet.getCell('C6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' });
expect(sheet.getCell('D6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' });
expect(sheet.getCell('A6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'left' });
expect(sheet.getCell('E6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'right' });
expect(sheet.getCell('C7').value).toBe(88.7);
expect(sheet.getCell('E7').value).toMatchObject({ formula: 'SUM(C7:D7)', result: 193.3 });
expect(sheet.getCell('E7').numFmt).toBe('#,##0.0" km"');
expect(sheet.views[0]).toMatchObject({ state: 'frozen', xSplit: 2, ySplit: 6, showGridLines: false });
expect(sheet.autoFilter).toEqual({ from: { row: 6, column: 1 }, to: { row: 8, column: 5 } });
expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1);
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
});

View File

@@ -0,0 +1,161 @@
import type { DailyMileageRow } from '../../api/types';
export type MileageExportVehicle = { vin: string; plate: string };
export type MileageExportSource = { protocol: string; label: string; mileageType: string };
export type MileageExportInput = {
dateFrom: string;
dateTo: string;
dates: string[];
vehicles: MileageExportVehicle[];
mileageRows: DailyMileageRow[];
sources: MileageExportSource[];
exportedAt?: Date;
};
function excelColumn(index: number) {
let value = index;
let result = '';
while (value > 0) {
value -= 1;
result = String.fromCharCode(65 + value % 26) + result;
value = Math.floor(value / 26);
}
return result;
}
function localDateTime(value: Date) {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).format(value).split('/').join('-');
}
export async function createMileageWorkbook(input: MileageExportInput) {
const { Workbook } = await import('exceljs');
const workbook = new Workbook();
workbook.creator = '灵牛车辆数据中台';
workbook.company = '灵牛科技';
workbook.created = input.exportedAt ?? new Date();
workbook.modified = input.exportedAt ?? new Date();
workbook.calcProperties.fullCalcOnLoad = true;
const sheet = workbook.addWorksheet('里程查询', {
views: [{ state: 'frozen', xSplit: 2, ySplit: 6, activeCell: 'C7', showGridLines: false }],
pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0, paperSize: 9, margins: { left: .25, right: .25, top: .45, bottom: .45, header: .2, footer: .2 } },
properties: { defaultRowHeight: 21 }
});
const firstDateColumn = 3;
const lastDateColumn = firstDateColumn + input.dates.length - 1;
const totalColumn = lastDateColumn + 1;
const lastColumnLetter = excelColumn(totalColumn);
const headerRowNumber = 6;
const firstDataRow = headerRowNumber + 1;
const lastDataRow = firstDataRow + input.vehicles.length - 1;
const exportedAt = input.exportedAt ?? new Date();
const sourceDescription = input.sources.map((source, index) => `${index + 1}. ${source.label}${source.mileageType}`).join(' ');
const title = sheet.getCell('A1');
title.value = '车辆里程查询明细';
title.font = { name: 'Microsoft YaHei', size: 18, bold: true, color: { argb: 'FF17345C' } };
title.alignment = { vertical: 'middle', horizontal: 'left' };
title.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F1FC' } };
for (let column = 1; column <= totalColumn; column += 1) {
const cell = sheet.getCell(1, column);
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F1FC' } };
cell.border = { bottom: { style: 'medium', color: { argb: 'FFBDD0E8' } } };
}
sheet.getRow(1).height = 38;
sheet.getRow(2).values = ['日期范围', `${input.dateFrom}${input.dateTo}`, '车辆范围', `${input.vehicles.length}`];
sheet.getRow(3).values = ['来源优先级', sourceDescription];
const periodTotal = input.mileageRows.reduce((sum, row) => sum + (Number.isFinite(row.dailyMileageKm) ? row.dailyMileageKm : 0), 0);
sheet.getRow(4).values = ['区间总里程', null, '有效车辆日', `${input.mileageRows.length}`];
sheet.getCell('B4').value = input.vehicles.length ? { formula: `SUM(${lastColumnLetter}${firstDataRow}:${lastColumnLetter}${lastDataRow})`, result: periodTotal } : 0;
sheet.getCell('B4').numFmt = '#,##0.0" km"';
for (let rowNumber = 2; rowNumber <= 4; rowNumber += 1) {
const row = sheet.getRow(rowNumber);
row.height = 25;
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
cell.font = { name: 'Microsoft YaHei', size: 10, bold: columnNumber === 1 || columnNumber === 3, color: { argb: columnNumber === 1 || columnNumber === 3 ? 'FF53657D' : 'FF213149' } };
cell.alignment = { vertical: 'middle', horizontal: columnNumber === 1 || columnNumber === 3 ? 'left' : 'right' };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: rowNumber % 2 ? 'FFF8FAFD' : 'FFF2F6FB' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE1E8F1' } } };
});
}
sheet.getRow(5).height = 8;
const header = sheet.getRow(headerRowNumber);
header.values = ['车牌', 'VIN', ...input.dates.map((date) => new Date(`${date}T12:00:00Z`)), '区间总里程'];
header.height = 30;
header.eachCell((cell, columnNumber) => {
const isDateHeader = columnNumber >= firstDateColumn && columnNumber <= lastDateColumn;
cell.font = { name: 'Microsoft YaHei', size: 10, bold: true, color: { argb: columnNumber === totalColumn ? 'FF1C4F91' : 'FF304158' } };
cell.alignment = {
vertical: 'middle',
horizontal: columnNumber <= 2 ? 'left' : isDateHeader ? 'center' : 'right'
};
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: columnNumber === totalColumn ? 'FFDCEBFF' : 'FFEAF0F7' } };
cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } };
if (isDateHeader) cell.numFmt = 'm/d';
});
const rowsByVin = new Map<string, Map<string, DailyMileageRow>>();
for (const row of input.mileageRows) {
if (!rowsByVin.has(row.vin)) rowsByVin.set(row.vin, new Map());
rowsByVin.get(row.vin)!.set(row.date, row);
}
input.vehicles.forEach((vehicle, index) => {
const rowNumber = firstDataRow + index;
const mileageByDate = rowsByVin.get(vehicle.vin) ?? new Map<string, DailyMileageRow>();
const dailyValues = input.dates.map((date) => mileageByDate.get(date)?.dailyMileageKm ?? null);
const total = dailyValues.reduce<number>((sum, value) => sum + (value ?? 0), 0);
const row = sheet.getRow(rowNumber);
row.values = [vehicle.plate || '未绑定', vehicle.vin, ...dailyValues, null];
row.height = 25;
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
cell.font = { name: columnNumber === 2 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 2 ? 9 : 10, color: { argb: columnNumber === totalColumn ? 'FF1D4E89' : 'FF34445A' }, bold: columnNumber === 1 || columnNumber === totalColumn };
cell.alignment = { vertical: 'middle', horizontal: columnNumber <= 2 ? 'left' : 'right' };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
if (columnNumber >= firstDateColumn) cell.numFmt = '#,##0.0" km"';
});
const dateStart = excelColumn(firstDateColumn);
const dateEnd = excelColumn(lastDateColumn);
const totalCell = sheet.getCell(rowNumber, totalColumn);
totalCell.value = { formula: `SUM(${dateStart}${rowNumber}:${dateEnd}${rowNumber})`, result: total };
totalCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFEDF5FF' : 'FFF4F8FE' } };
row.commit();
});
sheet.getColumn(1).width = 15;
sheet.getColumn(2).width = 24;
for (let column = firstDateColumn; column <= lastDateColumn; column += 1) sheet.getColumn(column).width = 12;
sheet.getColumn(totalColumn).width = 17;
if (input.vehicles.length) {
sheet.autoFilter = { from: { row: headerRowNumber, column: 1 }, to: { row: lastDataRow, column: totalColumn } };
sheet.addConditionalFormatting({
ref: `${excelColumn(firstDateColumn)}${firstDataRow}:${excelColumn(lastDateColumn)}${lastDataRow}`,
rules: [{
type: 'colorScale', priority: 1,
cfvo: [{ type: 'min' }, { type: 'percentile', value: 50 }, { type: 'max' }],
color: [{ argb: 'FFFFFFFF' }, { argb: 'FFE8F2FF' }, { argb: 'FFB9D8FF' }]
}]
});
}
sheet.headerFooter.oddFooter = '&L灵牛车辆数据中台&C第 &P / &N 页&R导出于 ' + localDateTime(exportedAt);
return workbook;
}
export async function downloadMileageWorkbook(input: MileageExportInput) {
const workbook = await createMileageWorkbook(input);
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `车辆里程查询_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${input.vehicles.length}辆.xlsx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { MONITOR_REFRESH, monitorMapQueryParams, monitorQueryParams } from './useMonitorData';
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorMapQueryParams, monitorQueryParams, parseMonitorSearchTerms } from './useMonitorData';
describe('monitor query params', () => {
it('keeps server-owned status filters and bounded list size', () => {
@@ -20,6 +20,14 @@ describe('monitor query params', () => {
expect(monitorMapQueryParams({ keyword: '粤A1', protocol: '', status: '' }, viewport).has('bounds')).toBe(false);
expect(monitorMapQueryParams({ keyword: '', protocol: '', status: '' }, viewport).get('bounds')).toBe(viewport.bounds);
});
it('normalizes pasted plates, removes duplicates, and sends one batch parameter', () => {
expect(parseMonitorSearchTerms('粤ag18312\n川AHTWO1粤AG18312 京A00001')).toEqual(['粤AG18312', '川AHTWO1', '京A00001']);
const params = monitorQueryParams({ keyword: '粤AG18312\n川AHTWO1粤AG18312', protocol: '', status: '' }, 200);
expect(params.get('keywords')).toBe('粤AG18312,川AHTWO1');
expect(params.get('keyword')).toBeNull();
expect(parseMonitorSearchTerms(Array.from({ length: MAX_MONITOR_SEARCH_TERMS + 5 }, (_, index) => `粤A${index}`).join('\n'))).toHaveLength(MAX_MONITOR_SEARCH_TERMS);
});
});
describe('monitor refresh cadence', () => {

View File

@@ -20,9 +20,28 @@ export const MONITOR_REFRESH = {
alerts: 15_000
} as const;
export const MAX_MONITOR_SEARCH_TERMS = 100;
export function parseMonitorSearchTerms(value: string) {
const terms: string[] = [];
const seen = new Set<string>();
for (const raw of value.split(/[\s,,、;]+/)) {
const term = raw.trim();
if (!term) continue;
const key = term.toLocaleUpperCase();
if (seen.has(key)) continue;
seen.add(key);
terms.push(term.toLocaleUpperCase());
if (terms.length === MAX_MONITOR_SEARCH_TERMS) break;
}
return terms;
}
export function monitorQueryParams(filters: MonitorFilters, limit: number) {
const params = new URLSearchParams({ limit: String(limit) });
if (filters.keyword.trim()) params.set('keyword', filters.keyword.trim());
const terms = parseMonitorSearchTerms(filters.keyword);
if (terms.length === 1) params.set('keyword', terms[0]);
if (terms.length > 1) params.set('keywords', terms.join(','));
if (filters.protocol) params.set('protocol', filters.protocol);
if (filters.status) params.set('status', filters.status);
if (filters.status === 'online' || filters.status === 'offline') params.set('online', filters.status);
@@ -32,11 +51,11 @@ export function monitorQueryParams(filters: MonitorFilters, limit: number) {
export function monitorMapQueryParams(filters: MonitorFilters, viewport: MonitorViewport) {
const params = monitorQueryParams(filters, 10_000);
params.set('zoom', String(viewport.zoom));
if (viewport.bounds && !filters.keyword.trim()) params.set('bounds', viewport.bounds);
if (viewport.bounds && parseMonitorSearchTerms(filters.keyword).length === 0) params.set('bounds', viewport.bounds);
return params;
}
export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewport, selectedVin: string) {
export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewport, selectedVin: string, mapEnabled = true, vehicleEnabled = true) {
const params = monitorQueryParams(filters, 200);
const mapParams = monitorMapQueryParams(filters, viewport);
@@ -48,12 +67,15 @@ export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewpor
const vehicles = useQuery({
queryKey: ['monitor', 'vehicles', params.toString()],
queryFn: () => api.vehicleRealtime(params),
enabled: vehicleEnabled,
placeholderData: (previous) => previous,
refetchInterval: MONITOR_REFRESH.fleet
});
const map = useQuery({
queryKey: ['monitor', 'map', mapParams.toString()],
queryFn: () => api.monitorMap(mapParams),
enabled: mapEnabled,
placeholderData: (previous) => previous,
staleTime: 5_000,
refetchInterval: MONITOR_REFRESH.fleet

View File

@@ -20,7 +20,7 @@ const navigation = [
{ to: '/vehicles', label: '车辆查询', icon: IconSearch },
{ to: '/tracks', label: '轨迹回放', icon: IconMapPin },
{ to: '/history', label: '历史数据', icon: IconBarChartHStroked },
{ to: '/statistics', label: '车辆统计', icon: IconBarChartHStroked },
{ to: '/statistics', label: '里程查询', icon: IconBarChartHStroked },
{ to: '/alerts', label: '告警中心', icon: IconAlarm },
{ to: '/access', label: '接入管理', icon: IconBox }
];
@@ -30,7 +30,7 @@ const pageNames: Record<string, string> = {
vehicles: '车辆查询',
tracks: '轨迹回放',
history: '历史数据',
statistics: '车辆统计',
statistics: '里程查询',
alerts: '告警中心',
access: '接入管理',
operations: '运维质量'

View File

@@ -1,29 +1,36 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { HistoryLocationRow, TrackPlaybackEvent } from '../../api/types';
import type { HistoryLocationRow, TrackStop } from '../../api/types';
import { getAMapConfig, isAMapConfigured } from '../../config/appConfig';
import { isValidAMapCoordinate, loadAMap, wgs84ToGcj02, type AMapLike, type AMapMap, type AMapOverlay } from '../../integrations/amap';
function markerContent(kind: string, label?: string) {
if (kind === 'current') return '<div class="v2-track-current-marker"><span></span></div>';
return `<div class="v2-track-marker is-${kind}">${label ?? ''}</div>`;
function markerContent(kind: 'start' | 'end' | 'stop' | 'current', label?: string) {
if (kind === 'current') return '<div class="v2-track-current-marker" aria-hidden="true"><i></i><span></span></div>';
return `<div class="v2-track-marker is-${kind}"><span>${label ?? ''}</span></div>`;
}
export function TrackMap({ points, events, activeIndex, onSelectIndex }: {
export function TrackMap({ points, stops, activeIndex, showStops, follow, onSelectIndex, onFollowChange }: {
points: HistoryLocationRow[];
events: TrackPlaybackEvent[];
stops: TrackStop[];
activeIndex: number;
showStops: boolean;
follow: boolean;
onSelectIndex: (index: number) => void;
onFollowChange: (follow: boolean) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<AMapMap | null>(null);
const amapRef = useRef<AMapLike | null>(null);
const overlaysRef = useRef<AMapOverlay[]>([]);
const currentMarkerRef = useRef<AMapOverlay | null>(null);
const passedPathRef = useRef<AMapOverlay | null>(null);
const pathRef = useRef<[number, number][]>([]);
const selectRef = useRef(onSelectIndex);
const followChangeRef = useRef(onFollowChange);
const [state, setState] = useState<'loading' | 'ready' | 'fallback' | 'error'>('loading');
const valid = useMemo(() => points.map((point, index) => ({ point, index })).filter(({ point }) => isValidAMapCoordinate(point.longitude, point.latitude)), [points]);
useEffect(() => { selectRef.current = onSelectIndex; }, [onSelectIndex]);
useEffect(() => { followChangeRef.current = onFollowChange; }, [onFollowChange]);
useEffect(() => {
if (!containerRef.current || !isAMapConfigured(getAMapConfig())) { setState('fallback'); return; }
@@ -34,10 +41,12 @@ export function TrackMap({ points, events, activeIndex, onSelectIndex }: {
const map = new AMap.Map(containerRef.current, {
zoom: first ? 13 : 5,
center: first ? wgs84ToGcj02(first.longitude, first.latitude) : wgs84ToGcj02(105.4, 35.9),
viewMode: '2D', mapStyle: 'amap://styles/whitesmoke', showLabel: true, resizeEnable: true
viewMode: '2D', mapStyle: 'amap://styles/whitesmoke', showLabel: true, resizeEnable: true,
zooms: [3, 20]
});
map.addControl(new AMap.Scale());
if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '22px' } }));
if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '148px' } }));
map.on?.('dragstart', () => followChangeRef.current(false));
mapRef.current = map;
amapRef.current = AMap;
setState('ready');
@@ -49,6 +58,8 @@ export function TrackMap({ points, events, activeIndex, onSelectIndex }: {
mapRef.current?.destroy();
overlaysRef.current = [];
currentMarkerRef.current = null;
passedPathRef.current = null;
pathRef.current = [];
mapRef.current = null;
amapRef.current = null;
};
@@ -56,45 +67,54 @@ export function TrackMap({ points, events, activeIndex, onSelectIndex }: {
useEffect(() => {
const AMap = amapRef.current;
if (state !== 'ready' || !mapRef.current || !valid.length || !AMap) return;
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
currentMarkerRef.current?.setMap?.(null);
const path = valid.map(({ point }) => wgs84ToGcj02(point.longitude, point.latitude));
const polyline = new AMap.Polyline({ path, strokeColor: '#1268f3', strokeWeight: 5, strokeOpacity: 0.92, lineJoin: 'round', lineCap: 'round', showDir: true, zIndex: 80 });
const first = valid[0];
const last = valid[valid.length - 1];
const overlays: AMapOverlay[] = [polyline];
const start = new AMap.Marker({ position: wgs84ToGcj02(first.point.longitude, first.point.latitude), anchor: 'center', content: markerContent('start', '始'), zIndex: 110 });
const end = new AMap.Marker({ position: wgs84ToGcj02(last.point.longitude, last.point.latitude), anchor: 'center', content: markerContent('end', '终'), zIndex: 110 });
start.on?.('click', () => selectRef.current(first.index));
end.on?.('click', () => selectRef.current(last.index));
overlays.push(start, end);
events.slice(1, -1).forEach((event, eventIndex) => {
if (!isValidAMapCoordinate(event.longitude, event.latitude)) return;
const exactIndex = points.findIndex((point) => point.deviceTime === event.time);
const targetIndex = exactIndex >= 0 ? exactIndex : points.reduce((closest, point, index) => {
const best = points[closest];
const distance = (point.longitude - event.longitude) ** 2 + (point.latitude - event.latitude) ** 2;
const bestDistance = (best.longitude - event.longitude) ** 2 + (best.latitude - event.latitude) ** 2;
return distance < bestDistance ? index : closest;
}, 0);
const marker = new AMap.Marker({ position: wgs84ToGcj02(event.longitude, event.latitude), anchor: 'center', content: markerContent('event', String(eventIndex + 1)), zIndex: 105 });
marker.on?.('click', () => selectRef.current(targetIndex));
overlays.push(marker);
const map = mapRef.current;
if (state !== 'ready' || !map || !valid.length || !AMap) return;
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
currentMarkerRef.current?.setMap?.(null);
const path = valid.map(({ point }) => wgs84ToGcj02(point.longitude, point.latitude));
pathRef.current = path;
const nextValidIndex = valid.findIndex(({ index }) => index >= activeIndex);
const activeValidIndex = nextValidIndex >= 0 ? nextValidIndex : valid.length - 1;
const fullPath = new AMap.Polyline({ path, strokeColor: '#2563eb', strokeWeight: 6, strokeOpacity: 0.82, lineJoin: 'round', lineCap: 'round', showDir: true, zIndex: 70 });
const passedPath = new AMap.Polyline({ path: path.slice(0, activeValidIndex + 1), strokeColor: '#18a86b', strokeWeight: 7, strokeOpacity: 0.96, lineJoin: 'round', lineCap: 'round', zIndex: 80 });
passedPathRef.current = passedPath;
const first = valid[0];
const last = valid[valid.length - 1];
const start = new AMap.Marker({ position: path[0], anchor: 'center', content: markerContent('start', '始'), zIndex: 115 });
const end = new AMap.Marker({ position: path[path.length - 1], anchor: 'center', content: markerContent('end', '终'), zIndex: 115 });
start.on?.('click', () => selectRef.current(first.index));
end.on?.('click', () => selectRef.current(last.index));
const overlays: AMapOverlay[] = [fullPath, passedPath, start, end];
if (showStops) stops.slice(0, 80).forEach((stop, index) => {
if (!isValidAMapCoordinate(stop.longitude, stop.latitude)) return;
const marker = new AMap.Marker({
position: wgs84ToGcj02(stop.longitude, stop.latitude), anchor: 'center',
content: markerContent('stop', String(index + 1)), zIndex: 105
});
const active = valid.find(({ index }) => index === activeIndex) ?? first;
const current = new AMap.Marker({ position: wgs84ToGcj02(active.point.longitude, active.point.latitude), anchor: 'center', content: markerContent('current'), zIndex: 130 });
currentMarkerRef.current = current;
overlaysRef.current = overlays;
mapRef.current.add([...overlays, current]);
mapRef.current.setFitView(overlays, false, [52, 52, 52, 52]);
}, [events, points, state, valid]);
marker.on?.('click', () => selectRef.current(Math.min(points.length - 1, Math.max(0, stop.sampledIndex))));
overlays.push(marker);
});
const active = valid.find(({ index }) => index === activeIndex) ?? first;
const current = new AMap.Marker({ position: wgs84ToGcj02(active.point.longitude, active.point.latitude), anchor: 'center', content: markerContent('current'), zIndex: 130 });
currentMarkerRef.current = current;
overlaysRef.current = overlays;
map.add([...overlays, current]);
map.setFitView([fullPath], false, [72, 72, 168, 72]);
}, [points, showStops, state, stops, valid]);
useEffect(() => {
const point = points[activeIndex];
if (!point || !isValidAMapCoordinate(point.longitude, point.latitude)) return;
currentMarkerRef.current?.setPosition?.(wgs84ToGcj02(point.longitude, point.latitude));
}, [activeIndex, points]);
const position = wgs84ToGcj02(point.longitude, point.latitude);
currentMarkerRef.current?.setPosition?.(position);
const passedCount = valid.reduce((count, entry) => count + (entry.index <= activeIndex ? 1 : 0), 0);
passedPathRef.current?.setPath?.(pathRef.current.slice(0, Math.max(1, passedCount)));
if (follow) mapRef.current?.panTo?.(position, 180);
}, [activeIndex, follow, points, valid]);
return <div className="v2-track-map">
<div ref={containerRef} className="v2-track-map-canvas" aria-label="历史轨迹地图" />
@@ -103,6 +123,5 @@ export function TrackMap({ points, events, activeIndex, onSelectIndex }: {
{state === 'fallback' ? `地图未配置,已载入 ${valid.length} 个有效轨迹点` : null}
{state === 'error' ? '地图加载失败,请检查高德地图配置' : null}
</div> : null}
<div className="v2-track-map-legend"><span><i className="is-start" /></span><span><i className="is-current" /></span><span><i className="is-end" /></span><b>{valid.length} </b></div>
</div>;
}

View File

@@ -1,117 +1,126 @@
import { IconClose, IconDownload, IconRefresh, IconSave, IconSearch } from '@douyinfe/semi-icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { IconDownload, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow } from '../../api/types';
import { accessRowsToCSV, accessStateLabels, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access';
import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow } from '../../api/types';
import { accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access';
import { InlineError } from '../shared/AsyncState';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', onlineState: '', delayState: '' };
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'];
const protocolColors = ['#1685c5', '#6f2da8', '#15a46d', '#7c8fd6', '#9aa4b2'];
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const;
const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' };
type Filters = typeof EMPTY_FILTERS;
function StatusLabel({ state }: { state: AccessVehicleRow['onlineState'] }) {
return <span className={`v2-access-status is-${state}`}><i />{accessStateLabels[state]}</span>;
const connectionLabels: Record<AccessVehicleRow['connectionState'], string> = {
healthy: '三协议正常', degraded: '协议离线', incomplete: '接入不完整', offline: '全部离线', not_connected: '尚未接入'
};
function statusByProtocol(row: AccessVehicleRow, protocol: string) {
return row.protocolStatuses.find((item) => item.protocol === protocol);
}
function ProtocolDistribution({ summary }: { summary?: AccessSummary }) {
const rows = summary?.protocols ?? [];
const total = Math.max(1, rows.reduce((sum, item) => sum + item.total, 0));
return <section className="v2-access-protocols"><header><strong></strong><span> · 线</span></header>
<div className="v2-access-segments">{rows.map((item, index) => <i key={item.name} style={{ width: `${item.total / total * 100}%`, background: protocolColors[index % protocolColors.length] }} title={`${item.name} ${item.total}`} />)}</div>
<div className="v2-access-legends">{rows.map((item, index) => <span key={item.name}><i style={{ background: protocolColors[index % protocolColors.length] }} /><b>{item.name}</b>{item.total.toLocaleString('zh-CN')} <em>{item.onlineRate.toFixed(1)}% 线</em></span>)}{!rows.length ? <span></span> : null}</div>
</section>;
function compactTime(value: string) {
if (!value) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '—';
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(parsed).replace(/\//g, '-');
}
function IdentityQueue({ items, total, loading }: { items: AccessUnresolvedIdentity[]; total: number; loading: boolean }) {
const [copied, setCopied] = useState('');
if (!loading && total === 0) return null;
const copyEvidence = async (item: AccessUnresolvedIdentity) => {
const text = [`身份待绑定:${item.identifierMasked}`, `协议:${item.protocol}`, `车牌:${item.plate || '待核对'}`, `厂家:${item.manufacturer || '待核对'}`, `来源:${item.sourceEndpoint || '未知'}`, `最近上报:${formatAccessTime(item.latestSeenAt)}`, `问题:${item.issueCode}`, `建议动作:${item.recommendedAction}`].join('\n');
await navigator.clipboard?.writeText(text);
setCopied(item.id);
};
return <details id="access-identity-queue" className="v2-access-identity-queue" open={total > 0}><summary><span><b></b><strong>{loading ? '…' : total.toLocaleString('zh-CN')}</strong></span><em> · VIN</em></summary>
<div>{items.slice(0, 5).map((item) => <article key={item.id}><span className="v2-access-identity-code">{item.identifierMasked}</span><dl><div><dt></dt><dd>{[item.plate, item.manufacturer, item.sourceEndpoint].filter(Boolean).join(' · ') || '仅有终端上报'}</dd></div><div><dt></dt><dd>{formatAccessTime(item.latestSeenAt)} · {formatSeconds(item.freshnessSec)}</dd></div></dl><button type="button" onClick={() => void copyEvidence(item)}>{copied === item.id ? '已复制' : '复制处置证据'}</button></article>)}</div>
</details>;
function ProtocolState({ status, detailed = false }: { status?: AccessProtocolStatus; detailed?: boolean }) {
const state = !status?.connected ? 'missing' : status.onlineState;
const label = state === 'missing' ? '未接入' : state === 'online' ? '在线' : state === 'offline' ? '离线' : state === 'unknown' ? '未知' : '从未上报';
if (detailed) {
return <article className={`v2-access-protocol-detail is-${state}`}>
<header><strong>{status?.protocol}</strong><span><i />{label}</span></header>
<dl>
<div><dt></dt><dd>{status?.provider || '—'}</dd></div>
<div><dt></dt><dd>{formatAccessTime(status?.firstSeenAt || '')}</dd></div>
<div><dt></dt><dd>{formatAccessTime(status?.latestReceivedAt || '')}</dd></div>
<div><dt>线</dt><dd>{formatSeconds(status?.freshnessSec)}</dd></div>
<div><dt></dt><dd>{formatSeconds(status?.reportIntervalSec)}</dd></div>
<div><dt></dt><dd className={status?.delayAbnormal ? 'is-danger' : ''}>{formatSeconds(status?.dataDelaySec)}</dd></div>
</dl>
<p>{status?.firstSeenEvidence || '应接协议尚未形成实时快照'}</p>
</article>;
}
return <div className={`v2-access-protocol-cell is-${state}`} title={status?.latestReceivedAt ? `最新上报:${formatAccessTime(status.latestReceivedAt)}` : '应接协议尚未形成实时快照'}>
<span><i />{label}</span>
<strong>{status?.connected ? compactTime(status.latestReceivedAt) : '等待接入'}</strong>
<small>{status?.provider || (status?.connected ? '接入方未维护' : '无实时快照')}</small>
</div>;
}
function AccessInspector({ row }: { row?: AccessVehicleRow }) {
if (!row) return <section className="v2-access-inspector"><header><strong></strong></header><div className="v2-access-side-empty"></div></section>;
return <section className="v2-access-inspector"><header><strong></strong><StatusLabel state={row.onlineState} /></header>
<dl className="v2-access-identity"><div><dt></dt><dd>{row.plate || '—'}</dd></div><div><dt>VIN</dt><dd>{row.vin}</dd></div><div><dt> / </dt><dd>{[row.model, row.company].filter(Boolean).join(' / ') || '—'}</dd></div><div><dt></dt><dd>{row.protocol || '—'}</dd></div><div><dt> / </dt><dd>{[row.oem, row.provider].filter(Boolean).join(' / ') || '—'}</dd></div></dl>
<Link className="v2-access-vehicle-link" to={`/vehicles/${encodeURIComponent(row.vin)}`}></Link>
<section><h3></h3><dl><div><dt></dt><dd>{formatAccessTime(row.latestEventAt)}</dd></div><div><dt></dt><dd>{formatAccessTime(row.latestReceivedAt)}</dd></div><div><dt></dt><dd className={row.delayAbnormal ? 'is-danger' : 'is-good'}>{formatSeconds(row.dataDelaySec)}</dd></div><div><dt></dt><dd>{formatSeconds(row.reportIntervalSec)}</dd></div><div><dt></dt><dd>{formatSeconds(row.freshnessSec)}</dd></div><div><dt></dt><dd>{formatSeconds(row.thresholdSec)}</dd></div></dl></section>
<section><h3></h3><dl><div><dt></dt><dd>{row.latestMessageType || '—'}</dd></div><div><dt> ID</dt><dd>{row.latestEventId || '—'}</dd></div><div><dt></dt><dd className={row.latestError ? 'is-danger' : ''}>{row.latestError || '无已知错误'}</dd></div><div><dt></dt><dd>{row.source || '—'}</dd></div></dl></section>
<section className="v2-access-proof"><h3></h3><p><b></b>{row.firstSeenAt ? `${formatAccessTime(row.firstSeenAt)} · ${row.firstSeenEvidence}` : row.firstSeenEvidence}</p><p><b></b>{row.reportIntervalEvidence || (row.reportIntervalSec !== null ? `${row.reportSampleCount} 个持久样本计算` : '等待连续样本')}</p></section>
</section>;
function ConnectionState({ row }: { row: AccessVehicleRow }) {
return <div className={`v2-access-connection is-${row.connectionState}`}><strong>{connectionLabels[row.connectionState]}</strong><span>{row.actualProtocols.length} / {row.expectedProtocols.length} </span></div>;
}
function ThresholdPanel({ config, draft, saving, error, editable, onChange, onSave }: { config?: AccessThresholdConfig; draft?: AccessThresholdUpdate; saving: boolean; error?: string; editable: boolean; onChange: (next: AccessThresholdUpdate) => void; onSave: () => void }) {
return <section className="v2-access-threshold"><header><strong>线</strong><span>v{config?.version ?? '—'}</span></header>
{draft ? <fieldset className="v2-threshold-form" disabled={!editable}><label><span></span><select value={draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, defaultThresholdSec: Number(event.target.value) })}><option value="60">1 </option><option value="300">5 </option><option value="600">10 </option><option value="1800">30 </option></select></label><label><span></span><input type="number" min="1" max="3600" value={draft.delayThresholdSec} onChange={(event) => onChange({ ...draft, delayThresholdSec: Number(event.target.value) })} /><em></em></label><label><span>线</span><select value={draft.longOfflineSec} onChange={(event) => onChange({ ...draft, longOfflineSec: Number(event.target.value) })}><option value="1800">30 </option><option value="3600">1 </option><option value="21600">6 </option><option value="86400">24 </option></select></label>
{PROTOCOLS.map((protocol) => <label key={protocol}><span>{protocol}</span><input type="number" min="30" max="86400" value={draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, Number(event.target.value)) })} /><em></em></label>)}
{error ? <p className="v2-threshold-error">{error}</p> : null}{editable ? <button type="button" disabled={saving} onClick={onSave}><IconSave />{saving ? '保存中' : '保存并重算'}</button> : <p className="v2-role-notice"></p>}</fieldset> : <div className="v2-access-side-empty"></div>}
{config?.audit[0] ? <footer><span></span><b>{config.audit[0].actor} · {formatAccessTime(config.audit[0].changedAt)}</b></footer> : <footer><span></span><b>MySQL </b></footer>}
</section>;
function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
return <div className="v2-access-protocol-coverage" aria-label="三协议接入概览">
{PROTOCOLS.map((protocol) => {
const actual = summary?.protocols.find((item) => item.name === protocol);
const total = summary?.totalVehicles ?? 0;
return <span key={protocol}><b>{protocol}</b><em>{(actual?.total ?? 0).toLocaleString('zh-CN')} / {total.toLocaleString('zh-CN')}</em></span>;
})}
</div>;
}
function VehicleInspector({ row, onClose }: { row: AccessVehicleRow; onClose: () => void }) {
return <aside className="v2-access-inspector-v3">
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><button type="button" onClick={onClose} aria-label="关闭车辆接入详情"><IconClose /></button></header>
<section className="v2-access-inspector-summary"><div><span> / </span><strong>{[row.oem, row.model].filter(Boolean).join(' / ') || '未维护'}</strong></div><div><span></span><strong>{row.expectedProtocols.join(' / ')}</strong></div><div><span></span><strong>{row.actualProtocols.length ? row.actualProtocols.join(' / ') : '尚未接入'}</strong></div><div><span></span><ConnectionState row={row} /></div></section>
<div className="v2-access-protocol-details">{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} detailed />)}</div>
<footer><span>{row.expectationEvidence}</span><Link to={`/vehicles/${encodeURIComponent(row.vin)}`}></Link></footer>
</aside>;
}
function IdentityQueue({ items, total }: { items: AccessUnresolvedIdentity[]; total: number }) {
if (!total) return null;
return <details id="access-identity-queue" className="v2-access-identity-queue-v3"><summary><strong> {total.toLocaleString('zh-CN')} </strong><span> VIN </span></summary><div>{items.slice(0, 6).map((item) => <article key={item.id}><b>{item.identifierMasked}</b><span>{item.protocol} · {item.plate || '车牌待核对'} · {formatAccessTime(item.latestSeenAt)}</span><small>{item.recommendedAction}</small></article>)}</div></details>;
}
function ThresholdSettings({ config, draft, editable, saving, error, onChange, onSave }: { config?: AccessThresholdConfig; draft?: AccessThresholdUpdate; editable: boolean; saving: boolean; error?: string; onChange: (next: AccessThresholdUpdate) => void; onSave: () => void }) {
if (!draft) return null;
return <details className="v2-access-settings"><summary>线 · v{config?.version ?? '—'}</summary><fieldset disabled={!editable}><label><span></span><input type="number" value={draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, defaultThresholdSec: Number(event.target.value) })} /></label><label><span>线</span><input type="number" value={draft.longOfflineSec} onChange={(event) => onChange({ ...draft, longOfflineSec: Number(event.target.value) })} /></label>{PROTOCOLS.map((protocol) => <label key={protocol}><span>{protocol}</span><input type="number" value={draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, Number(event.target.value)) })} /></label>)}{error ? <p>{error}</p> : null}{editable ? <button type="button" onClick={onSave} disabled={saving}><IconSave />{saving ? '保存中' : '保存阈值'}</button> : <small></small>}</fieldset></details>;
}
function downloadRows(rows: AccessVehicleRow[]) {
const blob = new Blob([accessRowsToCSV(rows)], { type: 'text/csv;charset=utf-8' });
const href = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = href; anchor.download = `vehicle-access-${new Date().toISOString().slice(0, 10)}.csv`; anchor.click();
URL.revokeObjectURL(href);
const href = URL.createObjectURL(blob); const anchor = document.createElement('a');
anchor.href = href; anchor.download = `vehicle-access-${new Date().toISOString().slice(0, 10)}.csv`; anchor.click(); URL.revokeObjectURL(href);
}
export default function AccessPage() {
const { session } = usePlatformSession(); const thresholdEditable = canAdminister(session);
const { session } = usePlatformSession(); const editable = canAdminister(session);
const [searchParams, setSearchParams] = useSearchParams();
const initial: Filters = Object.fromEntries(Object.keys(EMPTY_FILTERS).map((key) => [key, searchParams.get(key) ?? ''])) as Filters;
const [draft, setDraft] = useState(initial);
const [criteria, setCriteria] = useState(initial);
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(50);
const [selectedVIN, setSelectedVIN] = useState('');
const [thresholdDraft, setThresholdDraft] = useState<AccessThresholdUpdate>();
const queryClient = useQueryClient();
const baseQuery: AccessQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]);
const summaryQuery = useQuery({ queryKey: ['access-summary', baseQuery], queryFn: () => api.accessSummary(baseQuery), staleTime: 10_000 });
const initial = Object.fromEntries(Object.keys(EMPTY_FILTERS).map((key) => [key, searchParams.get(key) ?? ''])) as Filters;
const [draft, setDraft] = useState(initial); const [criteria, setCriteria] = useState(initial);
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(50); const [selectedVIN, setSelectedVIN] = useState('');
const [thresholdDraft, setThresholdDraft] = useState<AccessThresholdUpdate>(); const queryClient = useQueryClient();
const baseQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]);
const summaryQuery = useQuery({ queryKey: ['access-summary'], queryFn: () => api.accessSummary({}), staleTime: 15_000 });
const vehiclesQuery = useQuery({ queryKey: ['access-vehicles', baseQuery, limit, offset], queryFn: () => api.accessVehicles({ ...baseQuery, limit, offset }), placeholderData: (previous) => previous });
const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: () => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }), staleTime: 10_000 });
const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: () => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }), staleTime: 15_000 });
const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: api.accessThresholds, staleTime: 60_000 });
const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (config) => { queryClient.setQueryData(['access-thresholds'], config); setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); await Promise.all([queryClient.invalidateQueries({ queryKey: ['access-summary'] }), queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })]); } });
const rows = vehiclesQuery.data?.items ?? [];
const selected = rows.find((row) => row.vin === selectedVIN) ?? rows[0];
useEffect(() => { if (rows.length && !rows.some((row) => row.vin === selectedVIN)) setSelectedVIN(rows[0].vin); }, [rows, selectedVIN]);
useEffect(() => { const config = thresholdQuery.data; if (config && !thresholdDraft) setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); }, [thresholdDraft, thresholdQuery.data]);
useEffect(() => { if (thresholdQuery.data && !thresholdDraft) setThresholdDraft({ version: thresholdQuery.data.version, defaultThresholdSec: thresholdQuery.data.defaultThresholdSec, delayThresholdSec: thresholdQuery.data.delayThresholdSec, longOfflineSec: thresholdQuery.data.longOfflineSec, protocols: thresholdQuery.data.protocols }); }, [thresholdDraft, thresholdQuery.data]);
const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (config) => { setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); await Promise.all([queryClient.invalidateQueries({ queryKey: ['access-summary'] }), queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })]); } });
const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN);
const syncURL = (filters: Filters) => { const next = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { if (value) next.set(key, value); }); setSearchParams(next, { replace: true }); };
const submit = (event: FormEvent) => { event.preventDefault(); setCriteria(draft); setOffset(0); syncURL(draft); };
const reset = () => { setDraft(EMPTY_FILTERS); setCriteria(EMPTY_FILTERS); setOffset(0); setSearchParams({}, { replace: true }); };
const applyState = (onlineState: string, delayState = '') => { const next = { ...criteria, onlineState, delayState }; setDraft(next); setCriteria(next); setOffset(0); syncURL(next); };
const showIdentityQueue = () => document.getElementById('access-identity-queue')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
const page = Math.floor(offset / limit) + 1;
const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit));
const summary = summaryQuery.data;
const apply = (next: Filters) => { setDraft(next); setCriteria(next); setOffset(0); setSelectedVIN(''); syncURL(next); };
const submit = (event: FormEvent) => { event.preventDefault(); apply(draft); };
const page = Math.floor(offset / limit) + 1; const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit)); const summary = summaryQuery.data;
return <div className="v2-access-page">
<form className="v2-access-filter" onSubmit={submit}><label><span></span><div><IconSearch /><input value={draft.keyword} onChange={(event) => setDraft((current) => ({ ...current, keyword: event.target.value }))} placeholder="车牌 / VIN" /></div></label><label><span></span><select value={draft.protocol} onChange={(event) => setDraft((current) => ({ ...current, protocol: event.target.value }))}><option value=""></option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span></span><select value={draft.oem} onChange={(event) => setDraft((current) => ({ ...current, oem: event.target.value }))}><option value=""></option>{summary?.oems.filter((item) => item.name !== '未维护').map((item) => <option key={item.name}>{item.name}</option>)}</select></label><label><span>线</span><select value={draft.onlineState} onChange={(event) => setDraft((current) => ({ ...current, onlineState: event.target.value }))}><option value=""></option><option value="online">线</option><option value="offline">线</option><option value="never_reported"></option><option value="unknown"></option></select></label><label><span></span><select value={draft.delayState} onChange={(event) => setDraft((current) => ({ ...current, delayState: event.target.value }))}><option value=""></option><option value="normal"></option><option value="abnormal"></option></select></label><button className="v2-primary-button" type="submit"></button><button className="v2-secondary-button" type="button" onClick={reset}></button><details className="v2-access-advanced"><summary> · / / </summary><div><label><span></span><input value={draft.model} onChange={(event) => setDraft((current) => ({ ...current, model: event.target.value }))} placeholder="输入车型关键词" /></label><label><span></span><input value={draft.provider} onChange={(event) => setDraft((current) => ({ ...current, provider: event.target.value }))} placeholder="输入平台名称" /></label><label><span></span><input type="datetime-local" value={draft.firstSeenFrom} onChange={(event) => setDraft((current) => ({ ...current, firstSeenFrom: event.target.value }))} /></label><label><span></span><input type="datetime-local" value={draft.firstSeenTo} onChange={(event) => setDraft((current) => ({ ...current, firstSeenTo: event.target.value }))} /></label><label><span></span><input type="datetime-local" value={draft.latestSeenFrom} onChange={(event) => setDraft((current) => ({ ...current, latestSeenFrom: event.target.value }))} /></label><label><span></span><input type="datetime-local" value={draft.latestSeenTo} onChange={(event) => setDraft((current) => ({ ...current, latestSeenTo: event.target.value }))} /></label></div></details></form>
return <div className="v2-access-page v2-access-page-v3">
<header className="v2-access-heading"><div><h2></h2><p></p></div><div><span> {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</span><button type="button" onClick={() => void Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch()])}><IconRefresh /></button></div></header>
<form className="v2-access-filter-v3" onSubmit={submit}><label className="is-search"><span></span><div><IconSearch /><input aria-label="车辆" value={draft.keyword} onChange={(event) => setDraft({ ...draft, keyword: event.target.value })} placeholder="车牌 / VIN" /></div></label><label><span></span><select aria-label="接入状态" value={draft.connectionState} onChange={(event) => setDraft({ ...draft, connectionState: event.target.value })}><option value=""></option><option value="attention"></option><option value="healthy"></option><option value="incomplete"></option><option value="degraded">线</option><option value="offline">线</option><option value="not_connected"></option></select></label><label><span></span><select aria-label="关注协议" value={draft.protocol} onChange={(event) => setDraft({ ...draft, protocol: event.target.value })}><option value=""></option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span></span><select aria-label="车辆品牌" value={draft.oem} onChange={(event) => setDraft({ ...draft, oem: event.target.value })}><option value=""></option>{summary?.oems.filter((item) => item.name !== '未维护').map((item) => <option key={item.name}>{item.name}</option>)}</select></label><button className="v2-primary-button" type="submit"></button><button className="v2-secondary-button" type="button" onClick={() => apply(EMPTY_FILTERS)}></button></form>
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
<section className="v2-access-kpis">{[
['接入车辆', summary?.totalVehicles ?? 0, '', () => applyState('')], ['在线', summary?.onlineVehicles ?? 0, 'online', () => applyState('online')], ['长离线', summary?.longOfflineVehicles ?? 0, 'offline', () => applyState('offline')], ['从未上报', summary?.neverReported ?? 0, 'never', () => applyState('never_reported')], ['延迟异常', summary?.delayAbnormal ?? 0, 'delay', () => applyState('', 'abnormal')], ['身份待绑定', unresolvedQuery.data?.total ?? 0, 'identity', showIdentityQueue], ['今日上报', summary?.reportedToday ?? 0, 'today', () => applyState('')]
].map(([label, value, tone, action]) => <button key={String(label)} type="button" className={`is-${tone}`} onClick={action as () => void}><small>{label as string}</small><strong>{Number(value).toLocaleString('zh-CN')}</strong>{label === '在线' ? <em>{(summary?.onlineRate ?? 0).toFixed(1)}%</em> : null}</button>)}</section>
<ProtocolDistribution summary={summary} />
{unresolvedQuery.isError ? <InlineError message={unresolvedQuery.error instanceof Error ? unresolvedQuery.error.message : '身份待绑定队列读取失败'} onRetry={() => unresolvedQuery.refetch()} /> : null}
<IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} loading={unresolvedQuery.isLoading} />
<section className="v2-access-kpis-v3">{[
['车辆', summary?.totalVehicles ?? 0, 'all', ''], ['有接入差异', Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0)), 'attention', 'attention'], ['全部离线', summary?.offlineVehicles ?? 0, 'offline', 'offline'], ['尚未接入', summary?.neverReported ?? 0, 'never', 'not_connected']
].map(([label, value, tone, connectionState]) => <button key={String(label)} className={`is-${tone}`} type="button" onClick={() => apply({ ...criteria, connectionState: String(connectionState) })}><small>{label}</small><strong>{Number(value).toLocaleString('zh-CN')}</strong>{label === '主车辆' ? <em></em> : null}</button>)}</section>
{vehiclesQuery.isError ? <InlineError message={vehiclesQuery.error instanceof Error ? vehiclesQuery.error.message : '接入车辆读取失败'} onRetry={() => vehiclesQuery.refetch()} /> : null}
<div className="v2-access-workspace"><section className="v2-access-table-card"><header><strong></strong><div><span> v{summary?.thresholdVersion ?? '—'}</span><button type="button" onClick={() => vehiclesQuery.refetch()}><IconRefresh /></button><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload /></button><button type="button"><IconSetting /></button></div></header><div className="v2-access-table-scroll"><table><thead><tr><th /><th>线</th><th></th><th>VIN</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} className={selected?.vin === row.vin ? 'is-selected' : ''}><td><input type="radio" name="access-row" checked={selected?.vin === row.vin} onChange={() => setSelectedVIN(row.vin)} aria-label={`选择 ${row.plate || row.vin}`} /></td><td><StatusLabel state={row.onlineState} /></td><td>{row.plate || ''}</td><td title={row.vin}>{row.vin}</td><td>{row.oem || ''}</td><td>{row.protocol || '—'}</td><td title={row.firstSeenEvidence}>{formatAccessTime(row.firstSeenAt)}</td><td>{formatAccessTime(row.latestEventAt)}</td><td>{formatAccessTime(row.latestReceivedAt)}</td><td title={row.reportIntervalEvidence}>{formatSeconds(row.reportIntervalSec)}</td><td className={row.delayAbnormal ? 'is-danger' : 'is-good'}>{formatSeconds(row.dataDelaySec)}</td><td>{formatSeconds(row.thresholdSec)}</td><td>{row.latestMessageType || '—'}</td><td className={row.latestError ? 'is-danger' : ''} title={row.latestError}>{row.latestError || '—'}</td><td><button type="button" onClick={() => setSelectedVIN(row.vin)}></button></td></tr>)}</tbody></table>{vehiclesQuery.isFetching ? <div className="v2-access-loading"><i /></div> : null}{!vehiclesQuery.isFetching && !rows.length ? <div className="v2-access-empty"></div> : null}</div><footer><span> {page} / {totalPages} {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} </span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer></section>
<aside className="v2-access-side"><AccessInspector row={selected} /><ThresholdPanel config={thresholdQuery.data} draft={thresholdDraft} saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} editable={thresholdEditable} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} /></aside></div>
<div className={`v2-access-workspace-v3 ${selected ? 'is-inspector-open' : ''}`}><section className="v2-access-table-v3"><header><div className="v2-access-table-title"><strong></strong><span></span></div><div className="v2-access-table-actions"><ProtocolCoverage summary={summary} /><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload /></button></div></header><div className="v2-access-table-scroll-v3"><table><thead><tr><th></th><th> / </th><th></th>{PROTOCOLS.map((item) => <th key={item}>{item}</th>)}<th></th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} data-testid={`access-row-${row.vin}`} tabIndex={0} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedVIN(row.vin); }}><td><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></td><td><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></td><td><b>{row.expectedProtocols.length} </b><span>{row.expectedProtocols.join(' / ')}</span></td>{PROTOCOLS.map((protocol) => <td key={protocol}><ProtocolState status={statusByProtocol(row, protocol)} /></td>)}<td><ConnectionState row={row} /></td></tr>)}</tbody></table>{vehiclesQuery.isFetching ? <div className="v2-access-loading"><i /></div> : null}{!vehiclesQuery.isFetching && !rows.length ? <div className="v2-access-empty"></div> : null}</div><footer><span> {page} / {totalPages} {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} </span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select aria-label="每页数量" value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer></section>{selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} /> : null}</div>
<IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} />
<ThresholdSettings config={thresholdQuery.data} draft={thresholdDraft} editable={editable} saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} />
</div>;
}

View File

@@ -1,7 +1,9 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { VehicleRealtimeRow } from '../../api/types';
import { api } from '../../api/client';
import MonitorPage from './MonitorPage';
const vehicles = [{
@@ -26,7 +28,18 @@ vi.mock('../map/FleetMap', () => ({
}));
vi.mock('../hooks/useMonitorData', () => ({
MAX_MONITOR_SEARCH_TERMS: 100,
MONITOR_REFRESH: { selected: 10_000, fleet: 15_000, summary: 30_000 },
parseMonitorSearchTerms: (value: string) => Array.from(new Set(value.split(/[\s,,、;]+/).map((item) => item.trim().toLocaleUpperCase()).filter(Boolean))).slice(0, 100),
monitorQueryParams: (filters: { keyword: string; protocol: string; status: string }, limit: number) => {
const params = new URLSearchParams({ limit: String(limit) });
const terms = Array.from(new Set(filters.keyword.split(/[\s,,、;]+/).map((item) => item.trim().toLocaleUpperCase()).filter(Boolean))).slice(0, 100);
if (terms.length === 1) params.set('keyword', terms[0]);
if (terms.length > 1) params.set('keywords', terms.join(','));
if (filters.protocol) params.set('protocol', filters.protocol);
if (filters.status) params.set('status', filters.status);
return params;
},
useMonitorData: () => ({
summary: { data: { totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } },
vehicles: { data: { items: vehicles, total: 2 }, isError: false, isLoading: false, isFetching: false },
@@ -39,7 +52,8 @@ vi.mock('../hooks/useMonitorData', () => ({
afterEach(cleanup);
test('starts without a selection and supports expand, collapse, reselection, and clear', () => {
const view = render(<MemoryRouter><MonitorPage /></MemoryRouter>);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
const workspace = view.container.querySelector('.v2-monitor-workspace')!;
const firstVehicle = screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ });
const secondVehicle = screen.getByRole('button', { name: /粤B67890 LTEST000000000002/ });
@@ -78,3 +92,45 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(workspace).toHaveClass('is-detail-open');
expect(secondVehicle).toHaveClass('is-selected');
});
test('switches to a lightweight realtime list and resolves addresses only on demand', async () => {
const row = vehicles[0];
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 0 });
const reverseGeocode = vi.spyOn(api, 'reverseGeocode').mockResolvedValue({ provider: 'AMap', longitude: 113.26, latitude: 23.13, formattedAddress: '广东省广州市天河区测试路' });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
expect(screen.getByText('速度、里程和坐标实时刷新,文字地址按需解析')).toBeInTheDocument();
expect(await screen.findAllByText('粤A12345')).toHaveLength(2);
expect(screen.getAllByText('42')).toHaveLength(2);
expect(screen.getAllByText(/1,234/)).toHaveLength(2);
expect(screen.getAllByText(/113\.260000/)).toHaveLength(2);
expect(reverseGeocode).not.toHaveBeenCalled();
fireEvent.click(screen.getAllByRole('button', { name: '解析粤A12345位置' })[0]);
expect(await screen.findByText('广东省广州市天河区测试路')).toBeInTheDocument();
expect(reverseGeocode).toHaveBeenCalledTimes(1);
expect(screen.queryByText('综合状态')).not.toBeInTheDocument();
expect(screen.queryByText('三路正常')).not.toBeInTheDocument();
expect(screen.queryByTestId('fleet-map')).not.toBeInTheDocument();
});
test('pastes, deduplicates, and submits multiple plates as one batch search', async () => {
const vehicleRealtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
const input = screen.getByRole('textbox', { name: '搜索车辆' });
fireEvent.paste(input, { clipboardData: { getData: () => '粤a12345\n粤B67890粤A12345' } });
expect(input).toHaveValue('粤A12345粤B67890');
expect(screen.getByText('已识别 2 辆')).toBeInTheDocument();
expect(screen.getByText('正在批量筛选 2 辆')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
await waitFor(() => {
const params = vehicleRealtime.mock.calls[vehicleRealtime.mock.calls.length - 1]?.[0];
expect(params?.get('keywords')).toBe('粤A12345,粤B67890');
expect(params?.has('keyword')).toBe(false);
});
});

View File

@@ -1,15 +1,80 @@
import { IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { memo, useCallback, useDeferredValue, useMemo, useState } from 'react';
import { IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin, IconQrCode, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { useQuery } from '@tanstack/react-query';
import QRCode from 'qrcode';
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api/client';
import type { AlertEvent, MapReverseGeocode, VehicleDetail as VehicleDetailData, VehicleRealtimeRow } from '../../api/types';
import { FleetMap } from '../map/FleetMap';
import { EmptyState, InlineError } from '../shared/AsyncState';
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
import { MONITOR_REFRESH, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
const statuses = ['', 'online', 'offline', 'driving', 'idle'];
type AddressCoordinate = { longitude: number; latitude: number; key: string };
function addressCoordinate(vehicle: VehicleRealtimeRow): AddressCoordinate | undefined {
if (!Number.isFinite(vehicle.longitude) || !Number.isFinite(vehicle.latitude)
|| Math.abs(vehicle.longitude) > 180 || Math.abs(vehicle.latitude) > 90
|| (vehicle.longitude === 0 && vehicle.latitude === 0)) return undefined;
const longitude = Number(vehicle.longitude.toFixed(4));
const latitude = Number(vehicle.latitude.toFixed(4));
return { longitude, latitude, key: `${longitude.toFixed(4)},${latitude.toFixed(4)}` };
}
const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehicle: VehicleRealtimeRow }) {
const current = addressCoordinate(vehicle);
const [requested, setRequested] = useState<AddressCoordinate>();
const addressQuery = useQuery({
queryKey: ['monitor', 'list-address', requested?.key],
queryFn: () => api.reverseGeocode(new URLSearchParams({
longitude: requested!.longitude.toFixed(4),
latitude: requested!.latitude.toFixed(4)
})),
enabled: Boolean(requested),
staleTime: 6 * 60 * 60_000,
gcTime: 24 * 60 * 60_000,
refetchOnWindowFocus: false,
retry: 1
});
const moved = Boolean(current && requested && current.key !== requested.key);
if (!current) return <span className="v2-monitor-address-empty"></span>;
if (!requested) return <button type="button" className="v2-monitor-address-action" aria-label={`解析${vehicle.plate || vehicle.vin}位置`} title="按需调用高德逆地理编码,不随实时数据刷新重复请求" onClick={() => setRequested(current)}><IconMapPin /></button>;
if (addressQuery.isFetching && !addressQuery.data) return <span className="v2-monitor-address-loading"><i className="v2-spinner" /></span>;
if (addressQuery.isError) return <button type="button" className="v2-monitor-address-action is-error" onClick={() => void addressQuery.refetch()}></button>;
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span>{moved ? <button type="button" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}> · </button> : null}</div>;
});
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
return <section className="v2-monitor-table-panel">
<header><div><strong></strong><span></span></div><b>{total.toLocaleString('zh-CN')} </b></header>
<div className="v2-monitor-table-scroll"><table><thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>{rows.map((row) => <tr key={row.vin}>
<td className="v2-monitor-table-vehicle"><button type="button" title="在地图中定位" onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></button></td>
<td><strong className="v2-monitor-live-value">{formatNumber(row.speedKmh, 1)}</strong><small className="v2-monitor-live-unit">km/h</small></td>
<td><strong className="v2-monitor-live-value">{formatNumber(row.totalMileageKm, 1)}</strong><small className="v2-monitor-live-unit">km</small></td>
<td><code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code></td>
<td><MonitorAddressCell vehicle={row} /></td>
</tr>)}</tbody></table>{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" /></div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div>
<div className="v2-monitor-mobile-cards">{rows.map((row) => <article key={row.vin}>
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><b>{formatNumber(row.speedKmh, 1)}<small>km/h</small></b></header>
<dl><div><dt></dt><dd>{formatNumber(row.totalMileageKm, 1)} km</dd></div><div><dt></dt><dd><code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code></dd></div><div className="is-address"><dt></dt><dd><MonitorAddressCell vehicle={row} /></dd></div></dl>
<footer><button type="button" onClick={() => onSelect(row.vin)}><IconMapPin /></button></footer>
</article>)}{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" /></div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div>
<footer><span> {page} / {totalPages} </span><div><button type="button" disabled={page <= 1} onClick={() => onPage(page - 1)}></button><button type="button" disabled={page >= totalPages} onClick={() => onPage(page + 1)}></button><select aria-label="每页车辆数" value={limit} onChange={(event) => onLimit(Number(event.target.value))}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer>
</section>;
}
function MobileEntry({ onClose }: { onClose: () => void }) {
const [qr, setQr] = useState('');
const url = `${window.location.origin}/monitor`;
useEffect(() => { void QRCode.toDataURL(url, { width: 240, margin: 2, color: { dark: '#122033', light: '#ffffff' } }).then(setQr); }, [url]);
return <div className="v2-monitor-qr-backdrop" role="dialog" aria-modal="true" aria-label="手机端入口"><section><button type="button" onClick={onClose} aria-label="关闭手机端入口"><IconClose /></button><IconQrCode /><h3></h3><p>使 Token</p>{qr ? <img src={qr} alt="全局监控手机端二维码" /> : <span className="v2-spinner" />}<code>{url}</code><button type="button" onClick={() => void navigator.clipboard?.writeText(url)}>访</button></section></div>;
}
const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { vehicle: VehicleRealtimeRow; selected: boolean; onSelect: (vin: string) => void }) {
const status = vehicleStatus(vehicle);
return (
@@ -93,8 +158,13 @@ function VehicleDetailCard({
}
export default function MonitorPage() {
const [mode, setMode] = useState<'map' | 'list'>('map');
const [mobileEntryOpen, setMobileEntryOpen] = useState(false);
const [listOffset, setListOffset] = useState(0);
const [listLimit, setListLimit] = useState(50);
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword);
const searchTerms = useMemo(() => parseMonitorSearchTerms(keyword), [keyword]);
const [protocol, setProtocol] = useState('');
const [status, setStatus] = useState('');
const [selectedVin, setSelectedVin] = useState('');
@@ -103,7 +173,18 @@ export default function MonitorPage() {
const updateViewport = useCallback((next: MonitorViewport) => {
setViewport((current) => current.zoom === next.zoom && current.bounds === next.bounds ? current : next);
}, []);
const { summary, vehicles, map, selectedVehicle } = useMonitorData({ keyword: deferredKeyword, protocol, status }, viewport, selectedVin);
const filters = useMemo(() => ({ keyword: deferredKeyword, protocol, status }), [deferredKeyword, protocol, status]);
const listParams = useMemo(() => {
const params = monitorQueryParams(filters, listLimit);
params.set('offset', String(listOffset));
return params;
}, [filters, listLimit, listOffset]);
const { summary, vehicles, map, selectedVehicle } = useMonitorData(filters, viewport, selectedVin, mode === 'map', mode === 'map');
const realtimeListQuery = useQuery({
queryKey: ['monitor', 'vehicle-list', listParams.toString()],
queryFn: () => api.vehicleRealtime(listParams),
enabled: mode === 'list', placeholderData: (previous) => previous, refetchInterval: mode === 'list' ? MONITOR_REFRESH.fleet : false
});
const rows = useMemo(() => {
const data = vehicles.data?.items ?? [];
if (status === 'driving' || status === 'idle') return data.filter((vehicle) => vehicleStatus(vehicle) === status);
@@ -115,6 +196,7 @@ export default function MonitorPage() {
const selectVehicle = useCallback((vin: string) => {
setSelectedVin(vin);
setDetailOpen(true);
setMode('map');
}, []);
const clearSelection = useCallback(() => {
setSelectedVin('');
@@ -131,15 +213,33 @@ export default function MonitorPage() {
return (
<div className="v2-monitor-page">
<section className="v2-filterbar" aria-label="车辆筛选">
<label className="v2-search-field"><IconSearch /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="车牌 / VIN / 厂家" /></label>
<select value={protocol} onChange={(event) => setProtocol(event.target.value)} aria-label="协议">
<label className={`v2-search-field${searchTerms.length > 1 ? ' is-batch' : ''}`}>
<IconSearch />
<input
aria-label="搜索车辆"
value={keyword}
onChange={(event) => { setKeyword(event.target.value); setListOffset(0); }}
onPaste={(event) => {
const pastedTerms = parseMonitorSearchTerms(event.clipboardData.getData('text'));
if (pastedTerms.length <= 1) return;
event.preventDefault();
setKeyword(pastedTerms.join(''));
setListOffset(0);
}}
placeholder="车牌 / VIN可批量粘贴车牌"
/>
{searchTerms.length > 1 ? <span className="v2-search-batch-count" aria-live="polite" title={searchTerms.length === MAX_MONITOR_SEARCH_TERMS ? `最多支持 ${MAX_MONITOR_SEARCH_TERMS} 条;${searchTerms.join('、')}` : searchTerms.join('、')}> {searchTerms.length} </span> : null}
</label>
<select value={protocol} onChange={(event) => { setProtocol(event.target.value); setListOffset(0); }} aria-label="协议">
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
</select>
<select value={status} onChange={(event) => setStatus(event.target.value)} aria-label="在线状态">
<select value={status} onChange={(event) => { setStatus(event.target.value); setListOffset(0); }} aria-label="在线状态">
{statuses.map((item) => <option key={item} value={item}>{item ? statusLabel(item as never) : '全部状态'}</option>)}
</select>
<button type="button" className="v2-secondary-button" onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); }}><IconRefresh /></button>
<button type="button" className="v2-secondary-button" onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); setListOffset(0); }}><IconRefresh /></button>
<button type="button" className="v2-primary-button"><IconFilter /></button>
<div className="v2-monitor-mode" aria-label="监控视图"><button type="button" className={mode === 'map' ? 'is-active' : ''} onClick={() => setMode('map')}><IconMapPin /></button><button type="button" className={mode === 'list' ? 'is-active' : ''} onClick={() => { setMode('list'); setDetailOpen(false); }}><IconList /></button></div>
<button type="button" className="v2-monitor-mobile-entry" onClick={() => setMobileEntryOpen(true)}><IconQrCode /></button>
</section>
<section className="v2-kpis" aria-label="车辆整体统计">
@@ -155,10 +255,11 @@ export default function MonitorPage() {
</section>
{vehicles.isError ? <InlineError message={vehicles.error instanceof Error ? vehicles.error.message : '车辆数据加载失败'} onRetry={() => vehicles.refetch()} /> : null}
<section className={`v2-monitor-workspace${selected && detailOpen ? ' is-detail-open' : ''}${selected && !detailOpen ? ' is-detail-collapsed' : ''}`}>
{mode === 'list' && realtimeListQuery.isError ? <InlineError message={realtimeListQuery.error instanceof Error ? realtimeListQuery.error.message : '车辆列表加载失败'} onRetry={() => realtimeListQuery.refetch()} /> : null}
{mode === 'map' ? <section className={`v2-monitor-workspace${selected && detailOpen ? ' is-detail-open' : ''}${selected && !detailOpen ? ' is-detail-collapsed' : ''}`}>
<div className="v2-vehicle-rail">
<header><strong></strong><span>{formatNumber(vehicles.data?.total ?? rows.length)} </span></header>
<div className="v2-rail-search"><IconSearch /><span>{deferredKeyword ? `正在筛选“${deferredKeyword}` : '按最新上报排序'}</span></div>
<div className="v2-rail-search"><IconSearch /><span>{searchTerms.length > 1 ? `正在批量筛选 ${searchTerms.length}` : deferredKeyword ? `正在筛选“${deferredKeyword}` : '按最新上报排序'}</span></div>
<div className="v2-vehicle-scroll">
{vehicles.isLoading ? <div className="v2-list-loading"><span className="v2-spinner" /></div> : null}
{!vehicles.isLoading && rows.length === 0 ? <EmptyState /> : null}
@@ -193,15 +294,16 @@ export default function MonitorPage() {
</button>
</aside>
) : null}
</section>
</section> : <MonitorVehicleTable rows={realtimeListQuery.data?.items ?? []} total={realtimeListQuery.data?.total ?? 0} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil((realtimeListQuery.data?.total ?? 0) / listLimit))} limit={listLimit} loading={realtimeListQuery.isFetching} onSelect={selectVehicle} onPage={(page) => setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
<section className="v2-event-strip">
<strong></strong>
<span><i className="is-online" />{vehicles.isFetching ? '正在刷新' : '实时车辆已同步'}</span>
<span> {rows.length} · {map.data?.clusters.length ? `${map.data.clusters.length} 个聚合 + ${map.data.points.length} 个车辆点` : `${map.data?.points.length ?? 0} 个点`}</span>
<span>{mode === 'map' ? `列表 ${rows.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"><b></b> {MONITOR_REFRESH.selected / 1000} · {MONITOR_REFRESH.fleet / 1000} · {MONITOR_REFRESH.summary / 1000} </span>
<time>{new Date().toLocaleString('zh-CN', { hour12: false })}</time>
</section>
{mobileEntryOpen ? <MobileEntry onClose={() => setMobileEntryOpen(false)} /> : null}
</div>
);
}

View File

@@ -1,30 +1,126 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import StatisticsPage from './StatisticsPage';
const mileageStatistics = vi.hoisted(() => vi.fn());
vi.mock('../../api/client', () => ({ api: { mileageStatistics } }));
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn() }));
const exportMocks = vi.hoisted(() => ({ downloadMileageWorkbook: vi.fn() }));
vi.mock('../../api/client', () => ({ api: mocks }));
vi.mock('../domain/mileageExport', () => exportMocks);
afterEach(() => { cleanup(); mileageStatistics.mockReset(); });
afterEach(() => { cleanup(); window.localStorage.clear(); Object.values(mocks).forEach((mock) => mock.mockReset()); exportMocks.downloadMileageWorkbook.mockReset(); });
test('shows distinct period and latest fleet mileage metrics with exact daily evidence', async () => {
mileageStatistics.mockResolvedValue({
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 2, recordCount: 2, sourceCount: 2,
periodMileageKm: 193.3, fleetLatestMileageKm: 168723.9, averageMileagePerVin: 96.65, averageDailyMileageKm: 96.65,
trend: [{ date: '2026-07-14', mileageKm: 193.3, vehicles: 2 }],
ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 104.6, latestMileageKm: 119925, activeDays: 1 }],
function renderPage(initialEntry = '/statistics') {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[initialEntry]}><StatisticsPage /></MemoryRouter></QueryClientProvider>);
}
function prepareData() {
mocks.mileageStatistics.mockResolvedValue({
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 1, recordCount: 2, sourceCount: 2,
periodMileageKm: 193.3, fleetLatestMileageKm: 119925, averageMileagePerVin: 193.3, averageDailyMileageKm: 96.65,
trend: [], ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 193.3, latestMileageKm: 119925, activeDays: 2 }],
asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence'
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter><StatisticsPage /></MemoryRouter></QueryClientProvider>);
mocks.dailyMileage.mockResolvedValue({ items: [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, source: 'GB32960' }
], total: 2, limit: 10000, offset: 0 });
mocks.vehicles.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocol: 'GB32960', online: true, lastSeen: '', locationText: '', bindingScore: 100 }], total: 1, limit: 12, offset: 0 });
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
}
test('renders one vehicle per row with dates as columns and a period total', async () => {
prepareData();
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
expect(screen.getAllByText('区间总里程').length).toBeGreaterThan(1);
expect((await screen.findAllByText('193.3 km')).length).toBeGreaterThan(0);
expect(screen.getByText('168,723.9 km')).toBeInTheDocument();
expect(screen.getByText('按车辆与自然日去重')).toBeInTheDocument();
expect(screen.getByText('粤A12345')).toHaveAttribute('href', '/vehicles/LTEST000000000001');
expect(screen.getByRole('img', { name: '每日行驶里程趋势图' })).toBeInTheDocument();
await waitFor(() => expect(mileageStatistics).toHaveBeenCalledTimes(1));
expect(mileageStatistics.mock.calls[0][0].get('dateFrom')).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(screen.getAllByText('104.6 km').length).toBeGreaterThan(0);
expect(screen.getAllByText('88.7 km').length).toBeGreaterThan(0);
expect(screen.getAllByText('7/13').length).toBeGreaterThan(0);
expect(screen.getAllByText('7/14').length).toBeGreaterThan(0);
expect(screen.getAllByText('粤A12345').length).toBeGreaterThan(0);
expect(screen.queryByText('当日起始里程')).not.toBeInTheDocument();
expect(screen.queryByText('数据来源')).not.toBeInTheDocument();
expect(screen.queryByRole('img', { name: '每日行驶里程趋势图' })).not.toBeInTheDocument();
expect(screen.queryByText('车辆里程排名')).not.toBeInTheDocument();
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
expect(mocks.dailyMileage.mock.calls[0][0].get('limit')).toBe('10000');
expect(mocks.dailyMileage.mock.calls[0][0].get('protocols')).toBe('GB32960,JT808,YUTONG_MQTT');
});
test('supports searching and selecting license plates before querying exact VINs', async () => {
prepareData();
renderPage();
const search = screen.getByRole('textbox', { name: '搜索车牌' });
fireEvent.focus(search);
fireEvent.change(search, { target: { value: '粤A12' } });
expect(await screen.findByRole('option', { name: /粤A12345/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole('option', { name: /粤A12345/ }));
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => {
const calls = mocks.mileageStatistics.mock.calls;
expect(calls[calls.length - 1]?.[0].get('vins')).toBe('LTEST000000000001');
});
expect(screen.getByTitle('LTEST000000000001')).toHaveTextContent('粤A12345');
});
test('lets users disable mileage sources and persists the source priority', async () => {
prepareData();
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
fireEvent.click(screen.getByRole('button', { name: /数据源/ }));
expect(screen.getByRole('dialog', { name: '数据源策略配置' })).toBeInTheDocument();
expect(screen.getByText('GPS 里程')).toBeInTheDocument();
expect(screen.getAllByText('仪表盘里程')).toHaveLength(2);
fireEvent.click(screen.getByRole('switch', { name: '禁用 国标 GB32960' }));
fireEvent.click(screen.getByRole('button', { name: '上移 宇通 MQTT' }));
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => {
const calls = mocks.dailyMileage.mock.calls;
expect(calls[calls.length - 1]?.[0].get('protocols')).toBe('YUTONG_MQTT,JT808');
});
expect(window.localStorage.getItem('vehicle-platform:mileage-source-strategy')).toContain('YUTONG_MQTT');
expect(screen.getByText('来源优先级YUTONG_MQTT JT808')).toBeInTheDocument();
});
test('paginates all unique vehicles when no license plate is selected', async () => {
prepareData();
const firstPage = Array.from({ length: 20 }, (_, index) => ({ vin: `VIN${String(index + 1).padStart(14, '0')}`, plate: `粤A${String(index + 1).padStart(5, '0')}` }));
const secondPage = Array.from({ length: 12 }, (_, index) => ({ vin: `VIN${String(index + 21).padStart(14, '0')}`, plate: `粤A${String(index + 21).padStart(5, '0')}` }));
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams) => Promise.resolve({
items: params.get('offset') === '20' ? secondPage : firstPage,
total: 32,
limit: 20,
offset: Number(params.get('offset') ?? 0)
}));
mocks.dailyMileage.mockResolvedValue({ items: [], total: 0, limit: 10000, offset: 0 });
renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
expect((await screen.findAllByText('粤A00001')).length).toBeGreaterThan(0);
expect(screen.getByText('第 1 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
expect(mocks.vehicleCoverage.mock.calls[0][0].get('bindingStatus')).toBe('bound');
await waitFor(() => expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1]?.[0].get('vins')).toContain('VIN00000000000001'));
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
expect((await screen.findAllByText('粤A00021')).length).toBeGreaterThan(0);
expect(screen.getByText('第 2 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
await waitFor(() => expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1]?.[0].get('offset')).toBe('20'));
});
test('exports the full fleet mileage with one paginated query instead of VIN batches', async () => {
prepareData();
exportMocks.downloadMileageWorkbook.mockResolvedValue(undefined);
renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
await waitFor(() => expect(screen.getByRole('button', { name: /导出 Excel/ })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: /导出 Excel/ }));
await waitFor(() => expect(exportMocks.downloadMileageWorkbook).toHaveBeenCalledTimes(1));
const exportMileageCall = mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][0] as URLSearchParams;
expect(exportMileageCall.get('vins')).toBeNull();
expect(exportMileageCall.get('vehicleScope')).toBe('bound');
expect(exportMocks.downloadMileageWorkbook.mock.calls[0][0].vehicles).toHaveLength(1);
expect(screen.getByText(/已导出 1 辆车/)).toBeInTheDocument();
});

View File

@@ -1,12 +1,30 @@
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { useQuery } from '@tanstack/react-query';
import { FormEvent, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { MileageStatistics, MileageTrendPoint } from '../../api/types';
import type { DailyMileageRow, MileageStatistics, VehicleRow } from '../../api/types';
import { downloadMileageWorkbook } from '../domain/mileageExport';
import { InlineError } from '../shared/AsyncState';
const DAY = 86_400_000;
const DETAIL_LIMIT = 10_000;
const MAX_SELECTED_VEHICLES = 20;
const PAGE_SIZE = 20;
const EXPORT_VEHICLE_PAGE_SIZE = 2_000;
const EXPORT_VIN_BATCH_SIZE = 50;
type VehicleOption = Pick<VehicleRow, 'vin' | 'plate'>;
type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
type MileageSourceOption = { protocol: MileageProtocol; label: string; mileageType: string; enabled: boolean };
type Criteria = { vehicles: VehicleOption[]; dateFrom: string; dateTo: string; sources: MileageSourceOption[] };
const SOURCE_STORAGE_KEY = 'vehicle-platform:mileage-source-strategy';
const DEFAULT_SOURCES: MileageSourceOption[] = [
{ protocol: 'GB32960', label: '国标 GB32960', mileageType: '仪表盘里程', enabled: true },
{ protocol: 'JT808', label: '交通部 JT/T 808', mileageType: 'GPS 里程', enabled: true },
{ protocol: 'YUTONG_MQTT', label: '宇通 MQTT', mileageType: '仪表盘里程', enabled: true }
];
function localDate(value = new Date()) {
const offset = value.getTimezoneOffset() * 60_000;
@@ -18,73 +36,293 @@ function defaultWindow(days = 30) {
return { dateFrom: localDate(new Date(end.getTime() - (days - 1) * DAY)), dateTo: localDate(end) };
}
function formatKm(value?: number, compact = false) {
function formatKm(value?: number) {
if (value == null || !Number.isFinite(value)) return '—';
return new Intl.NumberFormat('zh-CN', compact ? { notation: 'compact', maximumFractionDigits: 1 } : { maximumFractionDigits: 1 }).format(value);
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(value);
}
function MileageChart({ points }: { points: MileageTrendPoint[] }) {
if (!points.length) return <div className="v2-stat-empty"></div>;
const width = 860; const height = 238; const left = 58; const right = 18; const top = 16; const bottom = 34;
const max = Math.max(...points.map((point) => point.mileageKm), 1);
const x = (index: number) => left + (width - left - right) * (points.length === 1 ? .5 : index / (points.length - 1));
const y = (value: number) => top + (height - top - bottom) * (1 - value / max);
const path = points.map((point, index) => `${index ? 'L' : 'M'}${x(index).toFixed(1)},${y(point.mileageKm).toFixed(1)}`).join(' ');
const labelIndexes = Array.from(new Set([0, Math.floor((points.length - 1) / 2), points.length - 1]));
return <div className="v2-stat-chart-wrap"><svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="每日行驶里程趋势图">
<g className="v2-stat-grid">{[0, .5, 1].map((ratio) => <line key={ratio} x1={left} x2={width - right} y1={y(max * ratio)} y2={y(max * ratio)} />)}</g>
<g className="v2-stat-axis"><text x={left - 8} y={y(max) + 4} textAnchor="end">{formatKm(max, true)}</text><text x={left - 8} y={y(max / 2) + 4} textAnchor="end">{formatKm(max / 2, true)}</text><text x={left - 8} y={y(0) + 4} textAnchor="end">0</text>{labelIndexes.map((index) => <text key={index} x={x(index)} y={height - 8} textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}>{points[index].date.slice(5)}</text>)}</g>
<path className="v2-stat-area" d={`${path} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`} />
<path className="v2-stat-line" d={path} />
{points.map((point, index) => <circle key={point.date} className="v2-stat-point" cx={x(index)} cy={y(point.mileageKm)} r="3"><title>{point.date}{formatKm(point.mileageKm)} km{point.vehicles} </title></circle>)}
</svg></div>;
function inclusiveDays(dateFrom: string, dateTo: string) {
const from = Date.parse(`${dateFrom}T00:00:00`);
const to = Date.parse(`${dateTo}T00:00:00`);
return Number.isFinite(from) && Number.isFinite(to) ? Math.max(1, Math.round((to - from) / DAY) + 1) : 0;
}
function Kpis({ data }: { data?: MileageStatistics }) {
function mileageParams(criteria: Criteria, offset = 0) {
const params = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
if (criteria.vehicles.length) params.set('vins', criteria.vehicles.map((vehicle) => vehicle.vin).join(','));
else params.set('vehicleScope', 'bound');
params.set('protocols', criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(','));
if (offset >= 0) {
params.set('deduplicate', '1');
params.set('limit', String(DETAIL_LIMIT));
params.set('offset', String(offset));
}
return params;
}
function initialCriteria(searchParams: URLSearchParams): Criteria {
const defaults = defaultWindow(30);
const vins = (searchParams.get('vins') ?? '').split(',').map((vin) => vin.trim()).filter(Boolean).slice(0, MAX_SELECTED_VEHICLES);
const requestedProtocols = (searchParams.get('protocols') ?? '').split(',').filter((protocol): protocol is MileageProtocol => DEFAULT_SOURCES.some((source) => source.protocol === protocol));
let sources = DEFAULT_SOURCES.map((source) => ({ ...source }));
if (requestedProtocols.length) {
sources = [...requestedProtocols.map((protocol) => ({ ...DEFAULT_SOURCES.find((source) => source.protocol === protocol)!, enabled: true })), ...DEFAULT_SOURCES.filter((source) => !requestedProtocols.includes(source.protocol)).map((source) => ({ ...source, enabled: false }))];
} else {
try {
const stored = JSON.parse(window.localStorage.getItem(SOURCE_STORAGE_KEY) ?? '[]') as Partial<MileageSourceOption>[];
const normalized = stored.flatMap((item) => {
const source = DEFAULT_SOURCES.find((candidate) => candidate.protocol === item.protocol);
return source ? [{ ...source, enabled: item.enabled !== false }] : [];
});
if (normalized.length === DEFAULT_SOURCES.length && normalized.some((source) => source.enabled)) sources = normalized;
} catch { /* retain safe defaults */ }
}
return {
vehicles: vins.map((vin) => ({ vin, plate: '' })),
dateFrom: searchParams.get('dateFrom') ?? defaults.dateFrom,
dateTo: searchParams.get('dateTo') ?? defaults.dateTo,
sources
};
}
function SourceStrategy({ value, onChange }: { value: MileageSourceOption[]; onChange: (sources: MileageSourceOption[]) => void }) {
const [open, setOpen] = useState(false);
const enabled = value.filter((source) => source.enabled);
const update = (sources: MileageSourceOption[]) => {
onChange(sources);
try { window.localStorage.setItem(SOURCE_STORAGE_KEY, JSON.stringify(sources)); } catch { /* preference persistence is optional */ }
};
const move = (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= value.length) return;
const next = [...value];
[next[index], next[target]] = [next[target], next[index]];
update(next);
};
const toggle = (protocol: MileageProtocol) => {
const current = value.find((source) => source.protocol === protocol);
if (current?.enabled && enabled.length === 1) return;
update(value.map((source) => source.protocol === protocol ? { ...source, enabled: !source.enabled } : source));
};
return <div className="v2-mileage-source-strategy">
<button type="button" className="v2-mileage-source-trigger" aria-haspopup="dialog" aria-expanded={open} onClick={() => setOpen((current) => !current)}>
<IconSetting /><span></span><b>{enabled.length}/3</b>
</button>
{open ? <section className="v2-mileage-source-popover" role="dialog" aria-label="数据源策略配置">
<header><div><strong></strong><span></span></div><button type="button" aria-label="关闭数据源策略" onClick={() => setOpen(false)}><IconClose /></button></header>
<div className="v2-mileage-source-list">{value.map((source, index) => <article key={source.protocol} className={source.enabled ? '' : 'is-disabled'}>
<button type="button" className={`v2-mileage-source-switch${source.enabled ? ' is-on' : ''}`} role="switch" aria-checked={source.enabled} aria-label={`${source.enabled ? '禁用' : '启用'} ${source.label}`} onClick={() => toggle(source.protocol)}><i /></button>
<div><strong>{source.label}</strong><small><b>{source.mileageType}</b><code>{source.protocol}</code></small></div>
<em>{source.enabled ? `优先级 ${enabled.findIndex((item) => item.protocol === source.protocol) + 1}` : '已禁用'}</em>
<p><button type="button" aria-label={`上移 ${source.label}`} disabled={index === 0} onClick={() => move(index, -1)}><IconArrowUp /></button><button type="button" aria-label={`下移 ${source.label}`} disabled={index === value.length - 1} onClick={() => move(index, 1)}><IconArrowDown /></button></p>
</article>)}</div>
<footer><span>使</span><button type="button" onClick={() => setOpen(false)}></button></footer>
</section> : null}
</div>;
}
function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onChange: (vehicles: VehicleOption[]) => void }) {
const [search, setSearch] = useState('');
const [debounced, setDebounced] = useState('');
const [open, setOpen] = useState(false);
useEffect(() => { const timer = window.setTimeout(() => setDebounced(search.trim()), 220); return () => window.clearTimeout(timer); }, [search]);
const candidateParams = useMemo(() => {
const params = new URLSearchParams({ limit: '12', offset: '0' });
if (debounced) params.set('keyword', debounced);
return params;
}, [debounced]);
const candidates = useQuery({
queryKey: ['mileage-vehicle-options', candidateParams.toString()],
queryFn: () => api.vehicles(candidateParams),
enabled: open,
staleTime: 60_000
});
const selected = useMemo(() => new Set(value.map((vehicle) => vehicle.vin)), [value]);
const options = (candidates.data?.items ?? []).filter((vehicle, index, rows) => rows.findIndex((item) => item.vin === vehicle.vin) === index);
const add = (vehicle: VehicleRow) => {
if (selected.has(vehicle.vin) || value.length >= MAX_SELECTED_VEHICLES) return;
onChange([...value, { vin: vehicle.vin, plate: vehicle.plate }]);
setSearch('');
};
return <label className="v2-mileage-vehicle-field">
<span></span>
<div className={`v2-mileage-multiselect${open ? ' is-open' : ''}`}>
<IconSearch />
<div className="v2-mileage-selection">
{value.map((vehicle) => <button key={vehicle.vin} type="button" className="v2-mileage-chip" title={vehicle.vin} onClick={() => onChange(value.filter((item) => item.vin !== vehicle.vin))}>
<span>{vehicle.plate || vehicle.vin}</span><IconClose />
</button>)}
<input value={search} onFocus={() => setOpen(true)} onBlur={() => window.setTimeout(() => setOpen(false), 120)} onChange={(event) => { setSearch(event.target.value); setOpen(true); }} placeholder={value.length ? '继续添加车牌' : '输入车牌搜索,可多选'} aria-label="搜索车牌" />
</div>
{open ? <div className="v2-mileage-options" role="listbox">
<header><span></span><em>{value.length}/{MAX_SELECTED_VEHICLES} </em></header>
{candidates.isLoading ? <p></p> : null}
{!candidates.isLoading && options.map((vehicle) => <button type="button" role="option" aria-selected={selected.has(vehicle.vin)} key={vehicle.vin} disabled={selected.has(vehicle.vin)} onMouseDown={(event) => event.preventDefault()} onClick={() => add(vehicle)}>
<strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span>{selected.has(vehicle.vin) ? <em></em> : null}
</button>)}
{!candidates.isLoading && !options.length ? <p></p> : null}
</div> : null}
</div>
<small> {MAX_SELECTED_VEHICLES} </small>
</label>;
}
function SummaryRail({ data, criteria, fleetTotal }: { data?: MileageStatistics; criteria: Criteria; fleetTotal?: number }) {
const days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
const vehicleCount = criteria.vehicles.length ? data?.vehicleCount ?? 0 : fleetTotal ?? 0;
const items = [
['统计期行驶里程', `${formatKm(data?.periodMileageKm)} km`, '按车辆与自然日去重'],
['最新里程表总和', `${formatKm(data?.fleetLatestMileageKm)} km`, '每车取最新一次上报'],
['有里程车辆', formatKm(data?.vehicleCount), `${data?.sourceCount ?? 0} 个数据来源`],
['车均行驶里程', `${formatKm(data?.averageMileagePerVin)} km`, '统计期累计 / 车辆'],
['车日均里程', `${formatKm(data?.averageDailyMileageKm)} km`, '有效车辆日平均']
['查询车辆', `${vehicleCount}`, criteria.vehicles.length ? `已选择 ${criteria.vehicles.length}` : `全部车辆 · ${data?.vehicleCount ?? 0} 辆有里程`],
['统计天数', `${days}`, `${criteria.dateFrom}${criteria.dateTo}`],
['区间总里程', `${formatKm(data?.periodMileageKm)} km`, `${data?.recordCount ?? 0} 条车辆日记录`],
['日均里程', `${formatKm(data?.averageDailyMileageKm)} km`, '按有效车辆日平均']
];
return <section className="v2-stat-kpis">{items.map(([label, value, note], index) => <article key={label} className={index < 2 ? 'is-primary' : ''}><small>{label}</small><strong>{value}</strong><span>{note}</span></article>)}</section>;
return <section className="v2-mileage-summary" aria-label="里程查询统计信息">{items.map(([label, value, note], index) => <article key={label} className={index === 2 ? 'is-primary' : ''}><small>{label}</small><strong>{value}</strong><span>{note}</span></article>)}</section>;
}
type VehicleMileageMatrix = VehicleOption & { days: Map<string, number>; sources: Map<string, string>; totalMileageKm: number };
function rangeDates(dateFrom: string, dateTo: string) {
const dates: string[] = [];
const cursor = new Date(`${dateFrom}T00:00:00`);
const end = new Date(`${dateTo}T00:00:00`);
while (cursor <= end) { dates.push(localDate(cursor)); cursor.setDate(cursor.getDate() + 1); }
return dates;
}
function dateLabel(date: string) {
const [, month, day] = date.split('-');
return `${Number(month)}/${Number(day)}`;
}
function MileageTable({ rows, dates }: { rows: VehicleMileageMatrix[]; dates: string[] }) {
const maxDailyMileage = Math.max(1, ...rows.flatMap((row) => Array.from(row.days.values())));
return <>
<div className="v2-mileage-table-wrap">
<table className="v2-mileage-table">
<thead><tr><th className="is-sticky is-plate"></th><th className="is-sticky is-vin">VIN</th>{dates.map((date) => <th key={date} className="is-number is-date" title={date}>{dateLabel(date)}</th>)}<th className="is-number is-total"></th></tr></thead>
<tbody>{rows.map((row) => <tr key={row.vin}><td className="is-sticky is-plate"><strong>{row.plate || '未绑定'}</strong></td><td className="is-sticky is-vin"><code>{row.vin}</code></td>{dates.map((date) => {
const mileage = row.days.get(date);
const intensity = mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
return <td key={date} className={`is-number${mileage != null ? ' is-daily' : ' is-empty'}`} title={mileage != null ? `来源:${row.sources.get(date) || '—'}` : undefined} style={intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined}>{mileage != null ? `${formatKm(mileage)} km` : '—'}</td>;
})}<td className="is-number is-period is-total">{formatKm(row.totalMileageKm)} km</td></tr>)}</tbody>
</table>
</div>
<div className="v2-mileage-mobile-list">{rows.map((row) => <article key={row.vin}><header><div><strong>{row.plate || '未绑定'}</strong><span>{row.vin}</span></div><b>{formatKm(row.totalMileageKm)} km<small></small></b></header><div className="v2-mileage-mobile-days">{dates.map((date) => <div key={date}><time>{dateLabel(date)}</time><strong>{row.days.has(date) ? `${formatKm(row.days.get(date))} km` : '—'}</strong></div>)}</div></article>)}</div>
</>;
}
export default function StatisticsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const defaults = useMemo(() => defaultWindow(30), []);
const initial = { vin: searchParams.get('vin') ?? '', protocol: searchParams.get('protocol') ?? '', dateFrom: searchParams.get('dateFrom') ?? defaults.dateFrom, dateTo: searchParams.get('dateTo') ?? defaults.dateTo };
const [draft, setDraft] = useState(initial); const [criteria, setCriteria] = useState(initial);
const params = useMemo(() => { const next = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo }); if (criteria.vin) next.set('vin', criteria.vin); if (criteria.protocol) next.set('protocol', criteria.protocol); return next; }, [criteria]);
const query = useQuery({ queryKey: ['mileage-statistics', params.toString()], queryFn: () => api.mileageStatistics(params), staleTime: 60_000, refetchInterval: 5 * 60_000, placeholderData: (previous) => previous });
const submit = (event: FormEvent) => { event.preventDefault(); setCriteria(draft); setSearchParams(paramsFrom(draft), { replace: true }); };
const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setDraft(next); setCriteria(next); setSearchParams(paramsFrom(next), { replace: true }); };
const data = query.data; const maximumRank = Math.max(...(data?.ranking.map((item) => item.mileageKm) ?? [1]), 1);
const [draft, setDraft] = useState<Criteria>(() => initialCriteria(searchParams));
const [criteria, setCriteria] = useState<Criteria>(() => initialCriteria(searchParams));
const [page, setPage] = useState(1);
const [isExporting, setIsExporting] = useState(false);
const [exportFeedback, setExportFeedback] = useState('');
const hasVehicles = criteria.vehicles.length > 0;
const fleetParams = useMemo(() => new URLSearchParams({ limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE), bindingStatus: 'bound' }), [page]);
const fleetVehicles = useQuery({
queryKey: ['mileage-fleet-page', fleetParams.toString()],
queryFn: () => api.vehicleCoverage(fleetParams),
enabled: !hasVehicles,
staleTime: 60_000
});
const displayVehicles = useMemo<VehicleOption[]>(() => hasVehicles
? criteria.vehicles
: (fleetVehicles.data?.items ?? []).map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate })), [criteria.vehicles, fleetVehicles.data?.items, hasVehicles]);
const statisticsParams = useMemo(() => mileageParams(criteria, -1), [criteria]);
const rowsCriteria = useMemo(() => ({ ...criteria, vehicles: displayVehicles }), [criteria, displayVehicles]);
const rowsParams = useMemo(() => mileageParams(rowsCriteria, 0), [rowsCriteria]);
const statistics = useQuery({ queryKey: ['mileage-statistics', statisticsParams.toString()], queryFn: () => api.mileageStatistics(statisticsParams), staleTime: 60_000, placeholderData: (previous) => previous });
const mileage = useQuery({ queryKey: ['daily-mileage-query', rowsParams.toString()], queryFn: () => api.dailyMileage(rowsParams), enabled: displayVehicles.length > 0, staleTime: 60_000, placeholderData: (previous) => previous });
const totals = useMemo(() => new Map((statistics.data?.ranking ?? []).map((row) => [row.vin, row.mileageKm])), [statistics.data?.ranking]);
const dates = useMemo(() => rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo]);
const matrixRows = useMemo(() => displayVehicles.map((vehicle) => {
const days = new Map<string, number>();
const sources = new Map<string, string>();
const dailyRows = (mileage.data?.items ?? []).filter((row) => row.vin === vehicle.vin);
for (const row of dailyRows) { days.set(row.date, row.dailyMileageKm); sources.set(row.date, row.source); }
const plate = vehicle.plate || dailyRows.find((row) => row.plate)?.plate || statistics.data?.ranking.find((row) => row.vin === vehicle.vin)?.plate || '';
return { ...vehicle, plate, days, sources, totalMileageKm: totals.get(vehicle.vin) ?? Array.from(days.values()).reduce((sum, value) => sum + value, 0) };
}), [displayVehicles, mileage.data?.items, statistics.data?.ranking, totals]);
const totalVehicles = hasVehicles ? criteria.vehicles.length : fleetVehicles.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(totalVehicles / PAGE_SIZE));
const submit = (event: FormEvent) => { event.preventDefault(); setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(mileageParams(draft, -1), { replace: true }); };
const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setDraft(next); setCriteria(next); setSearchParams(mileageParams(next, -1), { replace: true }); };
const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching;
return <div className="v2-stat-page">
<header className="v2-stat-heading"><div><h2></h2><p></p></div><button type="button" onClick={() => query.refetch()} disabled={query.isFetching}><IconRefresh />{query.isFetching ? '更新中' : '刷新数据'}</button></header>
<form className="v2-stat-filter" onSubmit={submit}>
<label className="v2-stat-search"><span></span><div><IconSearch /><input value={draft.vin} onChange={(event) => setDraft((current) => ({ ...current, vin: event.target.value }))} placeholder="车牌 / VIN留空统计全车队" /></div></label>
<label><span></span><input type="date" value={draft.dateFrom} max={draft.dateTo} onChange={(event) => setDraft((current) => ({ ...current, dateFrom: event.target.value }))} /></label>
<label><span></span><input type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(event) => setDraft((current) => ({ ...current, dateTo: event.target.value }))} /></label>
<label><span></span><select value={draft.protocol} onChange={(event) => setDraft((current) => ({ ...current, protocol: event.target.value }))}><option value=""></option><option value="GB32960">GB32960</option><option value="JT808">JT808</option><option value="YUTONG_MQTT">YUTONG_MQTT</option></select></label>
<button className="v2-primary-button" type="submit"></button>
<div className="v2-stat-ranges"><button type="button" onClick={() => setDays(7)}> 7 </button><button type="button" onClick={() => setDays(30)}> 30 </button><button type="button" onClick={() => setDays(90)}> 90 </button></div>
</form>
{query.isError ? <InlineError message={query.error instanceof Error ? query.error.message : '统计数据加载失败'} onRetry={() => query.refetch()} /> : null}
<Kpis data={data} />
<div className="v2-stat-grid-layout">
<section className="v2-stat-card v2-stat-trend"><header><div><strong></strong><span>{data?.dateFrom || criteria.dateFrom} {data?.dateTo || criteria.dateTo}</span></div><em>{data?.trend.length ?? 0} </em></header><MileageChart points={data?.trend ?? []} /><div className="v2-stat-daily-list" aria-label="每日里程精确数据">{[...(data?.trend ?? [])].reverse().slice(0, 10).map((point) => <div key={point.date}><time>{point.date}</time><strong>{formatKm(point.mileageKm)} km</strong><span>{point.vehicles} </span></div>)}</div></section>
<section className="v2-stat-card v2-stat-ranking"><header><div><strong></strong><span> 20 </span></div></header><div>{data?.ranking.map((item, index) => <article key={item.vin}><b>{index + 1}</b><div><header><Link to={`/vehicles/${encodeURIComponent(item.vin)}`}>{item.plate || item.vin}</Link><strong>{formatKm(item.mileageKm)} km</strong></header><span><i style={{ width: `${Math.max(2, item.mileageKm / maximumRank * 100)}%` }} /></span><footer><small>{item.plate ? item.vin : '未绑定车牌'}</small><em>{item.activeDays} · {formatKm(item.latestMileageKm)} km</em></footer></div></article>)}{!query.isLoading && !data?.ranking.length ? <div className="v2-stat-empty"></div> : null}</div></section>
</div>
<footer className="v2-stat-evidence"><span>{data?.asOf || '—'}</span><span>{data?.evidence || '正在读取生产统计证据'}</span><span> 5 </span></footer>
const exportExcel = async () => {
if (isExporting || !totalVehicles) return;
setIsExporting(true);
setExportFeedback('');
try {
let vehicles: VehicleOption[] = criteria.vehicles.map((vehicle) => ({ ...vehicle }));
if (!vehicles.length) {
vehicles = [];
let offset = 0;
while (offset < totalVehicles) {
const result = await api.vehicleCoverage(new URLSearchParams({ limit: String(EXPORT_VEHICLE_PAGE_SIZE), offset: String(offset), bindingStatus: 'bound' }));
vehicles.push(...result.items.map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate })));
if (!result.items.length) break;
offset += result.items.length;
if (offset >= result.total) break;
}
}
const mileageRows: DailyMileageRow[] = [];
const vehicleBatches = criteria.vehicles.length
? Array.from({ length: Math.ceil(vehicles.length / EXPORT_VIN_BATCH_SIZE) }, (_, index) => vehicles.slice(index * EXPORT_VIN_BATCH_SIZE, (index + 1) * EXPORT_VIN_BATCH_SIZE))
: [[] as VehicleOption[]];
for (const vehicleBatch of vehicleBatches) {
let offset = 0;
while (true) {
const params = mileageParams({ ...criteria, vehicles: vehicleBatch }, offset);
const result = await api.dailyMileage(params);
mileageRows.push(...result.items);
offset += result.items.length;
if (!result.items.length || offset >= result.total) break;
}
}
const plateByVin = new Map(mileageRows.filter((row) => row.plate).map((row) => [row.vin, row.plate]));
vehicles = vehicles.map((vehicle) => ({ ...vehicle, plate: vehicle.plate || plateByVin.get(vehicle.vin) || '' }));
await downloadMileageWorkbook({
dateFrom: criteria.dateFrom,
dateTo: criteria.dateTo,
dates,
vehicles,
mileageRows,
sources: criteria.sources.filter((source) => source.enabled),
exportedAt: new Date()
});
setExportFeedback(`已导出 ${vehicles.length} 辆车`);
} catch (error) {
setExportFeedback(error instanceof Error ? `导出失败:${error.message}` : '导出失败,请稍后重试');
} finally {
setIsExporting(false);
}
};
return <div className="v2-mileage-page">
<header className="v2-mileage-heading"><div><h2></h2><p></p></div><button type="button" onClick={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} disabled={refreshing}><IconRefresh />{refreshing ? '更新中' : '刷新数据'}</button></header>
<section className="v2-mileage-query-panel">
<form className="v2-mileage-filter" onSubmit={submit}>
<VehicleMultiSelect value={draft.vehicles} onChange={(vehicles) => setDraft((current) => ({ ...current, vehicles }))} />
<label><span></span><input type="date" value={draft.dateFrom} max={draft.dateTo} onChange={(event) => setDraft((current) => ({ ...current, dateFrom: event.target.value }))} /></label>
<label><span></span><input type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(event) => setDraft((current) => ({ ...current, dateTo: event.target.value }))} /></label>
<button className="v2-primary-button" type="submit"></button>
<SourceStrategy value={draft.sources} onChange={(sources) => setDraft((current) => ({ ...current, sources }))} />
<div className="v2-mileage-ranges"><span></span><button type="button" onClick={() => setDays(7)}> 7 </button><button type="button" onClick={() => setDays(30)}> 30 </button><button type="button" onClick={() => setDays(90)}> 90 </button></div>
</form>
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} />
</section>
{statistics.isError || mileage.isError || fleetVehicles.isError ? <InlineError message={(statistics.error ?? mileage.error ?? fleetVehicles.error) instanceof Error ? (statistics.error ?? mileage.error ?? fleetVehicles.error as Error).message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
<section className="v2-mileage-results">
<header><div><strong></strong><span>{criteria.dateFrom} {criteria.dateTo}</span></div><div className="v2-mileage-result-actions"><em>{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles}`} · {dates.length} </em><button type="button" onClick={exportExcel} disabled={isExporting || !totalVehicles}><IconDownload />{isExporting ? '正在导出…' : '导出 Excel'}</button></div></header>
<MileageTable rows={matrixRows} dates={dates} />
{!fleetVehicles.isLoading && !displayVehicles.length ? <div className="v2-mileage-empty"></div> : null}
<footer><span>{hasVehicles ? `已选择 ${totalVehicles} 辆车辆` : `${page} / ${totalPages} 页 · 共 ${totalVehicles} 辆 · 每页 ${PAGE_SIZE}`}{exportFeedback ? ` · ${exportFeedback}` : ''}</span>{!hasVehicles && totalVehicles ? <div><button type="button" disabled={page <= 1 || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.max(1, current - 1))}></button><button type="button" disabled={page >= totalPages || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.min(totalPages, current + 1))}></button></div> : null}</footer>
</section>
<footer className="v2-mileage-evidence"><span>{statistics.data?.asOf || '—'}</span><span>{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' ')}</span><span> 1 </span></footer>
</div>;
}
function paramsFrom(criteria: { vin: string; protocol: string; dateFrom: string; dateTo: string }) {
const next = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
if (criteria.vin.trim()) next.set('vin', criteria.vin.trim());
if (criteria.protocol) next.set('protocol', criteria.protocol);
return next;
}

View File

@@ -0,0 +1,60 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import type { TrackPlaybackResponse } from '../../api/types';
import TrackPage from './TrackPage';
const mocks = vi.hoisted(() => ({ trackPlayback: vi.fn(), reverseGeocode: vi.fn(), vehicles: vi.fn() }));
vi.mock('../../api/client', () => ({ api: mocks }));
vi.mock('../map/TrackMap', () => ({ TrackMap: ({ activeIndex }: { activeIndex: number }) => <div data-testid="track-map" data-active-index={activeIndex} /> }));
const track = {
vin: 'LTEST000000000001', plate: '粤A12345', total: 3, truncated: false, sampled: false, asOf: '2026-07-15T08:00:00Z',
points: [
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:00:00Z', serverTime: '2026-07-15T00:00:01Z', longitude: 113.1, latitude: 23.1, speedKmh: 0, totalMileageKm: 100, socPercent: 80, socAvailable: true },
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:10:00Z', serverTime: '2026-07-15T00:10:01Z', longitude: 113.2, latitude: 23.2, speedKmh: 35, totalMileageKm: 104, socPercent: 79, socAvailable: true },
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:20:00Z', serverTime: '2026-07-15T00:20:01Z', longitude: 113.3, latitude: 23.3, speedKmh: 10, totalMileageKm: 108, socPercent: 78, socAvailable: true }
],
events: [
{ index: 0, sampledIndex: 0, type: 'start', title: '开始行驶', time: '2026-07-15T00:00:00Z', speedKmh: 0, longitude: 113.1, latitude: 23.1 },
{ index: 1, sampledIndex: 1, type: 'acceleration', title: '急加速', time: '2026-07-15T00:10:00Z', speedKmh: 35, longitude: 113.2, latitude: 23.2 },
{ index: 2, sampledIndex: 2, type: 'end', title: '结束行驶', time: '2026-07-15T00:20:00Z', speedKmh: 10, longitude: 113.3, latitude: 23.3 }
],
sources: [{ protocol: 'JT808', pointCount: 3, startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z' }],
segments: [{ index: 0, type: 'moving', title: '行驶', startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z', durationSeconds: 1200, distanceKm: 8, pointCount: 3, startIndex: 0, endIndex: 2, sampledStartIndex: 0, sampledEndIndex: 2 }],
stops: [{ index: 0, startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:05:00Z', durationSeconds: 300, pointCount: 1, longitude: 113.1, latitude: 23.1, sampledIndex: 0, evidence: 'GPS 推断' }],
summary: { startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z', distanceKm: 8, durationSeconds: 1200, averageSpeedKmh: 24, maximumSpeedKmh: 35, pointCount: 3, movingSeconds: 900, stoppedSeconds: 300, stopCount: 1, segmentCount: 1 },
coverage: { requestedStart: '', requestedEnd: '', actualStart: '', actualEnd: '', totalPoints: 3, fetchedPoints: 3, processedPoints: 3, returnedPoints: 3, complete: true, limitReasons: [], evidence: '时间窗完整' },
quality: { status: 'good', selectedProtocol: 'JT808', rawPoints: 3, validPoints: 3, alternateSourcePoints: 0, invalidCoordinatePoints: 0, duplicatePoints: 0, driftPoints: 0, sourceSwitches: 0, largeGapCount: 0, maximumGapSeconds: 0, evidence: '轨迹质量正常' }
} as unknown as TrackPlaybackResponse;
beforeEach(() => {
Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) });
mocks.trackPlayback.mockResolvedValue(track);
mocks.reverseGeocode.mockResolvedValue({ formattedAddress: '广东省广州市测试道路' });
mocks.vehicles.mockResolvedValue({ items: [], total: 0, limit: 10, offset: 0 });
});
afterEach(() => { cleanup(); Object.values(mocks).forEach((mock) => mock.mockReset()); });
test('renders a map-first replay workspace and connects stop, event, and panel interactions', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '0');
expect(screen.getByText('停留 00:05:00 · 1 个点')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '暂停轨迹播放' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /事件点/ }));
fireEvent.click(screen.getByRole('button', { name: /急加速/ }));
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '1');
fireEvent.click(screen.getByRole('button', { name: '收起查询面板' }));
expect(screen.getByRole('button', { name: /查询与明细/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /查询与明细/ }));
expect(screen.getByRole('button', { name: '收起查询面板' })).toBeInTheDocument();
await waitFor(() => expect(mocks.trackPlayback).toHaveBeenCalledTimes(1));
expect(mocks.trackPlayback.mock.calls[0][0].get('maxPoints')).toBe('1600');
});

View File

@@ -1,17 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import {
IconBox, IconChevronLeft, IconChevronRight, IconDownload, IconPause, IconPlay, IconSearch
IconBox, IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed,
IconEyeOpened, IconList, IconMapPin, IconPause, IconPlay, IconRefresh, IconSearch
} from '@douyinfe/semi-icons';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { TrackPlaybackEvent, TrackPlaybackResponse } from '../../api/types';
import type { TrackPlaybackEvent, TrackPlaybackResponse, VehicleRow } from '../../api/types';
import { downloadTrackCsv, formatDuration, sampledEventIndex } from '../domain/track';
import { TrackMap } from '../map/TrackMap';
import { InlineError } from '../shared/AsyncState';
const speedOptions = [1, 2, 4] as const;
const visibleSegmentLimit = 120;
const speedOptions = [0.5, 1, 2, 4] as const;
type PlaybackSpeed = (typeof speedOptions)[number];
type PanelTab = 'stops' | 'events' | 'overview';
type Draft = { keyword: string; dateFrom: string; dateTo: string; protocol: string };
function number(value: number, digits = 1) {
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: digits }).format(Number.isFinite(value) ? value : 0);
@@ -33,8 +36,14 @@ function time(value?: string) {
if (!value) return '—';
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(parsed);
const clock = value.split(' ').pop();
return clock?.slice(0, 8) || '—';
return value.split(' ').pop()?.slice(0, 8) || '—';
}
function dateTime(value?: string) {
if (!value) return '—';
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(parsed);
return value.replace('T', ' ').slice(5, 19);
}
function localDateTime(value: Date) {
@@ -42,11 +51,17 @@ function localDateTime(value: Date) {
return local.toISOString().slice(0, 16);
}
function defaultTrackWindow() {
const now = new Date();
const start = new Date(now);
function trackWindow(daysBack = 0, fullDay = false) {
const end = new Date();
end.setDate(end.getDate() - daysBack);
if (fullDay) end.setHours(23, 59, 59, 999);
const start = new Date(end);
start.setHours(0, 0, 0, 0);
return { dateFrom: localDateTime(start), dateTo: localDateTime(now) };
return { dateFrom: localDateTime(start), dateTo: localDateTime(end) };
}
function defaultTrackWindow() {
return trackWindow();
}
function eventTone(type: string) {
@@ -56,60 +71,122 @@ function eventTone(type: string) {
return 'info';
}
function EmptyTrack({ queried }: { queried: boolean }) {
return <div className="v2-track-empty"><IconSearch size="extra-large" /><strong>{queried ? '当前条件没有轨迹点' : '选择车辆开始查询轨迹'}</strong><p>{queried ? '请扩大时间范围或切换数据来源。' : '支持车牌、VIN 或终端标识,结果不会回退到本地样例。'}</p></div>;
}
function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange: (value: string) => void; onSelect: (vehicle: VehicleRow) => void }) {
const [open, setOpen] = useState(false);
const [debounced, setDebounced] = useState(value.trim());
useEffect(() => { const timer = window.setTimeout(() => setDebounced(value.trim()), 220); return () => window.clearTimeout(timer); }, [value]);
const params = useMemo(() => {
const next = new URLSearchParams({ limit: '10', offset: '0' });
if (debounced) next.set('keyword', debounced);
return next;
}, [debounced]);
const candidates = useQuery({ queryKey: ['track-vehicle-options', params.toString()], queryFn: () => api.vehicles(params), enabled: open, staleTime: 60_000 });
function CoverageStrip({ track }: { track: TrackPlaybackResponse }) {
return <div className={`v2-track-coverage ${track.coverage.complete ? 'is-complete' : 'is-limited'}`}>
<strong>{track.coverage.complete ? '时间窗完整' : '仅展示最新切片'}</strong>
<span>{track.coverage.evidence}</span>
<em>{track.coverage.processedPoints} {track.coverage.returnedPoints} </em>
return <div className={`v2-track-vehicle-picker${open ? ' is-open' : ''}`}>
<IconSearch />
<input
aria-label="搜索轨迹车辆" autoComplete="off" placeholder="输入车牌 / VIN / 终端标识"
value={value} onFocus={() => setOpen(true)} onBlur={() => window.setTimeout(() => setOpen(false), 140)}
onChange={(event) => { onChange(event.target.value); setOpen(true); }}
/>
{value ? <button type="button" aria-label="清空车辆" onMouseDown={(event) => event.preventDefault()} onClick={() => onChange('')}><IconClose /></button> : null}
{open ? <div className="v2-track-vehicle-options" role="listbox">
<header><span></span><em> VIN</em></header>
{candidates.isFetching ? <p><span className="v2-spinner" /></p> : null}
{!candidates.isFetching && (candidates.data?.items ?? []).map((vehicle) => <button
type="button" role="option" aria-selected={false} key={vehicle.vin}
onMouseDown={(event) => event.preventDefault()} onClick={() => { onSelect(vehicle); setOpen(false); }}
><strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span><em></em></button>)}
{!candidates.isFetching && !(candidates.data?.items.length) ? <p></p> : null}
</div> : null}
</div>;
}
function SegmentTimeline({ track, onSelectIndex }: { track: TrackPlaybackResponse; onSelectIndex: (index: number) => void }) {
const segments = track.segments.slice(0, visibleSegmentLimit);
return <div className="v2-track-timeline">
<header><strong></strong><span>{track.segments.length} · {track.stops.length} </span></header>
<div>{segments.map((segment) => <button
aria-label={`${segment.title} ${time(segment.startTime)}${time(segment.endTime)}`}
className={`is-${segment.type}`}
key={`${segment.index}-${segment.startTime}`}
onClick={() => onSelectIndex(segment.sampledStartIndex)}
title={`${segment.title} · ${formatDuration(segment.durationSeconds)} · ${number(segment.distanceKm)} km`}
type="button"
><i /><span>{segment.title}</span></button>)}</div>
{track.segments.length > visibleSegmentLimit ? <em> {visibleSegmentLimit} </em> : null}
</div>;
}
function TripInspector({ track, onEvent }: { track: TrackPlaybackResponse; onEvent: (event: TrackPlaybackEvent) => void }) {
return <aside className="v2-track-inspector">
<section><header><strong></strong><span>{track.sampled ? '地图已抽稀' : '完整点集'}</span></header><div className="v2-track-vehicle"><span><IconBox /></span><div><strong>{track.plate || track.vin}</strong><small>VIN {track.vin}</small></div></div></section>
<section><header><strong></strong></header><dl className="v2-track-summary">
<div><dt></dt><dd>{track.summary.startTime || '—'}</dd></div><div><dt></dt><dd>{track.summary.endTime || '—'}</dd></div><div><dt></dt><dd>{number(track.summary.distanceKm)} km</dd></div><div><dt></dt><dd>{formatDuration(track.summary.durationSeconds)}</dd></div><div><dt> / </dt><dd>{formatDuration(track.summary.movingSeconds)} / {formatDuration(track.summary.stoppedSeconds)}</dd></div><div><dt> / </dt><dd>{track.summary.stopCount} / {track.summary.segmentCount}</dd></div><div><dt></dt><dd>{number(track.summary.averageSpeedKmh)} km/h</dd></div><div><dt></dt><dd>{number(track.summary.maximumSpeedKmh)} km/h</dd></div>
function OverviewPanel({ track }: { track: TrackPlaybackResponse }) {
return <div className="v2-track-overview-panel">
<section><header><strong></strong><span>{track.sampled ? '地图已抽稀' : '完整点集'}</span></header><dl>
<div><dt></dt><dd>{dateTime(track.summary.startTime)}</dd></div>
<div><dt></dt><dd>{dateTime(track.summary.endTime)}</dd></div>
<div><dt></dt><dd>{number(track.summary.distanceKm)} km</dd></div>
<div><dt> / </dt><dd>{formatDuration(track.summary.movingSeconds)} / {formatDuration(track.summary.stoppedSeconds)}</dd></div>
<div><dt> / </dt><dd>{number(track.summary.averageSpeedKmh)} / {number(track.summary.maximumSpeedKmh)} km/h</dd></div>
<div><dt> / </dt><dd>{track.summary.stopCount} / {track.summary.segmentCount}</dd></div>
</dl></section>
<section className="v2-track-sources"><header><strong></strong><b>{track.coverage.totalPoints} </b></header><div>{track.sources.map((source) => <span key={source.protocol}><strong>{source.protocol}</strong><small>{source.pointCount} · {time(source.startTime)}{time(source.endTime)}</small></span>)}</div>{!track.coverage.complete ? <p>{track.coverage.evidence} 7 </p> : null}</section>
<section className={`v2-track-quality is-${track.quality.status}`}><header><strong></strong><span>{track.quality.status === 'good' ? '通过' : '需关注'}</span></header><dl className="v2-track-summary"><div><dt> / </dt><dd>{track.quality.selectedProtocol || 'UNKNOWN'} / {track.quality.alternateSourcePoints}</dd></div><div><dt> / </dt><dd>{track.quality.validPoints} / {track.quality.rawPoints}</dd></div><div><dt> / / </dt><dd>{track.quality.invalidCoordinatePoints} / {track.quality.duplicatePoints} / {track.quality.driftPoints}</dd></div><div><dt> / </dt><dd>{track.quality.largeGapCount} / {formatDuration(track.quality.maximumGapSeconds)}</dd></div></dl><p>{track.quality.evidence}</p></section>
<section className="v2-track-events"><header><strong></strong><span>{track.events.length} </span></header><div>{track.events.map((event, index) => <button key={`${event.type}-${event.index}-${event.time}`} onClick={() => onEvent(event)} type="button"><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{time(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></button>)}</div></section>
<section><header><strong></strong><span>{track.coverage.totalPoints.toLocaleString('zh-CN')} </span></header><div className="v2-track-source-list">{track.sources.map((source) => <article key={source.protocol}><strong>{source.protocol}</strong><span>{source.pointCount.toLocaleString('zh-CN')} </span><small>{time(source.startTime)}{time(source.endTime)}</small></article>)}</div></section>
<section className={`v2-track-quality-card is-${track.quality.status}`}><header><strong></strong><span>{track.quality.status === 'good' ? '通过' : '需关注'}</span></header><p>{track.quality.evidence}</p><small> {track.quality.invalidCoordinatePoints} · {track.quality.duplicatePoints} · {track.quality.driftPoints} · {track.quality.largeGapCount}</small></section>
</div>;
}
function TrackRail({ draft, track, activeIndex, tab, onDraft, onSubmit, onTab, onSelectIndex, onCollapse }: {
draft: Draft;
track?: TrackPlaybackResponse;
activeIndex: number;
tab: PanelTab;
onDraft: (draft: Draft) => void;
onSubmit: (event: FormEvent) => void;
onTab: (tab: PanelTab) => void;
onSelectIndex: (index: number) => void;
onCollapse: () => void;
}) {
const choosePreset = (daysBack: number) => onDraft({ ...draft, ...trackWindow(daysBack, daysBack > 0) });
return <aside className="v2-track-rail">
<form className="v2-track-query" onSubmit={onSubmit}>
<header><div><strong></strong><span> 7 </span></div><button type="button" aria-label="收起查询面板" onClick={onCollapse}><IconChevronLeft /></button></header>
<label><span></span><VehiclePicker value={draft.keyword} onChange={(keyword) => onDraft({ ...draft, keyword })} onSelect={(vehicle) => onDraft({ ...draft, keyword: vehicle.plate || vehicle.vin })} /></label>
<div className="v2-track-presets"><button type="button" onClick={() => choosePreset(0)}></button><button type="button" onClick={() => choosePreset(1)}></button><button type="button" onClick={() => { const end = new Date(); const start = new Date(end); start.setDate(start.getDate() - 2); start.setHours(0, 0, 0, 0); onDraft({ ...draft, dateFrom: localDateTime(start), dateTo: localDateTime(end) }); }}> 3 </button></div>
<div className="v2-track-date-grid"><label><span></span><input type="datetime-local" value={draft.dateFrom} onChange={(event) => onDraft({ ...draft, dateFrom: event.target.value })} /></label><label><span></span><input type="datetime-local" value={draft.dateTo} onChange={(event) => onDraft({ ...draft, dateTo: event.target.value })} /></label></div>
<label><span></span><select value={draft.protocol} onChange={(event) => onDraft({ ...draft, protocol: event.target.value })}><option value=""></option><option value="GB32960">GB32960 · </option><option value="JT808">JT808 · GPS </option><option value="YUTONG_MQTT">YUTONG · </option></select></label>
<button className="v2-track-query-button" type="submit" disabled={!draft.keyword.trim()}><IconSearch /></button>
</form>
<div className="v2-track-rail-result">
{track ? <div className="v2-track-rail-vehicle"><span><IconBox /></span><div><strong>{track.plate || track.vin}</strong><small>{track.vin}</small></div><em>{number(track.summary.distanceKm)} km</em></div> : null}
<nav aria-label="轨迹明细分类"><button type="button" className={tab === 'stops' ? 'is-active' : ''} onClick={() => onTab('stops')}> <b>{track?.stops.length ?? 0}</b></button><button type="button" className={tab === 'events' ? 'is-active' : ''} onClick={() => onTab('events')}> <b>{track?.events.length ?? 0}</b></button><button type="button" className={tab === 'overview' ? 'is-active' : ''} onClick={() => onTab('overview')}></button></nav>
<div className="v2-track-rail-scroll">
{!track ? <div className="v2-track-rail-empty"><IconMapPin /><strong></strong><p></p></div> : null}
{track && tab === 'stops' ? <div className="v2-track-stop-list">{track.stops.map((stop, index) => <button type="button" className={Math.abs(stop.sampledIndex - activeIndex) < 2 ? 'is-active' : ''} key={`${stop.startTime}-${index}`} onClick={() => onSelectIndex(stop.sampledIndex)}><i>{index + 1}</i><span><strong>{dateTime(stop.startTime)}</strong><small> {formatDuration(stop.durationSeconds)} · {stop.pointCount} </small></span><em>{time(stop.endTime)}</em></button>)}{!track.stops.length ? <p className="v2-track-list-empty"> 3 </p> : null}</div> : null}
{track && tab === 'events' ? <div className="v2-track-event-list">{track.events.map((event, index) => { const sampled = sampledEventIndex(event, track.points.length, track.summary.pointCount); return <button type="button" className={sampled === activeIndex ? 'is-active' : ''} key={`${event.type}-${event.time}-${index}`} onClick={() => onSelectIndex(sampled)}><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{dateTime(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></button>; })}</div> : null}
{track && tab === 'overview' ? <OverviewPanel track={track} /> : null}
</div>
</div>
</aside>;
}
function SegmentRail({ track, onSelectIndex }: { track: TrackPlaybackResponse; onSelectIndex: (index: number) => void }) {
const segments = track.segments.slice(0, 160);
const total = Math.max(1, segments.reduce((sum, segment) => sum + Math.max(1, segment.durationSeconds), 0));
return <div className="v2-track-segment-rail" aria-label="轨迹活动分段">{segments.map((segment) => <button
aria-label={`${segment.title} ${time(segment.startTime)}${time(segment.endTime)}`}
className={`is-${segment.type}`} key={`${segment.index}-${segment.startTime}`}
onClick={() => onSelectIndex(segment.sampledStartIndex)} style={{ flexGrow: Math.max(1, segment.durationSeconds) / total }}
title={`${segment.title} · ${formatDuration(segment.durationSeconds)} · ${number(segment.distanceKm)} km`}
type="button"
/>)}</div>;
}
export default function TrackPage() {
const [searchParams, setSearchParams] = useSearchParams();
const initialKeyword = searchParams.get('vin') || searchParams.get('keyword') || '';
const [draft, setDraft] = useState(() => {
const fallback = defaultTrackWindow();
return { keyword: initialKeyword, dateFrom: searchParams.get('dateFrom') || fallback.dateFrom, dateTo: searchParams.get('dateTo') || fallback.dateTo, protocol: searchParams.get('protocol') || '' };
});
const [criteria, setCriteria] = useState(draft);
const fallback = useMemo(defaultTrackWindow, []);
const initialDraft = useMemo<Draft>(() => ({
keyword: searchParams.get('vin') || searchParams.get('keyword') || '',
dateFrom: searchParams.get('dateFrom') || fallback.dateFrom,
dateTo: searchParams.get('dateTo') || fallback.dateTo,
protocol: searchParams.get('protocol') || ''
}), []);
const [draft, setDraft] = useState(initialDraft);
const [criteria, setCriteria] = useState(initialDraft);
const [activeIndex, setActiveIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const [playbackSpeed, setPlaybackSpeed] = useState<(typeof speedOptions)[number]>(1);
const [playbackSpeed, setPlaybackSpeed] = useState<PlaybackSpeed>(1);
const [follow, setFollow] = useState(true);
const [showStops, setShowStops] = useState(true);
const [panelTab, setPanelTab] = useState<PanelTab>('stops');
const [railCollapsed, setRailCollapsed] = useState(false);
const animationRef = useRef<number>();
const lastFrameRef = useRef(0);
const params = useMemo(() => {
const next = new URLSearchParams({ keyword: criteria.keyword, maxPoints: '1200' });
const next = new URLSearchParams({ keyword: criteria.keyword, maxPoints: '1600' });
if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom);
if (criteria.dateTo) next.set('dateTo', criteria.dateTo);
if (criteria.protocol) next.set('protocol', criteria.protocol);
@@ -118,12 +195,13 @@ export default function TrackPage() {
const query = useQuery({ queryKey: ['track-playback', params.toString()], enabled: Boolean(criteria.keyword), queryFn: () => api.trackPlayback(params) });
const track = query.data;
const points = track?.points ?? [];
const current = points[Math.min(activeIndex, Math.max(points.length - 1, 0))];
const boundedIndex = Math.min(activeIndex, Math.max(points.length - 1, 0));
const current = points[boundedIndex];
const [addressPoint, setAddressPoint] = useState<{ longitude: number; latitude: number }>();
useEffect(() => {
if (playing || !current) return;
const timer = window.setTimeout(() => setAddressPoint({ longitude: current.longitude, latitude: current.latitude }), 350);
const timer = window.setTimeout(() => setAddressPoint({ longitude: current.longitude, latitude: current.latitude }), 360);
return () => window.clearTimeout(timer);
}, [current?.latitude, current?.longitude, playing]);
const addressQuery = useQuery({
@@ -132,14 +210,26 @@ export default function TrackPage() {
queryFn: () => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(6), latitude: addressPoint!.latitude.toFixed(6) }))
});
useEffect(() => { setActiveIndex(0); setPlaying(false); }, [track?.asOf]);
useEffect(() => { setActiveIndex(0); setPlaying(false); setFollow(true); if (track?.points.length) setRailCollapsed(window.matchMedia('(max-width: 700px)').matches); }, [track?.asOf]);
useEffect(() => {
if (!playing || points.length < 2) return;
const timer = window.setInterval(() => setActiveIndex((index) => {
if (index >= points.length - 1) { setPlaying(false); return points.length - 1; }
return index + 1;
}), Math.max(120, 800 / playbackSpeed));
return () => window.clearInterval(timer);
lastFrameRef.current = 0;
const interval = Math.max(55, 300 / playbackSpeed);
const tick = (timestamp: number) => {
if (!lastFrameRef.current) lastFrameRef.current = timestamp;
if (timestamp - lastFrameRef.current >= interval) {
const steps = Math.max(1, Math.floor((timestamp - lastFrameRef.current) / interval));
lastFrameRef.current = timestamp;
setActiveIndex((index) => {
const next = Math.min(points.length - 1, index + steps);
if (next >= points.length - 1) setPlaying(false);
return next;
});
}
animationRef.current = window.requestAnimationFrame(tick);
};
animationRef.current = window.requestAnimationFrame(tick);
return () => { if (animationRef.current) window.cancelAnimationFrame(animationRef.current); };
}, [playing, playbackSpeed, points.length]);
const submit = (event: FormEvent) => {
@@ -148,44 +238,64 @@ export default function TrackPage() {
if (!keyword) return;
const next = { ...draft, keyword };
setCriteria(next);
const url = new URLSearchParams({ vin: keyword });
const url = new URLSearchParams({ keyword });
if (next.dateFrom) url.set('dateFrom', next.dateFrom);
if (next.dateTo) url.set('dateTo', next.dateTo);
if (next.protocol) url.set('protocol', next.protocol);
setSearchParams(url, { replace: true });
};
const selectEvent = (event: TrackPlaybackEvent) => {
if (!track) return;
setActiveIndex(sampledEventIndex(event, points.length, track.summary.pointCount));
setPlaying(false);
const selectIndex = (index: number) => { setPlaying(false); setActiveIndex(Math.max(0, Math.min(points.length - 1, index))); };
const togglePlayback = () => {
if (points.length < 2) return;
if (!playing && boundedIndex >= points.length - 1) setActiveIndex(0);
setFollow(true);
setPlaying((value) => !value);
};
const progress = points.length > 1 ? boundedIndex / (points.length - 1) * 100 : 0;
return <div className="v2-track-page">
<form className="v2-track-toolbar" onSubmit={submit}>
<label className="v2-track-vehicle-input"><span></span><div><IconSearch /><input value={draft.keyword} onChange={(event) => setDraft((value) => ({ ...value, keyword: event.target.value }))} placeholder="车牌 / VIN / 终端标识" /></div></label>
<label><span></span><input type="datetime-local" value={draft.dateFrom} onChange={(event) => setDraft((value) => ({ ...value, dateFrom: event.target.value }))} /></label>
<label><span></span><input type="datetime-local" value={draft.dateTo} onChange={(event) => setDraft((value) => ({ ...value, dateTo: event.target.value }))} /></label>
<label><span></span><select value={draft.protocol} onChange={(event) => setDraft((value) => ({ ...value, protocol: event.target.value }))}><option value=""></option><option value="GB32960">GB32960</option><option value="JT808">JT808</option><option value="YUTONG_MQTT">YUTONG_MQTT</option></select></label>
<button className="v2-primary-button" type="submit" disabled={!draft.keyword.trim()}></button>
<button className="v2-secondary-button" type="button" disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}><IconDownload /></button>
</form>
return <div className={`v2-track-page${railCollapsed ? ' is-rail-collapsed' : ''}`}>
<TrackRail draft={draft} track={track} activeIndex={boundedIndex} tab={panelTab} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectIndex} onCollapse={() => setRailCollapsed(true)} />
<section className="v2-track-stage">
<TrackMap points={points} stops={track?.stops ?? []} activeIndex={boundedIndex} showStops={showStops} follow={follow} onSelectIndex={selectIndex} onFollowChange={setFollow} />
{query.isError ? <InlineError message={query.error instanceof Error ? query.error.message : '轨迹查询失败'} onRetry={() => query.refetch()} /> : null}
<div className="v2-track-workspace">
<div className="v2-track-main">
{track && points.length ? <CoverageStrip track={track} /> : <div className="v2-track-coverage is-empty"><span> 7 </span></div>}
<div className="v2-track-canvas-wrap">
{query.isFetching ? <div className="v2-track-loading"><span className="v2-spinner" /></div> : null}
{points.length ? <TrackMap points={points} events={track?.events ?? []} activeIndex={activeIndex} onSelectIndex={setActiveIndex} /> : <EmptyTrack queried={Boolean(track)} />}
</div>
{track && points.length ? <SegmentTimeline track={track} onSelectIndex={(index) => { setPlaying(false); setActiveIndex(index); }} /> : <div className="v2-track-timeline is-empty"><span></span></div>}
<div className="v2-track-playback">
<div className="v2-play-controls"><small></small><p><button type="button" onClick={() => setPlaying((value) => !value)} disabled={points.length < 2}>{playing ? <IconPause /> : <IconPlay />}</button><button type="button" onClick={() => { setPlaying(false); setActiveIndex((value) => Math.max(0, value - 1)); }} disabled={!activeIndex}><IconChevronLeft /></button><button type="button" onClick={() => { setPlaying(false); setActiveIndex((value) => Math.min(points.length - 1, value + 1)); }} disabled={!points.length || activeIndex >= points.length - 1}><IconChevronRight /></button><select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value) as 1 | 2 | 4)}>{speedOptions.map((speed) => <option value={speed} key={speed}>{speed}×</option>)}</select></p></div>
<div className="v2-play-progress"><header><strong>{time(current?.deviceTime)}</strong><span>{track?.summary.endTime ? time(track.summary.endTime) : '—'}</span></header><input aria-label="轨迹播放进度" type="range" min="0" max={Math.max(0, points.length - 1)} value={Math.min(activeIndex, Math.max(0, points.length - 1))} onChange={(event) => { setPlaying(false); setActiveIndex(Number(event.target.value)); }} disabled={!points.length} /><footer> {points.length ? activeIndex + 1 : 0} / {points.length}</footer></div>
<dl className="v2-current-metrics"><div><dt> / </dt><dd>{number(current?.speedKmh ?? 0)}<em>km/h · {direction(current?.directionDeg)}</em></dd></div><div><dt>SOC / </dt><dd>{current?.socAvailable ? `${number(current.socPercent)}%` : '—'}<em> · {alarm(current?.alarmFlag)}</em></dd></div><div><dt> / </dt><dd>{number(current?.totalMileageKm ?? 0)}<em>km · {current?.protocol || '—'}</em></dd></div><div><dt></dt><dd title={addressQuery.data?.formattedAddress}>{playing ? '播放中暂停解析' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || (current ? `${current.longitude.toFixed(6)}, ${current.latitude.toFixed(6)}` : '—')}</dd></div></dl>
</div>
<div className="v2-track-stage-tools">
{railCollapsed ? <button type="button" aria-label="展开查询与明细" onClick={() => setRailCollapsed(false)}><IconList /><span></span></button> : null}
<button type="button" aria-label={follow ? '关闭车辆跟随' : '开启车辆跟随'} className={follow ? 'is-active' : ''} disabled={!points.length} onClick={() => setFollow((value) => !value)}><IconMapPin /><span>{follow ? '跟随车辆' : '自由浏览'}</span></button>
<button type="button" aria-label={showStops ? '隐藏停留点' : '显示停留点'} className={showStops ? 'is-active' : ''} disabled={!track?.stops.length} onClick={() => setShowStops((value) => !value)}>{showStops ? <IconEyeOpened /> : <IconEyeClosed />}<span></span></button>
<button type="button" aria-label="导出轨迹 CSV" disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}><IconDownload /><span></span></button>
</div>
{track && points.length ? <TripInspector track={track} onEvent={selectEvent} /> : <aside className="v2-track-inspector is-empty"><strong></strong><p></p></aside>}
</div>
{track?.points.length ? <>
<div className={`v2-track-coverage-float${track.coverage.complete ? '' : ' is-warning'}`}><i />
<span><strong>{track.coverage.complete ? '时间窗完整' : '仅展示最新切片'}</strong>{track.coverage.evidence}</span>
<em>{track.coverage.processedPoints.toLocaleString('zh-CN')} {track.coverage.returnedPoints.toLocaleString('zh-CN')} </em>
</div>
<article className="v2-track-current-card">
<header><div><strong>{track.plate || track.vin}</strong><span>{dateTime(current?.deviceTime)}</span></div><b>{number(progress, 0)}%</b></header>
<div><span><small></small><strong>{number(current?.speedKmh ?? 0)}<em> km/h</em></strong></span><span><small></small><strong>{direction(current?.directionDeg)}</strong></span><span><small>SOC</small><strong>{current?.socAvailable ? `${number(current.socPercent)}%` : '—'}</strong></span></div>
<p title={addressQuery.data?.formattedAddress}>{playing ? '播放中,暂停地址解析' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || `${current?.longitude.toFixed(6)}, ${current?.latitude.toFixed(6)}`}</p>
</article>
</> : <div className="v2-track-empty-state"><IconMapPin /><strong></strong><p></p><button type="button" onClick={() => setRailCollapsed(false)}><IconSearch /></button></div>}
{query.isFetching ? <div className="v2-track-loading"><span className="v2-spinner" /></div> : null}
{query.isError ? <div className="v2-track-error"><InlineError message={query.error instanceof Error ? query.error.message : '轨迹查询失败'} onRetry={() => query.refetch()} /></div> : null}
<footer className="v2-track-playback-dock">
<div className="v2-track-dock-summary"><strong>{track ? `${number(track.summary.distanceKm)} km` : '等待查询'}</strong><span>{track ? `${formatDuration(track.summary.durationSeconds)} · ${track.summary.stopCount} 次停留` : '查询后可播放完整轨迹'}</span></div>
<div className="v2-track-dock-progress">
{track ? <SegmentRail track={track} onSelectIndex={selectIndex} /> : <div className="v2-track-segment-placeholder" />}
<input aria-label="轨迹播放进度" type="range" min="0" max={Math.max(0, points.length - 1)} value={boundedIndex} onChange={(event) => selectIndex(Number(event.target.value))} disabled={!points.length} style={{ '--track-progress': `${progress}%` } as React.CSSProperties} />
<div><time>{time(current?.deviceTime)}</time><span> {points.length ? boundedIndex + 1 : 0} / {points.length}</span><time>{time(track?.summary.endTime)}</time></div>
</div>
<div className="v2-track-dock-controls">
<button type="button" aria-label="上一个轨迹点" onClick={() => selectIndex(boundedIndex - 1)} disabled={!boundedIndex}><IconChevronLeft /></button>
<button type="button" className="is-primary" aria-label={playing ? '暂停轨迹播放' : '开始轨迹播放'} onClick={togglePlayback} disabled={points.length < 2}>{playing ? <IconPause /> : <IconPlay />}</button>
<button type="button" aria-label="下一个轨迹点" onClick={() => selectIndex(boundedIndex + 1)} disabled={!points.length || boundedIndex >= points.length - 1}><IconChevronRight /></button>
<label><span></span><select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value) as PlaybackSpeed)}>{speedOptions.map((speed) => <option value={speed} key={speed}>{speed}×</option>)}</select></label>
<button type="button" aria-label="回到起点" onClick={() => selectIndex(0)} disabled={!boundedIndex}><IconRefresh /></button>
</div>
<div className="v2-track-dock-metrics"><span><small></small><strong>{number(current?.totalMileageKm ?? 0)} km</strong></span><span><small></small><strong>{current?.protocol || '—'}</strong></span><span><small></small><strong>{alarm(current?.alarmFlag)}</strong></span></div>
</footer>
</section>
</div>;
}

View File

@@ -55,14 +55,20 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-sidebar.is-collapsed .v2-collapse { justify-content: center; padding: 0; transform: rotate(180deg); }
.v2-monitor-page { display: flex; height: 100%; min-height: 0; overflow: hidden; flex-direction: column; gap: clamp(10px, 1vh, 14px); padding: clamp(10px, 1vw, 18px); }
.v2-filterbar { display: grid; grid-template-columns: minmax(240px, 1.4fr) minmax(130px, .55fr) minmax(130px, .55fr) auto auto; gap: 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 10px 12px; box-shadow: var(--v2-shadow); }
.v2-filterbar { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(120px, 150px) minmax(120px, 150px) auto auto auto auto; gap: 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 10px 12px; box-shadow: var(--v2-shadow); }
.v2-search-field { display: flex; height: 36px; align-items: center; gap: 8px; border: 1px solid #dce4ef; border-radius: 7px; padding: 0 10px; color: #8a98aa; }
.v2-search-field:focus-within { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
.v2-search-field input { min-width: 0; flex: 1; border: 0; outline: 0; color: var(--v2-text); font-size: 12px; }
.v2-search-field.is-batch { border-color: #9fc0f7; background: #fbfdff; }
.v2-search-batch-count { flex: 0 0 auto; border-radius: 999px; background: var(--v2-blue-soft); padding: 3px 7px; color: var(--v2-blue); font-size: 10px; font-weight: 700; line-height: 1; white-space: nowrap; }
.v2-filterbar select { height: 36px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 30px 0 11px; color: #4d5c70; outline: 0; font-size: 12px; }
.v2-primary-button, .v2-secondary-button { display: flex; height: 36px; align-items: center; justify-content: center; gap: 7px; border-radius: 7px; padding: 0 14px; cursor: pointer; font-size: 12px; font-weight: 700; }
.v2-primary-button { border: 1px solid var(--v2-blue); background: var(--v2-blue); color: #fff; box-shadow: 0 5px 12px rgba(18,104,243,.18); }
.v2-secondary-button { border: 1px solid #dce4ef; background: #fff; color: #59677c; }
.v2-monitor-mode { display: flex; height: 36px; overflow: hidden; border: 1px solid #dce4ef; border-radius: 7px; background: #f7f9fc; padding: 2px; }
.v2-monitor-mode button, .v2-monitor-mobile-entry { display: flex; align-items: center; justify-content: center; gap: 5px; border: 0; border-radius: 5px; background: transparent; padding: 0 10px; color: #65758a; font-size: 12px; cursor: pointer; white-space: nowrap; }
.v2-monitor-mode button.is-active { background: #fff; color: var(--v2-blue); box-shadow: 0 1px 4px rgba(21,32,51,.12); font-weight: 700; }
.v2-monitor-mobile-entry { height: 36px; border: 1px solid #dce4ef; background: #fff; }
.v2-kpis { display: grid; grid-template-columns: repeat(7, minmax(90px, 1fr)); border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-kpi { position: relative; min-width: 0; padding: 10px 14px; }
@@ -100,6 +106,46 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-vehicle-rail > footer { display: flex; height: 34px; align-items: center; justify-content: center; border-top: 1px solid var(--v2-border); }
.v2-list-loading { display: flex; min-height: 100px; align-items: center; justify-content: center; gap: 8px; color: var(--v2-muted); font-size: 11px; }
.v2-monitor-table-panel { display: flex; min-height: 0; flex: 1; flex-direction: column; overflow: hidden; border: 1px solid #dce4ee; border-radius: 9px; background: #fff; box-shadow: 0 4px 16px rgba(21,32,51,.04); }
.v2-monitor-table-panel > header { display: flex; min-height: 54px; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--v2-border); padding: 8px 14px; }
.v2-monitor-table-panel > header div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.v2-monitor-table-panel > header strong { font-size: 14px; }
.v2-monitor-table-panel > header span, .v2-monitor-table-panel > header > b { color: var(--v2-muted); font-size: 11px; font-weight: 500; }
.v2-monitor-table-scroll { position: relative; min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
.v2-monitor-table-scroll table { width: 100%; min-width: 720px; border-collapse: separate; border-spacing: 0; table-layout: fixed; }
.v2-monitor-table-scroll th { position: sticky; top: 0; z-index: 3; height: 42px; border-bottom: 1px solid #dce4ee; background: #f7f9fc; padding: 0 14px; color: #607086; font-size: 11px; font-weight: 650; text-align: left; }
.v2-monitor-table-scroll th:nth-child(1) { width: 180px; }.v2-monitor-table-scroll th:nth-child(2) { width: 92px; }.v2-monitor-table-scroll th:nth-child(3) { width: 128px; }.v2-monitor-table-scroll th:nth-child(4) { width: 164px; }.v2-monitor-table-scroll th:nth-child(5) { width: auto; }
.v2-monitor-table-scroll td { height: 62px; border-bottom: 1px solid #edf1f6; padding: 7px 14px; vertical-align: middle; }
.v2-monitor-table-scroll tbody tr { transition: background .14s ease; }
.v2-monitor-table-scroll tbody tr:hover { background: #f8fbff; }
.v2-monitor-table-scroll td > strong, .v2-monitor-table-scroll td > span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-monitor-table-vehicle > button { display: flex; max-width: 100%; flex-direction: column; align-items: flex-start; border: 0; background: transparent; padding: 0; cursor: pointer; text-align: left; }.v2-monitor-table-vehicle > button:hover strong { color: var(--v2-blue); }
.v2-monitor-table-vehicle strong, .v2-monitor-table-vehicle span { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-monitor-table-vehicle strong { color: #263448; font-size: 13px; transition: color .14s ease; }.v2-monitor-table-vehicle span { margin-top: 4px; color: #8996a8; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 9px; }
.v2-monitor-live-value { display: inline !important; color: #213044; font-size: 14px !important; font-variant-numeric: tabular-nums; }
.v2-monitor-live-unit { margin-left: 4px; color: #8b98aa; font-size: 9px; }
.v2-monitor-coordinate { color: #52637a; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 10px; font-variant-numeric: tabular-nums; line-height: 1.55; }
.v2-monitor-address-action { display: inline-flex; height: 28px; align-items: center; gap: 5px; border: 1px solid #d5e0ee; border-radius: 6px; background: #fff; padding: 0 9px; color: #3568a9; cursor: pointer; font-size: 10px; font-weight: 650; transition: border-color .14s ease, background .14s ease; }
.v2-monitor-address-action:hover { border-color: #9bb8dd; background: #f4f8fe; }.v2-monitor-address-action.is-error { border-color: #f0c9c9; color: #b74b4b; }
.v2-monitor-address-empty, .v2-monitor-address-loading { display: inline-flex; align-items: center; gap: 6px; color: #929dac; font-size: 10px; }
.v2-monitor-address-result { display: flex; min-width: 0; flex-direction: column; align-items: flex-start; gap: 4px; }
.v2-monitor-address-result > span { display: block; width: 100%; overflow: hidden; color: #47576c; font-size: 10px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
.v2-monitor-address-result > button { border: 0; background: transparent; padding: 0; color: #c27a13; cursor: pointer; font-size: 9px; }
.v2-monitor-source { min-width: 0; border-left: 2px solid #d6dee9; padding-left: 8px; }
.v2-monitor-source.is-online { border-color: #1fb573; }.v2-monitor-source.is-offline, .v2-monitor-source.is-pending, .v2-monitor-source.is-unknown { border-color: #f59e0b; }.v2-monitor-source.is-not-required { opacity: .62; }
.v2-monitor-source header, .v2-monitor-source header span, .v2-monitor-source > div { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.v2-monitor-source header span { justify-content: flex-start; font-size: 10px; font-weight: 700; }.v2-monitor-source header i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.v2-monitor-source.is-online header span { color: #16885a; }.v2-monitor-source.is-abnormal header span { color: #c87905; }.v2-monitor-source header time { color: #8996a8; font-size: 9px; white-space: nowrap; }
.v2-monitor-source > div { margin-top: 7px; justify-content: flex-start; }.v2-monitor-source > div b { font-size: 11px; }.v2-monitor-source > div span { color: #59677c; font-size: 10px; }
.v2-monitor-source > small { display: block; margin-top: 5px; overflow: hidden; color: #8996a8; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.v2-monitor-overall { display: inline-flex; border-radius: 10px; background: #edf2f7; padding: 3px 8px; color: #637186; font-size: 10px; }.v2-monitor-overall.is-healthy { background: #e7f8f0; color: #16885a; }.v2-monitor-overall.is-degraded, .v2-monitor-overall.is-incomplete { background: #fff4df; color: #a86400; }.v2-monitor-overall.is-offline { background: #f1f3f6; color: #68778b; }
.v2-monitor-table-loading { position: sticky; bottom: 0; display: flex; height: 34px; align-items: center; justify-content: center; gap: 7px; background: rgba(255,255,255,.92); color: #607086; font-size: 10px; }
.v2-monitor-mobile-cards { display: none; }
.v2-monitor-table-panel > footer { display: flex; min-height: 44px; align-items: center; justify-content: space-between; border-top: 1px solid var(--v2-border); padding: 6px 14px; color: #718096; font-size: 11px; }
.v2-monitor-table-panel > footer div { display: flex; gap: 7px; }.v2-monitor-table-panel > footer button, .v2-monitor-table-panel > footer select { height: 30px; border: 1px solid #dce4ef; border-radius: 6px; background: #fff; padding: 0 10px; color: #536277; font-size: 10px; cursor: pointer; }.v2-monitor-table-panel > footer button:disabled { cursor: not-allowed; opacity: .45; }
.v2-monitor-qr-backdrop { position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; background: rgba(15,23,42,.42); padding: 18px; backdrop-filter: blur(4px); }
.v2-monitor-qr-backdrop > section { position: relative; display: flex; width: min(360px, 100%); align-items: center; flex-direction: column; border-radius: 16px; background: #fff; padding: 26px; box-shadow: 0 24px 70px rgba(15,23,42,.26); text-align: center; }
.v2-monitor-qr-backdrop > section > button:first-child { position: absolute; top: 12px; right: 12px; border: 0; background: transparent; color: #6b7a8f; cursor: pointer; }.v2-monitor-qr-backdrop > section > svg { color: var(--v2-blue); font-size: 26px; }.v2-monitor-qr-backdrop h3 { margin: 9px 0 4px; font-size: 17px; }.v2-monitor-qr-backdrop p { margin: 0 0 13px; color: #718096; font-size: 11px; }.v2-monitor-qr-backdrop img { width: 220px; height: 220px; }.v2-monitor-qr-backdrop code { width: 100%; overflow: hidden; margin-top: 10px; border-radius: 6px; background: #f5f7fa; padding: 8px; color: #5f6e82; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }.v2-monitor-qr-backdrop > section > button:last-child { width: 100%; height: 38px; margin-top: 10px; border: 0; border-radius: 7px; background: var(--v2-blue); color: #fff; font-weight: 700; cursor: pointer; }
.v2-fleet-map { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: #e8f0f6; }
.v2-fleet-map-canvas { position: absolute; inset: 0; }
.v2-map-controls { position: absolute; top: 12px; right: 12px; z-index: 5; display: flex; align-items: stretch; gap: 8px; }
@@ -615,6 +661,68 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-access-threshold > footer { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid var(--v2-border); padding: 8px 11px; color: #7f8c9f; font-size: 6px; }
.v2-access-threshold > footer b { color: #536177; text-align: right; font-weight: 600; }
/* Vehicle-first access matrix. Text deliberately starts at 11px instead of the legacy 7-9px scale. */
.v2-access-page-v3 { gap: 12px; overflow: auto; padding: 16px 18px 20px; background: #f6f8fb; }
.v2-access-heading { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 20px; }
.v2-access-heading h2 { margin: 0; color: #26354b; font-size: 22px; line-height: 1.25; letter-spacing: -.03em; }
.v2-access-heading p { margin: 5px 0 0; color: #6f7d90; font-size: 13px; line-height: 1.5; }
.v2-access-heading > div:last-child { display: flex; align-items: center; gap: 12px; color: #7b889a; font-size: 12px; }
.v2-access-heading button, .v2-access-table-v3 > header button { display: inline-flex; height: 34px; align-items: center; gap: 6px; border: 1px solid #dbe3ed; border-radius: 7px; background: #fff; padding: 0 12px; color: #536176; cursor: pointer; font-size: 12px; font-weight: 700; }
.v2-access-heading button:hover, .v2-access-table-v3 > header button:hover { border-color: #9fc1f5; color: var(--v2-blue); }
.v2-access-filter-v3 { display: grid; flex: 0 0 auto; grid-template-columns: minmax(260px, 1.4fr) repeat(3, minmax(150px, .7fr)) auto auto; align-items: end; gap: 10px; border: 1px solid var(--v2-border); border-radius: 9px; background: #fff; padding: 13px 14px; box-shadow: var(--v2-shadow); }
.v2-access-filter-v3 label { display: flex; min-width: 0; flex-direction: column; gap: 6px; color: #647287; font-size: 12px; font-weight: 650; }
.v2-access-filter-v3 label > div { display: flex; height: 38px; align-items: center; gap: 8px; border: 1px solid #d9e2ed; border-radius: 7px; padding: 0 10px; color: #8997aa; }
.v2-access-filter-v3 input, .v2-access-filter-v3 select { min-width: 0; width: 100%; height: 38px; border: 1px solid #d9e2ed; border-radius: 7px; background: #fff; padding: 0 10px; color: #35445b; outline: 0; font-size: 13px; }
.v2-access-filter-v3 label > div input { height: auto; border: 0; padding: 0; }
.v2-access-filter-v3 label > div:focus-within, .v2-access-filter-v3 select:focus { border-color: #83aff4; box-shadow: 0 0 0 3px rgba(18,104,243,.09); }
.v2-access-filter-v3 > button { height: 38px; padding: 0 15px; font-size: 13px; }
.v2-access-kpis-v3 { display: grid; min-height: 56px; flex: 0 0 auto; grid-template-columns: repeat(4, minmax(140px, 1fr)); overflow: hidden; border: 1px solid var(--v2-border); border-radius: 9px; background: #fff; box-shadow: var(--v2-shadow); }
.v2-access-kpis-v3 button { position: relative; display: flex; min-width: 0; align-items: center; gap: 12px; border: 0; background: #fff; padding: 10px 18px; text-align: left; cursor: pointer; }
.v2-access-kpis-v3 button + button::before { position: absolute; inset: 10px auto 10px 0; width: 1px; background: #e6ebf2; content: ''; }
.v2-access-kpis-v3 button:hover { background: #f8fbff; }
.v2-access-kpis-v3 small { color: #748195; font-size: 12px; }
.v2-access-kpis-v3 strong { margin-left: auto; color: #29384e; font-size: 22px; line-height: 1; font-variant-numeric: tabular-nums; }
.v2-access-kpis-v3 em { color: #8995a6; font-size: 11px; font-style: normal; }
.v2-access-kpis-v3 .is-healthy strong, .v2-access-kpis-v3 .is-today strong { color: var(--v2-green); }
.v2-access-kpis-v3 .is-incomplete strong { color: #d47b12; }
.v2-access-kpis-v3 .is-degraded strong { color: #bd6423; }
.v2-access-kpis-v3 .is-attention strong { color: #d47b12; }
.v2-access-kpis-v3 .is-offline strong, .v2-access-kpis-v3 .is-never strong { color: #5f6f83; }
.v2-access-protocol-overview { display: grid; flex: 0 0 auto; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.v2-access-protocol-overview article { min-width: 0; border: 1px solid var(--v2-border); border-radius: 9px; background: #fff; padding: 13px 15px; box-shadow: var(--v2-shadow); }
.v2-access-protocol-overview header, .v2-access-protocol-overview div, .v2-access-protocol-overview footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.v2-access-protocol-overview header strong { color: #33445a; font-size: 14px; }.v2-access-protocol-overview header span { color: #8995a6; font-size: 11px; }
.v2-access-protocol-overview div { justify-content: flex-start; margin-top: 9px; }.v2-access-protocol-overview div b { color: #25364c; font-size: 24px; }.v2-access-protocol-overview div small { color: #708095; font-size: 12px; }.v2-access-protocol-overview div em { margin-left: auto; color: #c66a0a; font-size: 12px; font-style: normal; }
.v2-access-protocol-overview footer { margin-top: 10px; }.v2-access-protocol-overview footer > i { height: 5px; flex: 1; overflow: hidden; border-radius: 5px; background: #edf1f6; }.v2-access-protocol-overview footer > i span { display: block; height: 100%; border-radius: inherit; background: #3787ee; }.v2-access-protocol-overview footer > span { color: #728095; font-size: 11px; white-space: nowrap; }
.v2-access-workspace-v3 { display: grid; min-height: 540px; flex: 1 0 540px; grid-template-columns: minmax(0, 1fr); gap: 12px; }
.v2-access-workspace-v3.is-inspector-open { grid-template-columns: minmax(760px, 1fr) 390px; }
.v2-access-table-v3 { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--v2-border); border-radius: 9px; background: #fff; box-shadow: var(--v2-shadow); }
.v2-access-table-v3 > header { display: flex; min-height: 52px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--v2-border); padding: 0 13px 0 15px; }
.v2-access-table-title { display: flex; min-width: 0; align-items: baseline; gap: 10px; }.v2-access-table-title strong { color: #334258; font-size: 15px; white-space: nowrap; }.v2-access-table-title > span { overflow: hidden; color: #8692a3; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.v2-access-table-actions { display: flex; min-width: 0; align-items: center; gap: 9px; }
.v2-access-protocol-coverage { display: flex; min-width: 0; align-items: center; gap: 6px; }
.v2-access-protocol-coverage > span { display: inline-flex; align-items: center; gap: 5px; border: 1px solid #e2e8f0; border-radius: 6px; background: #f8fafc; padding: 5px 7px; white-space: nowrap; }
.v2-access-protocol-coverage b { color: #526178; font-size: 10px; }.v2-access-protocol-coverage em { color: #7e8b9d; font-size: 10px; font-style: normal; font-variant-numeric: tabular-nums; }
.v2-access-table-scroll-v3 { position: relative; min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
.v2-access-table-scroll-v3 table { width: 100%; min-width: 1030px; border-collapse: separate; border-spacing: 0; table-layout: fixed; color: #48576c; font-size: 13px; }
.v2-access-table-scroll-v3 th { position: sticky; z-index: 2; top: 0; height: 40px; border-bottom: 1px solid #dfe5ed; background: #f7f9fc; color: #647287; text-align: left; font-size: 12px; font-weight: 700; }
.v2-access-table-scroll-v3 th, .v2-access-table-scroll-v3 td { padding: 8px 11px; vertical-align: middle; }
.v2-access-table-scroll-v3 th:nth-child(1) { width: 165px; }.v2-access-table-scroll-v3 th:nth-child(2) { width: 140px; }.v2-access-table-scroll-v3 th:nth-child(3) { width: 115px; }.v2-access-table-scroll-v3 th:nth-child(4), .v2-access-table-scroll-v3 th:nth-child(5), .v2-access-table-scroll-v3 th:nth-child(6) { width: 160px; }.v2-access-table-scroll-v3 th:nth-child(7) { width: 120px; }
.v2-access-table-scroll-v3 tbody tr { cursor: pointer; transition: background .14s ease; }.v2-access-table-scroll-v3 tbody tr:hover, .v2-access-table-scroll-v3 tbody tr.is-selected { background: #f2f7ff; }.v2-access-table-scroll-v3 tbody td { height: 67px; border-bottom: 1px solid #edf1f5; }
.v2-access-table-scroll-v3 td > strong, .v2-access-table-scroll-v3 td > b { display: block; overflow: hidden; color: #314158; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }.v2-access-table-scroll-v3 td > span { display: block; overflow: hidden; margin-top: 5px; color: #8793a5; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.v2-access-protocol-cell { display: grid; min-width: 0; gap: 4px; }.v2-access-protocol-cell > span { display: inline-flex; width: fit-content; align-items: center; gap: 5px; color: #6c788b; font-size: 12px; }.v2-access-protocol-cell > span i { width: 7px; height: 7px; border-radius: 50%; background: #9aa6b5; }.v2-access-protocol-cell > strong { color: #3d4d63; font-size: 12px; font-weight: 650; }.v2-access-protocol-cell > small { overflow: hidden; color: #8b97a7; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.v2-access-protocol-cell.is-online > span { color: #16815f; }.v2-access-protocol-cell.is-online > span i { background: #19a974; box-shadow: 0 0 0 3px rgba(25,169,116,.11); }.v2-access-protocol-cell.is-offline > span { color: #b26512; }.v2-access-protocol-cell.is-offline > span i { background: #e6922e; }.v2-access-protocol-cell.is-missing > span { color: #c14b4b; }.v2-access-protocol-cell.is-missing > span i { border: 1px solid #dd7777; background: #fff; }
.v2-access-connection { display: grid; gap: 5px; }.v2-access-connection strong { width: fit-content; border-radius: 12px; background: #eef2f6; padding: 4px 8px; color: #607086; font-size: 11px; }.v2-access-connection span { color: #8793a5; font-size: 11px; }.v2-access-connection.is-healthy strong { background: #eaf8f2; color: #15815e; }.v2-access-connection.is-incomplete strong, .v2-access-connection.is-degraded strong { background: #fff4e5; color: #b5660c; }.v2-access-connection.is-not_connected strong { background: #fff0f0; color: #bc4d4d; }
.v2-access-table-v3 > footer { display: flex; min-height: 46px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid var(--v2-border); padding: 0 12px; color: #788699; font-size: 12px; }.v2-access-table-v3 > footer div { display: flex; align-items: center; gap: 7px; }.v2-access-table-v3 > footer button, .v2-access-table-v3 > footer select { height: 30px; border: 1px solid #dbe3ed; border-radius: 6px; background: #fff; padding: 0 9px; color: #5d6b80; font-size: 12px; }
.v2-access-inspector-v3 { min-width: 0; overflow: auto; border: 1px solid var(--v2-border); border-radius: 9px; background: #fff; box-shadow: var(--v2-shadow); }
.v2-access-inspector-v3 > header { position: sticky; z-index: 2; top: 0; display: flex; min-height: 58px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); background: #fff; padding: 0 14px; }.v2-access-inspector-v3 > header > div { display: grid; gap: 4px; }.v2-access-inspector-v3 > header strong { color: #2f3f55; font-size: 16px; }.v2-access-inspector-v3 > header span { color: #8290a3; font-size: 11px; }.v2-access-inspector-v3 > header button { display: grid; width: 30px; height: 30px; place-items: center; border: 0; border-radius: 6px; background: #f2f5f8; color: #6d7a8e; cursor: pointer; }
.v2-access-inspector-summary { display: grid; grid-template-columns: 1fr 1fr; border-bottom: 1px solid var(--v2-border); padding: 10px 14px; }.v2-access-inspector-summary > div { min-width: 0; padding: 7px 4px; }.v2-access-inspector-summary span { display: block; color: #8793a5; font-size: 11px; }.v2-access-inspector-summary strong { display: block; margin-top: 5px; color: #3f4e63; font-size: 12px; line-height: 1.45; overflow-wrap: anywhere; }.v2-access-inspector-summary .v2-access-connection { margin-top: 4px; }
.v2-access-protocol-details { display: grid; gap: 9px; padding: 12px; }.v2-access-protocol-detail { overflow: hidden; border: 1px solid #e1e7ef; border-radius: 8px; }.v2-access-protocol-detail > header { display: flex; height: 38px; align-items: center; justify-content: space-between; background: #f8fafc; padding: 0 10px; }.v2-access-protocol-detail > header strong { color: #3b4a60; font-size: 13px; }.v2-access-protocol-detail > header span { display: inline-flex; align-items: center; gap: 5px; color: #69778b; font-size: 11px; }.v2-access-protocol-detail > header i { width: 7px; height: 7px; border-radius: 50%; background: #9aa6b5; }.v2-access-protocol-detail.is-online > header i { background: var(--v2-green); }.v2-access-protocol-detail.is-offline > header i { background: var(--v2-orange); }.v2-access-protocol-detail.is-missing { border-color: #efd7d7; }.v2-access-protocol-detail.is-missing > header { background: #fff8f8; }.v2-access-protocol-detail.is-missing > header i { border: 1px solid #d96c6c; background: #fff; }
.v2-access-protocol-detail dl { display: grid; grid-template-columns: 1fr 1fr; margin: 0; padding: 7px 10px; }.v2-access-protocol-detail dl > div { min-width: 0; padding: 5px 3px; }.v2-access-protocol-detail dt { color: #8995a6; font-size: 11px; }.v2-access-protocol-detail dd { overflow: hidden; margin: 4px 0 0; color: #46556a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.v2-access-protocol-detail dd.is-danger { color: var(--v2-red); }.v2-access-protocol-detail p { margin: 0; border-top: 1px solid #edf1f5; padding: 7px 10px; color: #7c899b; font-size: 11px; line-height: 1.5; }
.v2-access-inspector-v3 > footer { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; border-top: 1px solid var(--v2-border); padding: 10px 14px; }.v2-access-inspector-v3 > footer span { color: #8591a2; font-size: 11px; line-height: 1.5; }.v2-access-inspector-v3 > footer a { flex: 0 0 auto; color: var(--v2-blue); font-size: 12px; text-decoration: none; }
.v2-access-identity-queue-v3, .v2-access-settings { flex: 0 0 auto; border: 1px solid var(--v2-border); border-radius: 8px; background: #fff; }.v2-access-identity-queue-v3 summary, .v2-access-settings summary { display: flex; min-height: 40px; align-items: center; gap: 10px; padding: 0 12px; color: #5c6b80; cursor: pointer; font-size: 11px; }.v2-access-identity-queue-v3 summary span { color: #8995a6; font-size: 10px; }.v2-access-identity-queue-v3 > div { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; border-top: 1px solid var(--v2-border); padding: 10px; }.v2-access-identity-queue-v3 article { display: grid; gap: 4px; border: 1px solid #e4e9f0; border-radius: 7px; padding: 9px; }.v2-access-identity-queue-v3 article b { font-size: 11px; }.v2-access-identity-queue-v3 article span, .v2-access-identity-queue-v3 article small { color: #7d8a9c; font-size: 9px; line-height: 1.5; }
.v2-access-settings fieldset { display: flex; flex-wrap: wrap; align-items: end; gap: 9px; margin: 0; border: 0; border-top: 1px solid var(--v2-border); padding: 11px; }.v2-access-settings label { display: grid; gap: 5px; color: #718095; font-size: 10px; }.v2-access-settings input { width: 110px; height: 32px; border: 1px solid #dbe3ed; border-radius: 6px; padding: 0 8px; font-size: 11px; }.v2-access-settings button { display: inline-flex; height: 32px; align-items: center; gap: 5px; border: 0; border-radius: 6px; background: var(--v2-blue); padding: 0 11px; color: #fff; font-size: 10px; }.v2-access-settings p { width: 100%; margin: 0; color: var(--v2-red); font-size: 10px; }
.v2-alert-page { display: flex; height: 100%; min-height: 0; flex-direction: column; overflow: hidden; background: #f5f7fa; padding: 0 12px 12px; }
.v2-alert-heading { display: flex; min-height: 48px; flex: 0 0 auto; align-items: center; justify-content: space-between; background: #fff; margin: 0 -12px; padding: 0 16px; }
.v2-alert-heading h2 { margin: 0; color: #26354b; font-size: 17px; letter-spacing: -.02em; }
@@ -811,65 +919,118 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-ops-kpis { grid-template-columns: repeat(3,1fr); }.v2-ops-grid { grid-template-columns: 1fr; }.v2-ops-sources > div { grid-template-columns: 1fr; }.v2-ops-sources article + article { border-top: 1px solid var(--v2-border); border-left: 0; }
}
.v2-stat-page { display: flex; min-height: 100%; flex-direction: column; gap: 12px; padding: 16px 18px 20px; }
.v2-stat-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.v2-stat-heading h2 { margin: 0; font-size: 20px; letter-spacing: -.03em; }
.v2-stat-heading p { margin: 5px 0 0; color: var(--v2-muted); font-size: 11px; }
.v2-stat-heading > button { display: flex; height: 34px; align-items: center; gap: 6px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 12px; color: #59677c; cursor: pointer; font-size: 11px; font-weight: 700; }
.v2-stat-filter { display: grid; grid-template-columns: minmax(250px, 1.3fr) minmax(130px, .65fr) minmax(130px, .65fr) minmax(130px, .65fr) auto; align-items: end; gap: 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 12px; box-shadow: var(--v2-shadow); }
.v2-stat-filter label { display: flex; min-width: 0; flex-direction: column; gap: 6px; color: #68768a; font-size: 10px; font-weight: 600; }
.v2-stat-filter input, .v2-stat-filter select { width: 100%; height: 36px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 10px; color: var(--v2-text); outline: 0; font-size: 12px; }
.v2-stat-search > div { display: flex; height: 36px; align-items: center; gap: 7px; border: 1px solid #dce4ef; border-radius: 7px; padding: 0 10px; color: #8a98aa; }
.v2-stat-search > div:focus-within, .v2-stat-filter input:focus, .v2-stat-filter select:focus { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
.v2-stat-search input { height: auto; border: 0; padding: 0; box-shadow: none !important; }
.v2-stat-ranges { display: flex; grid-column: 1 / -1; gap: 6px; }
.v2-stat-ranges button { height: 26px; border: 1px solid #dce4ef; border-radius: 13px; background: #fff; padding: 0 11px; color: #64748b; cursor: pointer; font-size: 9px; }
.v2-stat-ranges button:hover { border-color: #a9c8f8; color: var(--v2-blue); }
.v2-stat-kpis { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-stat-kpis article { position: relative; min-width: 0; padding: 14px 16px; }
.v2-stat-kpis article + article::before { position: absolute; inset: 14px auto 14px 0; width: 1px; background: var(--v2-border); content: ""; }
.v2-stat-kpis small, .v2-stat-kpis span { display: block; overflow: hidden; color: var(--v2-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.v2-stat-kpis strong { display: block; margin: 7px 0 5px; overflow: hidden; font-size: clamp(17px, 1.55vw, 24px); font-variant-numeric: tabular-nums; letter-spacing: -.03em; text-overflow: ellipsis; white-space: nowrap; }
.v2-stat-kpis .is-primary strong { color: var(--v2-blue); }
.v2-stat-grid-layout { display: grid; min-height: 490px; flex: 1; grid-template-columns: minmax(0, 1.65fr) minmax(320px, .85fr); gap: 12px; }
.v2-stat-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-stat-card > header { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--v2-border); padding: 8px 14px; }
.v2-stat-card > header div { display: flex; flex-direction: column; gap: 3px; }
.v2-stat-card > header strong { font-size: 12px; }
.v2-stat-card > header span, .v2-stat-card > header em { color: var(--v2-muted); font-size: 9px; font-style: normal; }
.v2-stat-chart-wrap { padding: 14px 12px 2px; }
.v2-stat-chart-wrap svg { display: block; width: 100%; max-height: 260px; overflow: visible; }
.v2-stat-grid line { stroke: #e9eef5; stroke-width: 1; vector-effect: non-scaling-stroke; }
.v2-stat-axis text { fill: #8290a3; font-size: 10px; }
.v2-stat-area { fill: url(#none); fill: rgba(18,104,243,.07); stroke: none; }
.v2-stat-line { fill: none; stroke: var(--v2-blue); stroke-linecap: round; stroke-linejoin: round; stroke-width: 2.2; vector-effect: non-scaling-stroke; }
.v2-stat-point { fill: #fff; stroke: var(--v2-blue); stroke-width: 2; vector-effect: non-scaling-stroke; }
.v2-stat-daily-list { display: grid; grid-template-columns: repeat(5, minmax(0,1fr)); margin: 0 14px 14px; border: 1px solid #edf1f6; border-radius: 8px; overflow: hidden; }
.v2-stat-daily-list > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; border-right: 1px solid #edf1f6; border-bottom: 1px solid #edf1f6; padding: 8px 9px; }
.v2-stat-daily-list time, .v2-stat-daily-list span { color: var(--v2-muted); font-size: 8px; }
.v2-stat-daily-list strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.v2-stat-ranking > div { max-height: 440px; overflow: auto; }
.v2-stat-ranking article { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 8px; border-bottom: 1px solid #eef2f7; padding: 10px 13px; }
.v2-stat-ranking article > b { display: grid; width: 22px; height: 22px; place-items: center; border-radius: 6px; background: #f2f5f9; color: #728096; font-size: 9px; }
.v2-stat-ranking article:nth-child(-n+3) > b { background: var(--v2-blue-soft); color: var(--v2-blue); }
.v2-stat-ranking article header, .v2-stat-ranking article footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.v2-stat-ranking article header a { overflow: hidden; color: var(--v2-text); font-size: 11px; font-weight: 700; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; }
.v2-stat-ranking article header strong { flex: 0 0 auto; color: var(--v2-blue); font-size: 10px; }
.v2-stat-ranking article > div > span { display: block; height: 5px; margin: 7px 0; overflow: hidden; border-radius: 3px; background: #eef2f7; }
.v2-stat-ranking article > div > span i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #4f93fa, #1268f3); }
.v2-stat-ranking article footer small, .v2-stat-ranking article footer em { overflow: hidden; color: var(--v2-muted); font-size: 8px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.v2-stat-ranking article footer small { max-width: 46%; }
.v2-stat-empty { display: grid; min-height: 180px; place-items: center; padding: 20px; color: var(--v2-muted); text-align: center; font-size: 11px; }
.v2-stat-evidence { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 16px; color: var(--v2-muted); font-size: 9px; }
.v2-mileage-page { display: flex; min-height: 100%; flex-direction: column; gap: 14px; padding: 22px 24px 20px; }
.v2-mileage-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.v2-mileage-heading h2 { margin: 0; color: #172033; font-size: 24px; font-weight: 720; letter-spacing: -.04em; }
.v2-mileage-heading p { margin: 6px 0 0; color: #748196; font-size: 12px; line-height: 1.5; }
.v2-mileage-heading > button { display: flex; height: 36px; align-items: center; gap: 7px; border: 1px solid #d8e0eb; border-radius: 8px; background: #fff; padding: 0 13px; color: #56657a; cursor: pointer; font-size: 12px; font-weight: 600; transition: border-color .16s ease, color .16s ease, background .16s ease; }
.v2-mileage-heading > button:hover { border-color: #aebed3; background: #f8fafc; color: #1f4e8c; }
.v2-mileage-query-panel { overflow: visible; border: 1px solid #dce3ec; border-radius: 11px; background: #fff; box-shadow: 0 1px 2px rgba(15,23,42,.025); }
.v2-mileage-filter { display: grid; grid-template-columns: minmax(320px,1.5fr) minmax(132px,.55fr) minmax(132px,.55fr) auto auto auto; align-items: start; gap: 12px; padding: 18px 20px 16px; }
.v2-mileage-filter > label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #4f5f75; font-size: 11px; font-weight: 650; }
.v2-mileage-filter > label > input { width: 100%; height: 40px; border: 1px solid #d7e0ea; border-radius: 7px; background: #fff; padding: 0 11px; color: #253248; outline: 0; font-family: inherit; font-size: 13px; transition: border-color .16s ease, box-shadow .16s ease; }
.v2-mileage-filter > label > input:focus, .v2-mileage-multiselect:focus-within { border-color: #6d9ff1; box-shadow: 0 0 0 3px rgba(37,99,235,.08); }
.v2-mileage-filter > .v2-primary-button { height: 40px; margin-top: 18px; border-radius: 7px; padding: 0 22px; box-shadow: 0 4px 10px rgba(37,99,235,.16); font-size: 12px; }
.v2-mileage-vehicle-field { position: relative; }
.v2-mileage-vehicle-field > small { color: #96a1b0; font-size: 9px; font-weight: 400; }
.v2-mileage-multiselect { position: relative; display: flex; min-height: 40px; align-items: flex-start; gap: 8px; border: 1px solid #d7e0ea; border-radius: 7px; background: #fff; padding: 6px 10px; color: #8a98aa; transition: border-color .16s ease, box-shadow .16s ease; }
.v2-mileage-multiselect > svg { flex: 0 0 auto; margin-top: 5px; }
.v2-mileage-selection { display: flex; min-width: 0; flex: 1; flex-wrap: wrap; gap: 4px; }
.v2-mileage-selection > input { width: 120px; min-width: 90px; height: 26px; flex: 1; border: 0; background: transparent; padding: 0 3px; color: var(--v2-text); outline: 0; font-family: inherit; font-size: 12px; }
.v2-mileage-chip { display: inline-flex; max-width: 150px; height: 26px; align-items: center; gap: 4px; border: 1px solid #c8daf6; border-radius: 5px; background: #eff5ff; padding: 0 6px 0 8px; color: #245da8; cursor: pointer; font-size: 10px; }
.v2-mileage-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-mileage-chip svg { flex: 0 0 auto; font-size: 9px; }
.v2-mileage-options { position: absolute; z-index: 40; top: calc(100% + 6px); right: 0; left: 0; overflow: hidden; border: 1px solid #d8e1ed; border-radius: 8px; background: #fff; box-shadow: 0 14px 34px rgba(35,53,78,.16); }
.v2-mileage-options > header { display: flex; height: 34px; align-items: center; justify-content: space-between; border-bottom: 1px solid #edf1f5; padding: 0 11px; color: #77859a; font-size: 9px; }
.v2-mileage-options > header em { font-style: normal; }
.v2-mileage-options > button { display: grid; width: 100%; min-height: 42px; grid-template-columns: minmax(90px,.55fr) minmax(150px,1fr) auto; align-items: center; gap: 10px; border: 0; border-bottom: 1px solid #f0f3f7; background: #fff; padding: 7px 11px; color: var(--v2-text); cursor: pointer; text-align: left; }
.v2-mileage-options > button:hover { background: #f6f9fd; }
.v2-mileage-options > button:disabled { color: #8491a3; cursor: default; }
.v2-mileage-options > button strong { font-size: 11px; }
.v2-mileage-options > button span { overflow: hidden; color: #7d899a; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9px; text-overflow: ellipsis; }
.v2-mileage-options > button em { color: var(--v2-blue); font-size: 9px; font-style: normal; }
.v2-mileage-options > p { margin: 0; padding: 16px; color: var(--v2-muted); text-align: center; font-size: 10px; }
.v2-mileage-source-strategy { position: relative; align-self: start; margin-top: 18px; }
.v2-mileage-source-trigger { display: flex; height: 40px; align-items: center; gap: 6px; border: 1px solid #d7e0ea; border-radius: 7px; background: #fff; padding: 0 11px; color: #56657a; cursor: pointer; font-size: 11px; font-weight: 600; white-space: nowrap; transition: border-color .16s ease, background .16s ease; }
.v2-mileage-source-trigger:hover, .v2-mileage-source-trigger[aria-expanded="true"] { border-color: #a8bad2; background: #f8fafc; }
.v2-mileage-source-trigger b { display: grid; min-width: 28px; height: 20px; place-items: center; border-radius: 10px; background: #eef4fd; color: #3167ad; font-size: 9px; }
.v2-mileage-source-popover { position: absolute; z-index: 45; top: calc(100% + 7px); right: 0; width: 430px; overflow: hidden; border: 1px solid #d8e1ed; border-radius: 10px; background: #fff; box-shadow: 0 18px 44px rgba(35,53,78,.18); }
.v2-mileage-source-popover > header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; border-bottom: 1px solid #edf1f5; padding: 0 14px 0 16px; }
.v2-mileage-source-popover > header div { display: flex; flex-direction: column; gap: 3px; }
.v2-mileage-source-popover > header strong { color: #263448; font-size: 13px; }
.v2-mileage-source-popover > header span, .v2-mileage-source-popover > footer span { color: #8491a3; font-size: 9px; }
.v2-mileage-source-popover > header button { display: grid; width: 30px; height: 30px; place-items: center; border: 0; border-radius: 6px; background: transparent; color: #7d899a; cursor: pointer; }
.v2-mileage-source-list article { display: grid; min-height: 62px; grid-template-columns: auto minmax(130px,1fr) 72px auto; align-items: center; gap: 11px; border-bottom: 1px solid #edf1f5; padding: 8px 13px 8px 16px; }
.v2-mileage-source-list article.is-disabled { background: #fafbfc; }
.v2-mileage-source-switch { position: relative; width: 32px; height: 18px; border: 0; border-radius: 9px; background: #c9d1dc; padding: 0; cursor: pointer; transition: background .16s ease; }
.v2-mileage-source-switch i { position: absolute; top: 3px; left: 3px; width: 12px; height: 12px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.2); transition: transform .16s ease; }
.v2-mileage-source-switch.is-on { background: var(--v2-blue); }
.v2-mileage-source-switch.is-on i { transform: translateX(14px); }
.v2-mileage-source-list article > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.v2-mileage-source-list article > div strong { color: #344257; font-size: 11px; }
.v2-mileage-source-list article > div small { display: flex; min-width: 0; align-items: center; gap: 7px; color: #8a96a7; font-size: 8px; }
.v2-mileage-source-list article > div small b { color: #55708f; font-size: 9px; font-weight: 650; }
.v2-mileage-source-list article > div code { overflow: hidden; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; text-overflow: ellipsis; white-space: nowrap; }
.v2-mileage-source-list article > em { color: #5f7190; font-size: 9px; font-style: normal; text-align: right; white-space: nowrap; }
.v2-mileage-source-list article.is-disabled > div, .v2-mileage-source-list article.is-disabled > em { opacity: .58; }
.v2-mileage-source-list article > p { display: flex; gap: 3px; margin: 0; }
.v2-mileage-source-list article > p button { display: grid; width: 26px; height: 26px; place-items: center; border: 1px solid #dce4ee; border-radius: 5px; background: #fff; color: #65758b; cursor: pointer; }
.v2-mileage-source-list article > p button:disabled { opacity: .32; cursor: default; }
.v2-mileage-source-popover > footer { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 10px; padding: 0 13px 0 16px; }
.v2-mileage-source-popover > footer button { height: 30px; border: 0; border-radius: 6px; background: var(--v2-blue); padding: 0 13px; color: #fff; cursor: pointer; font-size: 10px; font-weight: 650; }
.v2-mileage-ranges { display: flex; align-items: center; align-self: end; gap: 2px; margin-bottom: 1px; }
.v2-mileage-ranges > span { display: none; }
.v2-mileage-ranges button { height: 38px; border: 0; border-radius: 6px; background: transparent; padding: 0 9px; color: #64748b; cursor: pointer; font-size: 11px; font-weight: 550; white-space: nowrap; transition: color .16s ease, background .16s ease; }
.v2-mileage-ranges button:hover { background: #f1f5fb; color: var(--v2-blue); }
.v2-mileage-summary { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); border-top: 1px solid #e5eaf1; background: #fbfcfe; }
.v2-mileage-summary article { position: relative; min-width: 0; padding: 15px 22px 16px; }
.v2-mileage-summary article + article::before { position: absolute; inset: 15px auto 15px 0; width: 1px; background: #dfe6ef; content: ""; }
.v2-mileage-summary small, .v2-mileage-summary span { display: block; overflow: hidden; color: #7c899b; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.v2-mileage-summary strong { display: block; margin: 6px 0 3px; overflow: hidden; color: #172033; font-size: clamp(18px,1.45vw,24px); font-variant-numeric: tabular-nums; letter-spacing: -.035em; text-overflow: ellipsis; white-space: nowrap; }
.v2-mileage-summary .is-primary strong { color: var(--v2-blue); }
.v2-mileage-results { display: flex; min-height: 430px; flex: 1; flex-direction: column; overflow: hidden; border: 1px solid #dce3ec; border-radius: 11px; background: #fff; box-shadow: 0 1px 2px rgba(15,23,42,.025); }
.v2-mileage-results > header, .v2-mileage-results > footer { display: flex; min-height: 50px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 12px; padding: 0 18px; }
.v2-mileage-results > header { border-bottom: 1px solid #e4e9f0; }
.v2-mileage-results > header div { display: flex; align-items: baseline; gap: 10px; }
.v2-mileage-results > header strong { color: #1f2a3d; font-size: 13px; }
.v2-mileage-results > header span, .v2-mileage-results > header em, .v2-mileage-results > footer { color: #7c899b; font-size: 10px; font-style: normal; }
.v2-mileage-results > header .v2-mileage-result-actions { align-items: center; gap: 12px; }
.v2-mileage-result-actions > button { display: inline-flex; height: 32px; align-items: center; gap: 6px; border: 1px solid #cbd8e8; border-radius: 7px; background: #fff; padding: 0 11px; color: #315f9f; cursor: pointer; font-size: 10px; font-weight: 650; box-shadow: 0 1px 2px rgba(15,23,42,.04); transition: border-color .16s ease, background .16s ease, box-shadow .16s ease; }
.v2-mileage-result-actions > button:hover { border-color: #99b6dd; background: #f5f9ff; box-shadow: 0 2px 7px rgba(37,99,235,.08); }
.v2-mileage-result-actions > button:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }
.v2-mileage-table-wrap { min-height: 0; flex: 1; overflow: auto; }
.v2-mileage-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.v2-mileage-table th { position: sticky; z-index: 2; top: 0; height: 42px; border-bottom: 1px solid #dfe6ef; background: #f8fafc; padding: 0 14px; color: #59677b; font-size: 11px; font-weight: 650; text-align: left; white-space: nowrap; }
.v2-mileage-table td { height: 52px; border-bottom: 1px solid #e8edf3; padding: 0 14px; color: #46556a; white-space: nowrap; }
.v2-mileage-table tbody tr:hover { background: #f8fbff; }
.v2-mileage-table td strong { color: #263448; }
.v2-mileage-table code { color: #69778b; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 10px; }
.v2-mileage-table .is-number { text-align: right; font-variant-numeric: tabular-nums; }
.v2-mileage-table .is-date { min-width: 96px; text-align: center; }
.v2-mileage-table .is-sticky { position: sticky; z-index: 3; background: #fff; }
.v2-mileage-table th.is-sticky { z-index: 5; background: #f7f9fc; }
.v2-mileage-table .is-plate { left: 0; width: 120px; min-width: 120px; }
.v2-mileage-table .is-vin { left: 120px; width: 182px; min-width: 182px; border-right: 1px solid #d8e0ea; box-shadow: 5px 0 10px rgba(15,23,42,.025); }
.v2-mileage-table .is-total { position: sticky; z-index: 3; right: 0; min-width: 128px; border-left: 1px solid #d8e0ea; background: #fbfdff; box-shadow: -5px 0 10px rgba(15,23,42,.025); }
.v2-mileage-table th.is-total { z-index: 5; background: #f1f6fe; color: #315f9f; }
.v2-mileage-table .is-daily { color: #28496f; font-size: 11px; font-weight: 650; }
.v2-mileage-table .is-empty { color: #b2bcc9; text-align: center; }
.v2-mileage-table .is-period { color: #263448; font-weight: 700; }
.v2-mileage-source { display: inline-flex; height: 22px; align-items: center; border: 1px solid #dbe4ef; border-radius: 4px; background: #f8fafc; padding: 0 7px; color: #66758a; font-size: 8px; }
.v2-mileage-results > footer { border-top: 1px solid var(--v2-border); background: #fbfcfe; }
.v2-mileage-results > footer div { display: flex; gap: 6px; }
.v2-mileage-results > footer button { height: 29px; border: 1px solid #d7e0eb; border-radius: 6px; background: #fff; padding: 0 12px; color: #50627a; cursor: pointer; font-size: 9px; font-weight: 600; }
.v2-mileage-results > footer button:hover:not(:disabled) { border-color: #a9bdd7; background: #f5f8fc; color: #315f9f; }
.v2-mileage-results > footer button:disabled { opacity: .45; cursor: not-allowed; }
.v2-mileage-mobile-list { display: none; }
.v2-mileage-empty { display: grid; min-height: 210px; flex: 1; place-items: center; color: #8290a3; font-size: 12px; }
.v2-mileage-evidence { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 16px; color: var(--v2-muted); font-size: 9px; }
@media (max-width: 1050px) {
.v2-stat-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.v2-stat-search { grid-column: 1 / -1; }
.v2-stat-filter > .v2-primary-button { width: 100%; }
.v2-stat-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.v2-stat-kpis article:nth-child(4)::before { display: none; }
.v2-stat-grid-layout { grid-template-columns: 1fr; }
.v2-stat-ranking > div { max-height: 400px; }
.v2-mileage-filter { grid-template-columns: repeat(2,minmax(0,1fr)) auto auto; }
.v2-mileage-vehicle-field { grid-column: 1 / -1; }
.v2-mileage-filter > .v2-primary-button { width: auto; margin-top: 18px; }
.v2-mileage-ranges { grid-column: 1 / -1; }
}
@media (max-width: 680px) {
@@ -899,6 +1060,9 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-search-field input, .v2-filterbar select { font-size: 16px; }
.v2-filterbar select, .v2-primary-button, .v2-secondary-button { height: 44px; }
.v2-primary-button, .v2-secondary-button { padding: 0 10px; font-size: 12px; }
.v2-monitor-mode { height: 44px; }
.v2-monitor-mode button { flex: 1; font-size: 12px; }
.v2-monitor-mobile-entry { height: 44px; font-size: 12px; }
.v2-kpis { display: flex; min-width: 0; overflow-x: auto; border-radius: 9px; scroll-snap-type: x proximity; scrollbar-width: none; }
.v2-kpis::-webkit-scrollbar { display: none; }
.v2-kpi { width: 108px; min-width: 108px; min-height: 70px; flex: 0 0 108px; padding: 11px 13px; scroll-snap-align: start; }
@@ -906,6 +1070,23 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-kpi small { font-size: 10px; }
.v2-kpi strong { margin-top: 7px; font-size: 20px; }
.v2-monitor-workspace, .v2-monitor-workspace.is-detail-open, .v2-monitor-workspace.is-detail-collapsed { min-height: calc(52dvh + 360px); flex: none; grid-template-columns: 1fr; grid-template-rows: minmax(360px, 52dvh) 360px; overflow: hidden; }
.v2-monitor-table-panel { min-height: 68dvh; flex: none; }
.v2-monitor-table-panel > header { align-items: flex-start; flex-direction: column; gap: 4px; padding: 10px 12px; }
.v2-monitor-table-scroll { display: none; }
.v2-monitor-mobile-cards { display: flex; min-height: 0; flex: 1; flex-direction: column; gap: 8px; overflow: auto; padding: 8px; background: #f5f7fa; }
.v2-monitor-mobile-cards > article { border: 1px solid #e0e6ee; border-radius: 10px; background: #fff; padding: 12px; box-shadow: 0 2px 8px rgba(21,32,51,.04); }
.v2-monitor-mobile-cards > article > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.v2-monitor-mobile-cards > article > header div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.v2-monitor-mobile-cards > article > header strong { font-size: 15px; }.v2-monitor-mobile-cards > article > header span { color: #7f8da1; font-size: 10px; }
.v2-monitor-mobile-cards > article > header > b { color: #1f4f8d; font-size: 18px; font-variant-numeric: tabular-nums; }.v2-monitor-mobile-cards > article > header > b small { margin-left: 3px; color: #8996a8; font-size: 8px; font-weight: 500; }
.v2-monitor-mobile-cards dl { display: grid; grid-template-columns: .72fr 1.28fr; gap: 10px; margin: 12px 0 0; border-top: 1px solid #edf1f6; padding-top: 11px; }
.v2-monitor-mobile-cards dl > div { min-width: 0; }.v2-monitor-mobile-cards dl > div.is-address { grid-column: 1 / -1; }
.v2-monitor-mobile-cards dt { color: #8a97a9; font-size: 9px; }.v2-monitor-mobile-cards dd { margin: 4px 0 0; color: #34445a; font-size: 11px; font-weight: 650; }
.v2-monitor-mobile-cards dd code { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 9px; font-weight: 500; }
.v2-monitor-mobile-cards .v2-monitor-address-result > span { white-space: normal; }
.v2-monitor-mobile-cards > article > footer { display: flex; align-items: center; justify-content: flex-end; gap: 16px; margin-top: 10px; border-top: 1px solid #edf1f6; padding-top: 10px; }
.v2-monitor-mobile-cards > article > footer button { display: flex; align-items: center; gap: 4px; border: 0; background: transparent; color: var(--v2-blue); font-size: 11px; font-weight: 700; }
.v2-monitor-table-panel > footer { position: sticky; bottom: calc(64px + env(safe-area-inset-bottom)); z-index: 4; background: #fff; }
.v2-vehicle-rail { grid-row: 2; border: 0; border-top: 1px solid var(--v2-border); }
.v2-fleet-map { grid-row: 1; }
.v2-vehicle-rail > header { height: 48px; }
@@ -1023,29 +1204,101 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-alert-rule-editor > footer { align-items: stretch; flex-direction: column; }
.v2-alert-notifications > footer { flex-direction: column; gap: 5px; }
.v2-ops-page { padding: 8px; }.v2-ops-heading { align-items: flex-start; flex-direction: column; gap: 8px; }.v2-ops-kpis { grid-template-columns: 1fr 1fr; }.v2-ops-kpis article + article::before { display: none; }.v2-ops-kpis article { border-bottom: 1px solid var(--v2-border); }
.v2-stat-page { gap: 9px; padding: 10px 8px 16px; }
.v2-stat-heading { align-items: flex-start; flex-direction: column; gap: 8px; }
.v2-stat-heading h2 { font-size: 18px; }
.v2-stat-heading p { line-height: 1.6; }
.v2-stat-heading > button { width: 100%; height: 40px; justify-content: center; }
.v2-stat-filter { grid-template-columns: 1fr; gap: 9px; padding: 10px; }
.v2-stat-search, .v2-stat-ranges { grid-column: auto; }
.v2-stat-filter input, .v2-stat-filter select, .v2-stat-search > div { height: 44px; font-size: 16px; }
.v2-stat-filter > .v2-primary-button { height: 44px; }
.v2-stat-ranges { display: grid; grid-template-columns: repeat(3, 1fr); }
.v2-stat-ranges button { height: 34px; border-radius: 7px; }
.v2-stat-kpis { display: flex; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: none; }
.v2-stat-kpis article { width: 150px; min-width: 150px; flex: 0 0 150px; padding: 12px 14px; scroll-snap-align: start; }
.v2-stat-kpis article + article::before { display: block; }
.v2-stat-kpis strong { font-size: 19px; }
.v2-stat-grid-layout { min-height: 0; grid-template-columns: 1fr; }
.v2-stat-card > header { align-items: flex-start; flex-direction: column; }
.v2-stat-chart-wrap { overflow-x: auto; padding: 10px 4px 0; }
.v2-stat-chart-wrap svg { width: 680px; max-width: none; }
.v2-stat-daily-list { grid-template-columns: repeat(2, minmax(0,1fr)); margin: 0 9px 10px; }
.v2-stat-ranking > div { max-height: none; }
.v2-stat-ranking article { padding: 11px 10px; }
.v2-stat-evidence { flex-direction: column; line-height: 1.5; }
.v2-mileage-page { gap: 10px; padding: 12px 8px 18px; }
.v2-mileage-heading { align-items: flex-start; flex-direction: row; gap: 10px; }
.v2-mileage-heading h2 { font-size: 18px; }
.v2-mileage-heading p { line-height: 1.6; }
.v2-mileage-heading > button { width: auto; height: 36px; flex: 0 0 auto; justify-content: center; padding: 0 11px; }
.v2-mileage-query-panel { border-radius: 10px; }
.v2-mileage-filter { grid-template-columns: 1fr; gap: 12px; padding: 12px; }
.v2-mileage-vehicle-field { grid-column: auto; }
.v2-mileage-filter > label > input { height: 44px; font-size: 16px; }
.v2-mileage-multiselect { min-height: 44px; padding-top: 7px; padding-bottom: 7px; }
.v2-mileage-selection > input { height: 28px; font-size: 16px; }
.v2-mileage-filter > .v2-primary-button { height: 44px; margin-top: 0; }
.v2-mileage-source-strategy { margin-top: 0; }
.v2-mileage-source-trigger { width: 100%; height: 44px; justify-content: center; }
.v2-mileage-source-popover { position: fixed; top: 70px; right: 8px; bottom: auto; left: 8px; width: auto; max-height: calc(100dvh - 142px - env(safe-area-inset-bottom)); overflow-y: auto; overscroll-behavior: contain; }
.v2-mileage-source-popover > header { position: sticky; z-index: 2; top: 0; background: #fff; }
.v2-mileage-source-popover > footer { position: sticky; z-index: 2; bottom: 0; background: #fff; }
.v2-mileage-source-list article { grid-template-columns: auto minmax(0,1fr) auto; gap: 9px; padding-right: 11px; padding-left: 12px; }
.v2-mileage-source-list article > p { grid-column: 2 / -1; justify-content: flex-end; margin-top: -5px; }
.v2-mileage-ranges { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 4px; }
.v2-mileage-ranges button { height: 36px; background: #f6f8fb; }
.v2-mileage-options > button { grid-template-columns: minmax(80px,.7fr) minmax(0,1fr); }
.v2-mileage-options > button em { display: none; }
.v2-mileage-summary { grid-template-columns: repeat(2,minmax(0,1fr)); }
.v2-mileage-summary article { padding: 13px 14px 14px; }
.v2-mileage-summary article:nth-child(3)::before { display: none; }
.v2-mileage-summary article:nth-child(-n+2) { border-bottom: 1px solid #e5eaf1; }
.v2-mileage-summary strong { font-size: 18px; }
.v2-mileage-results { min-height: 420px; overflow: visible; }
.v2-mileage-results > header { align-items: flex-start; height: auto; flex-direction: column; gap: 4px; padding: 11px 12px; }
.v2-mileage-results > header div { align-items: flex-start; flex-direction: column; gap: 3px; }
.v2-mileage-results > header .v2-mileage-result-actions { width: 100%; align-items: center; justify-content: space-between; flex-direction: row; gap: 8px; }
.v2-mileage-result-actions > button { height: 34px; flex: 0 0 auto; }
.v2-mileage-table-wrap { display: none; }
.v2-mileage-mobile-list { display: flex; flex-direction: column; }
.v2-mileage-mobile-list article { border-bottom: 1px solid #e9eef5; padding: 14px 12px; }
.v2-mileage-mobile-list article > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.v2-mileage-mobile-list article > header div { min-width: 0; }
.v2-mileage-mobile-list article > header strong, .v2-mileage-mobile-list article > header span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-mileage-mobile-list article > header strong { color: #253449; font-size: 13px; }
.v2-mileage-mobile-list article > header b { flex: 0 0 auto; color: var(--v2-blue); font-size: 14px; text-align: right; }
.v2-mileage-mobile-list article > header b small { display: block; margin-top: 3px; color: var(--v2-muted); font-size: 8px; font-weight: 500; }
.v2-mileage-mobile-list article > header span, .v2-mileage-mobile-list time { margin-top: 3px; color: var(--v2-muted); font-size: 9px; }
.v2-mileage-mobile-list dl { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px 14px; margin: 12px 0 0; }
.v2-mileage-mobile-list dl div { min-width: 0; }
.v2-mileage-mobile-list dt { color: var(--v2-muted); font-size: 9px; }
.v2-mileage-mobile-list dd { margin: 4px 0 0; overflow: hidden; color: #334155; font-size: 11px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.v2-mileage-mobile-list dd.is-daily { color: var(--v2-blue); font-size: 14px; }
.v2-mileage-mobile-days { display: grid; grid-auto-columns: 84px; grid-auto-flow: column; gap: 6px; margin-top: 12px; overflow-x: auto; padding-bottom: 3px; }
.v2-mileage-mobile-days > div { border: 1px solid #e2e8f0; border-radius: 7px; background: #f8fafc; padding: 8px; }
.v2-mileage-mobile-days time, .v2-mileage-mobile-days strong { display: block; }
.v2-mileage-mobile-days time { color: var(--v2-muted); font-size: 8px; }
.v2-mileage-mobile-days strong { margin-top: 4px; color: #334155; font-size: 10px; }
.v2-mileage-results > footer { min-height: 58px; align-items: flex-start; flex-direction: column; gap: 7px; padding: 9px 10px; }
.v2-mileage-results > footer div { width: 100%; }
.v2-mileage-results > footer button { height: 34px; }
.v2-mileage-results > footer div button { flex: 1; }
.v2-mileage-evidence { flex-direction: column; line-height: 1.5; }
}
@media (max-width: 1280px) {
.v2-access-filter-v3 { grid-template-columns: minmax(210px, 1.3fr) repeat(3, minmax(120px, .75fr)) auto auto; }
.v2-access-filter-v3 > button { min-width: 76px; }
.v2-access-protocol-coverage b { display: none; }
.v2-access-workspace-v3.is-inspector-open { position: relative; grid-template-columns: minmax(0, 1fr); }
.v2-access-inspector-v3 { position: absolute; z-index: 6; top: 0; right: 0; bottom: 0; width: min(390px, calc(100% - 40px)); box-shadow: -12px 0 38px rgba(24, 38, 58, .16); }
}
@media (max-width: 760px) {
.v2-access-page-v3 { gap: 9px; padding: 10px 8px 18px; }
.v2-access-heading { align-items: flex-start; flex-direction: column; gap: 8px; }
.v2-access-heading h2 { font-size: 19px; }
.v2-access-heading p { font-size: 11px; }
.v2-access-heading > div:last-child { width: 100%; justify-content: space-between; }
.v2-access-filter-v3 { grid-template-columns: 1fr 1fr; padding: 11px; }
.v2-access-filter-v3 .is-search { grid-column: 1 / -1; }
.v2-access-filter-v3 label { font-size: 11px; }
.v2-access-filter-v3 input, .v2-access-filter-v3 select, .v2-access-filter-v3 label > div { height: 42px; font-size: 13px; }
.v2-access-filter-v3 > button { height: 42px; }
.v2-access-kpis-v3 { display: flex; min-height: 56px; overflow-x: auto; scroll-snap-type: x proximity; }
.v2-access-kpis-v3 button { min-width: 150px; flex: 0 0 150px; scroll-snap-align: start; }
.v2-access-kpis-v3 strong { font-size: 20px; }
.v2-access-workspace-v3 { min-height: 620px; flex-basis: 620px; }
.v2-access-table-v3 > header { align-items: flex-start; min-height: 62px; flex-direction: column; padding: 9px 10px; }
.v2-access-table-title { align-items: flex-start; flex-direction: column; gap: 3px; }
.v2-access-table-actions { width: 100%; justify-content: space-between; overflow: hidden; }
.v2-access-protocol-coverage { overflow-x: auto; }
.v2-access-protocol-coverage b { display: inline; }
.v2-access-table-v3 > footer { align-items: flex-start; min-height: 76px; flex-direction: column; padding: 9px 10px; }
.v2-access-table-scroll-v3 table { font-size: 12px; }
.v2-access-inspector-v3 { position: fixed; z-index: 30; top: 76px; right: 8px; bottom: 8px; width: calc(100% - 16px); border-radius: 12px; }
.v2-access-identity-queue-v3 summary { align-items: flex-start; flex-direction: column; justify-content: center; gap: 3px; padding: 8px 10px; }
.v2-access-identity-queue-v3 > div { grid-template-columns: 1fr; }
.v2-access-settings fieldset { align-items: stretch; flex-direction: column; }
.v2-access-settings input { width: 100%; box-sizing: border-box; }
}
@keyframes v2-mobile-sheet-enter {
@@ -1062,3 +1315,122 @@ button, a { -webkit-tap-highlight-color: transparent; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
/* Track replay command center — map-first layout based on the G7 reference. */
.v2-track-page { display: grid; width: 100%; height: 100%; min-height: 0; grid-template-columns: 320px minmax(0, 1fr); gap: 0; overflow: hidden; padding: 0; background: #e9eef4; }
.v2-track-page.is-rail-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
.v2-track-rail { position: relative; z-index: 12; display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: visible; border-right: 1px solid #d9e1ea; background: #fff; box-shadow: 8px 0 24px rgba(30, 45, 66, .07); transition: transform .18s ease, opacity .18s ease; }
.v2-track-page.is-rail-collapsed .v2-track-rail { opacity: 0; pointer-events: none; transform: translateX(-100%); }
.v2-track-query { flex: 0 0 auto; border-bottom: 1px solid #e2e8f0; padding: 14px 14px 13px; background: #fff; }
.v2-track-query > header { display: flex; height: 34px; align-items: flex-start; justify-content: space-between; margin-bottom: 11px; }
.v2-track-query > header div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
.v2-track-query > header strong { color: #1e293b; font-size: 14px; letter-spacing: -.02em; }
.v2-track-query > header span { color: #8a97a9; font-size: 9px; }
.v2-track-query > header button { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid #dde5ef; border-radius: 7px; background: #fff; color: #66758a; cursor: pointer; }
.v2-track-query > label, .v2-track-date-grid label { position: relative; display: flex; min-width: 0; flex-direction: column; gap: 5px; color: #738197; font-size: 10px; }
.v2-track-vehicle-picker { position: relative; display: grid; height: 38px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 7px; border: 1px solid #d8e1ec; border-radius: 7px; background: #fdfefe; padding: 0 9px; color: #8a97a8; }
.v2-track-vehicle-picker:focus-within { border-color: #8cb7fb; box-shadow: 0 0 0 3px rgba(37, 99, 235, .08); }
.v2-track-vehicle-picker input { min-width: 0; height: 34px; border: 0; background: transparent; color: #334155; outline: 0; font-family: inherit; font-size: 11px; }
.v2-track-vehicle-picker > button { display: grid; width: 24px; height: 24px; place-items: center; border: 0; border-radius: 5px; background: transparent; color: #8a97a8; cursor: pointer; }
.v2-track-vehicle-options { position: absolute; z-index: 50; top: calc(100% + 6px); right: -1px; left: -1px; overflow: hidden; border: 1px solid #d8e1ec; border-radius: 9px; background: #fff; box-shadow: 0 16px 36px rgba(29, 43, 63, .16); }
.v2-track-vehicle-options header { display: flex; height: 32px; align-items: center; justify-content: space-between; border-bottom: 1px solid #edf1f6; background: #f8fafc; padding: 0 10px; color: #617086; font-size: 9px; }
.v2-track-vehicle-options header em { color: #95a1b1; font-style: normal; }
.v2-track-vehicle-options > button { display: grid; width: 100%; min-height: 44px; grid-template-columns: minmax(0, .85fr) minmax(0, 1.4fr) auto; align-items: center; gap: 8px; border: 0; border-top: 1px solid #f0f3f7; background: #fff; padding: 6px 10px; text-align: left; cursor: pointer; }
.v2-track-vehicle-options > button:hover { background: #f3f7ff; }
.v2-track-vehicle-options > button strong, .v2-track-vehicle-options > button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-track-vehicle-options > button strong { color: #334155; font-size: 10px; }.v2-track-vehicle-options > button span { color: #8390a2; font-size: 8px; }.v2-track-vehicle-options > button em { color: var(--v2-blue); font-size: 8px; font-style: normal; }
.v2-track-vehicle-options > p { display: flex; min-height: 46px; align-items: center; justify-content: center; gap: 7px; margin: 0; color: #8390a2; font-size: 9px; }
.v2-track-presets { display: flex; gap: 6px; margin: 8px 0; }
.v2-track-presets button { height: 25px; flex: 1; border: 1px solid #e0e7f0; border-radius: 5px; background: #f8fafc; color: #64748b; font-family: inherit; font-size: 9px; cursor: pointer; }
.v2-track-presets button:hover { border-color: #aac7f6; background: #eef5ff; color: var(--v2-blue); }
.v2-track-date-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-bottom: 8px; }
.v2-track-date-grid input, .v2-track-query select { width: 100%; height: 35px; box-sizing: border-box; border: 1px solid #dbe3ed; border-radius: 6px; background: #fff; padding: 0 7px; color: #48566a; outline: 0; font-family: inherit; font-size: 8px; }
.v2-track-query select { margin-bottom: 9px; font-size: 10px; }
.v2-track-query-button { display: flex; width: 100%; height: 36px; align-items: center; justify-content: center; gap: 7px; border: 1px solid #2563eb; border-radius: 7px; background: #2563eb; color: #fff; box-shadow: 0 6px 14px rgba(37, 99, 235, .18); font-family: inherit; font-size: 11px; font-weight: 700; cursor: pointer; }
.v2-track-query-button:disabled { cursor: not-allowed; opacity: .48; }
.v2-track-rail-result { display: flex; min-height: 0; flex: 1; flex-direction: column; }
.v2-track-rail-vehicle { display: grid; min-height: 58px; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 9px; border-bottom: 1px solid #e8edf3; padding: 8px 13px; }
.v2-track-rail-vehicle > span { display: grid; width: 30px; height: 30px; place-items: center; border-radius: 7px; background: #eaf2ff; color: #2563eb; }
.v2-track-rail-vehicle > div { min-width: 0; }.v2-track-rail-vehicle strong, .v2-track-rail-vehicle small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-track-rail-vehicle strong { color: #253348; font-size: 12px; }.v2-track-rail-vehicle small { margin-top: 3px; color: #8b97a8; font-size: 8px; }
.v2-track-rail-vehicle em { color: #2563eb; font-size: 12px; font-style: normal; font-weight: 700; }
.v2-track-rail-result > nav { display: grid; height: 40px; flex: 0 0 40px; grid-template-columns: 1fr 1fr 1fr; border-bottom: 1px solid #dfe6ee; padding: 0 8px; }
.v2-track-rail-result > nav button { position: relative; border: 0; background: transparent; color: #66758a; font-family: inherit; font-size: 10px; cursor: pointer; }
.v2-track-rail-result > nav button.is-active { color: #2563eb; font-weight: 700; }.v2-track-rail-result > nav button.is-active::after { position: absolute; right: 12px; bottom: -1px; left: 12px; height: 2px; border-radius: 2px 2px 0 0; background: #2563eb; content: ''; }
.v2-track-rail-result > nav b { margin-left: 2px; font-size: 8px; font-weight: 600; }
.v2-track-rail-scroll { min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; }
.v2-track-rail-empty { display: flex; min-height: 280px; align-items: center; justify-content: center; flex-direction: column; padding: 24px; color: #9aa6b5; text-align: center; }
.v2-track-rail-empty > svg { font-size: 25px; }.v2-track-rail-empty strong { margin-top: 10px; color: #4c596d; font-size: 12px; }.v2-track-rail-empty p { margin: 6px 0 0; color: #8a97a8; font-size: 9px; line-height: 1.55; }
.v2-track-stop-list > button, .v2-track-event-list > button { display: grid; width: 100%; min-height: 48px; grid-template-columns: 23px minmax(0, 1fr) auto; align-items: center; gap: 8px; border: 0; border-bottom: 1px solid #edf1f5; background: #fff; padding: 6px 11px; text-align: left; cursor: pointer; }
.v2-track-stop-list > button:hover, .v2-track-event-list > button:hover { background: #f8fbff; }.v2-track-stop-list > button.is-active, .v2-track-event-list > button.is-active { background: #edf5ff; box-shadow: inset 3px 0 #2563eb; }
.v2-track-stop-list button > i, .v2-track-event-list button > i { display: grid; width: 21px; height: 21px; place-items: center; border-radius: 50%; background: #f3aa3c; color: #fff; font-size: 8px; font-style: normal; font-weight: 800; }
.v2-track-event-list button > i { background: #2563eb; }.v2-track-event-list button > i.is-start { background: #18a86b; }.v2-track-event-list button > i.is-end { background: #e74b4b; }.v2-track-event-list button > i.is-warning { background: #f1a238; }
.v2-track-stop-list button > span, .v2-track-event-list button > span { min-width: 0; }.v2-track-stop-list button strong, .v2-track-stop-list button small, .v2-track-event-list button strong, .v2-track-event-list button small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-track-stop-list button strong, .v2-track-event-list button strong { color: #3d4b5f; font-size: 9px; }.v2-track-stop-list button small, .v2-track-event-list button small { margin-top: 3px; color: #8c98a9; font-size: 8px; }
.v2-track-stop-list button > em, .v2-track-event-list button > em { color: #6f7d90; font-size: 8px; font-style: normal; white-space: nowrap; }
.v2-track-list-empty { margin: 50px 20px; color: #8a97a8; font-size: 10px; text-align: center; }
.v2-track-overview-panel { padding-bottom: 14px; }.v2-track-overview-panel > section { border-bottom: 1px solid #edf1f5; padding: 11px 13px; }.v2-track-overview-panel section > header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }.v2-track-overview-panel header strong { color: #364357; font-size: 10px; }.v2-track-overview-panel header span { color: #8a97a8; font-size: 8px; }
.v2-track-overview-panel dl { margin: 0; }.v2-track-overview-panel dl div { display: grid; grid-template-columns: 86px minmax(0, 1fr); padding: 4px 0; font-size: 8px; }.v2-track-overview-panel dt { color: #8390a2; }.v2-track-overview-panel dd { margin: 0; color: #48566a; text-align: right; font-variant-numeric: tabular-nums; }
.v2-track-source-list { display: grid; gap: 5px; }.v2-track-source-list article { display: grid; grid-template-columns: minmax(0, 1fr) auto; border: 1px solid #e4e9f0; border-radius: 6px; background: #f8fafc; padding: 7px 8px; }.v2-track-source-list strong { color: #425066; font-size: 9px; }.v2-track-source-list span { color: #2563eb; font-size: 8px; }.v2-track-source-list small { grid-column: 1 / -1; margin-top: 3px; color: #8b97a8; font-size: 7px; }
.v2-track-quality-card p { margin: 0; color: #526176; font-size: 8px; line-height: 1.55; }.v2-track-quality-card small { display: block; margin-top: 6px; color: #8190a3; font-size: 7px; }.v2-track-quality-card.is-warning { background: #fffaf2; }
.v2-track-stage { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: #e7edf3; }
.v2-track-map, .v2-track-map-canvas { position: absolute; inset: 0; }
.v2-track-stage-tools { position: absolute; z-index: 18; top: 14px; right: 14px; display: flex; gap: 7px; }
.v2-track-stage-tools button { display: flex; height: 34px; align-items: center; gap: 6px; border: 1px solid #d7e0eb; border-radius: 7px; background: rgba(255, 255, 255, .95); padding: 0 10px; color: #607086; box-shadow: 0 5px 15px rgba(26, 42, 63, .1); backdrop-filter: blur(8px); font-family: inherit; font-size: 9px; cursor: pointer; }
.v2-track-stage-tools button.is-active { border-color: #a8c5f4; background: #eef5ff; color: #2563eb; }.v2-track-stage-tools button:disabled { cursor: not-allowed; opacity: .45; }
.v2-track-coverage-float { position: absolute; z-index: 16; top: 14px; left: 14px; display: grid; width: min(510px, calc(100% - 450px)); min-height: 34px; grid-template-columns: 7px minmax(0, 1fr) auto; align-items: center; gap: 9px; border: 1px solid rgba(209, 221, 234, .94); border-radius: 7px; background: rgba(255, 255, 255, .94); padding: 5px 10px; color: #65748a; box-shadow: 0 5px 16px rgba(26, 42, 63, .09); backdrop-filter: blur(9px); font-size: 8px; }
.v2-track-coverage-float > i { width: 7px; height: 7px; border-radius: 50%; background: #18a86b; }.v2-track-coverage-float.is-warning > i { background: #f1a238; }.v2-track-coverage-float > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-track-coverage-float strong { margin-right: 7px; color: #354358; font-size: 9px; }.v2-track-coverage-float em { color: #758399; font-size: 8px; font-style: normal; white-space: nowrap; }
.v2-track-current-card { position: absolute; z-index: 16; top: 60px; left: 14px; width: 292px; overflow: hidden; border: 1px solid rgba(204, 216, 230, .95); border-radius: 9px; background: rgba(28, 39, 55, .91); color: #fff; box-shadow: 0 12px 30px rgba(15, 23, 42, .2); backdrop-filter: blur(12px); }
.v2-track-current-card > header { display: flex; align-items: center; justify-content: space-between; padding: 10px 11px 8px; }.v2-track-current-card header div { min-width: 0; }.v2-track-current-card header strong, .v2-track-current-card header span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-track-current-card header strong { font-size: 12px; }.v2-track-current-card header span { margin-top: 3px; color: #b8c4d2; font-size: 8px; }.v2-track-current-card header b { border-radius: 10px; background: rgba(37, 99, 235, .38); padding: 3px 7px; color: #dce9ff; font-size: 8px; }
.v2-track-current-card > div { display: grid; grid-template-columns: 1fr 1fr 1fr; border-top: 1px solid rgba(255, 255, 255, .1); border-bottom: 1px solid rgba(255, 255, 255, .1); }.v2-track-current-card > div > span { min-width: 0; padding: 8px 10px; }.v2-track-current-card > div > span + span { border-left: 1px solid rgba(255, 255, 255, .1); }.v2-track-current-card small, .v2-track-current-card strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-track-current-card small { color: #9eacbd; font-size: 7px; }.v2-track-current-card > div strong { margin-top: 4px; font-size: 10px; }.v2-track-current-card em { color: #aebac8; font-size: 7px; font-style: normal; font-weight: 500; }
.v2-track-current-card > p { margin: 0; overflow: hidden; padding: 8px 11px; color: #c4ced9; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.v2-track-empty-state { position: absolute; z-index: 14; top: 50%; left: 50%; display: flex; width: min(330px, calc(100% - 36px)); align-items: center; flex-direction: column; transform: translate(-50%, calc(-50% - 56px)); border: 1px solid rgba(211, 221, 232, .95); border-radius: 12px; background: rgba(255, 255, 255, .93); padding: 24px; color: #8a97a8; box-shadow: 0 14px 36px rgba(27, 42, 61, .1); text-align: center; backdrop-filter: blur(10px); }.v2-track-empty-state > svg { color: #2563eb; font-size: 28px; }.v2-track-empty-state strong { margin-top: 10px; color: #344256; font-size: 13px; }.v2-track-empty-state p { margin: 6px 0 14px; font-size: 9px; }.v2-track-empty-state button { display: flex; height: 34px; align-items: center; gap: 6px; border: 0; border-radius: 6px; background: #2563eb; padding: 0 12px; color: #fff; font-family: inherit; font-size: 10px; font-weight: 700; cursor: pointer; }
.v2-track-loading { position: absolute; z-index: 25; top: 14px; left: 50%; display: flex; height: 34px; align-items: center; gap: 8px; transform: translateX(-50%); border: 1px solid #d9e2ec; border-radius: 7px; background: rgba(255, 255, 255, .96); padding: 0 12px; color: #59687c; box-shadow: 0 8px 20px rgba(30, 45, 66, .13); font-size: 9px; }
.v2-track-error { position: absolute; z-index: 28; top: 56px; right: 14px; width: min(420px, calc(100% - 28px)); }
.v2-track-marker { display: grid; width: 25px; height: 25px; place-items: center; border: 3px solid #fff; border-radius: 50%; background: #2563eb; color: #fff; box-shadow: 0 3px 10px rgba(29, 44, 65, .28); }.v2-track-marker span { font-size: 8px; font-weight: 800; line-height: 1; }.v2-track-marker.is-start { background: #18a86b; }.v2-track-marker.is-end { background: #e64c4c; }.v2-track-marker.is-stop { width: 22px; height: 22px; border-width: 2px; background: #f1a238; }
.v2-track-current-marker { position: relative; width: 42px; height: 42px; }.v2-track-current-marker > i { position: absolute; inset: 2px; border: 2px solid rgba(37, 99, 235, .45); border-radius: 50%; animation: v2-track-pulse 1.8s ease-out infinite; }.v2-track-current-marker > span { position: absolute; top: 13px; left: 13px; width: 16px; height: 16px; box-sizing: border-box; border: 4px solid #fff; border-radius: 50%; background: #2563eb; box-shadow: 0 3px 9px rgba(37, 99, 235, .5); }
@keyframes v2-track-pulse { 0% { opacity: .9; transform: scale(.45); } 80%, 100% { opacity: 0; transform: scale(1); } }
.v2-track-playback-dock { position: absolute; z-index: 20; right: 0; bottom: 0; left: 0; display: grid; min-height: 112px; grid-template-columns: 145px minmax(260px, 1fr) auto; align-items: center; gap: 18px; border-top: 1px solid rgba(202, 214, 228, .95); background: rgba(255, 255, 255, .96); padding: 12px 16px; box-shadow: 0 -8px 26px rgba(24, 38, 58, .11); backdrop-filter: blur(14px); }
.v2-track-dock-summary { min-width: 0; }.v2-track-dock-summary strong, .v2-track-dock-summary span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-track-dock-summary strong { color: #263448; font-size: 17px; letter-spacing: -.02em; }.v2-track-dock-summary span { margin-top: 5px; color: #7e8b9d; font-size: 8px; }
.v2-track-dock-progress { min-width: 0; }.v2-track-segment-rail { display: flex; width: 100%; height: 10px; overflow: hidden; border-radius: 5px; background: #e8edf3; }.v2-track-segment-rail button { min-width: 2px; border: 0; border-right: 1px solid rgba(255, 255, 255, .55); background: #b9e6d0; padding: 0; cursor: pointer; }.v2-track-segment-rail button.is-stopped { background: #f8d797; }.v2-track-segment-rail button.is-gap { background: #f2a7a7; }.v2-track-segment-rail button:hover { filter: brightness(.94); }.v2-track-segment-placeholder { height: 10px; border-radius: 5px; background: #e7ecf2; }
.v2-track-dock-progress > input { width: 100%; height: 4px; margin: 13px 0 7px; accent-color: #2563eb; cursor: pointer; }.v2-track-dock-progress > input:disabled { cursor: not-allowed; }.v2-track-dock-progress > div { display: flex; justify-content: space-between; color: #7c899a; font-size: 8px; }.v2-track-dock-progress time { color: #4b596d; font-variant-numeric: tabular-nums; }
.v2-track-dock-controls { display: flex; align-items: center; gap: 6px; }.v2-track-dock-controls > button { display: grid; width: 32px; height: 32px; place-items: center; border: 1px solid #d8e1eb; border-radius: 50%; background: #fff; color: #5c6b7f; cursor: pointer; }.v2-track-dock-controls > button.is-primary { width: 42px; height: 42px; border-color: #2563eb; background: #2563eb; color: #fff; box-shadow: 0 5px 14px rgba(37, 99, 235, .25); }.v2-track-dock-controls > button:disabled { cursor: not-allowed; opacity: .36; }
.v2-track-dock-controls label { display: flex; height: 32px; align-items: center; gap: 5px; border: 1px solid #d8e1eb; border-radius: 16px; padding: 0 8px 0 10px; color: #7c899a; font-size: 8px; }.v2-track-dock-controls select { border: 0; background: transparent; color: #3f4e63; outline: 0; font-family: inherit; font-size: 9px; font-weight: 700; }
.v2-track-dock-metrics { display: none; align-items: center; }.v2-track-dock-metrics > span { min-width: 92px; padding: 0 12px; }.v2-track-dock-metrics > span + span { border-left: 1px solid #e2e8f0; }.v2-track-dock-metrics small, .v2-track-dock-metrics strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-track-dock-metrics small { color: #8a97a8; font-size: 7px; }.v2-track-dock-metrics strong { margin-top: 5px; color: #405066; font-size: 9px; }
@media (min-width: 1680px) {
.v2-track-page { grid-template-columns: 340px minmax(0, 1fr); }
.v2-track-page.is-rail-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
.v2-track-playback-dock { grid-template-columns: 150px minmax(310px, 1fr) auto auto; }
.v2-track-dock-metrics { display: flex; }
}
@media (max-width: 1100px) {
.v2-track-page { grid-template-columns: 286px minmax(0, 1fr); }
.v2-track-page.is-rail-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
.v2-track-current-card { width: 260px; }
.v2-track-coverage-float { width: min(420px, calc(100% - 360px)); }
.v2-track-playback-dock { grid-template-columns: minmax(250px, 1fr) auto; gap: 12px; }
.v2-track-dock-summary, .v2-track-dock-metrics { display: none; }
}
@media (max-width: 700px) {
.v2-track-page, .v2-track-page.is-rail-collapsed { position: relative; display: block; height: 100%; min-height: 0; overflow: hidden; }
.v2-track-stage { width: 100%; height: 100%; }
.v2-track-rail { position: fixed; z-index: 72; right: 8px; bottom: calc(72px + env(safe-area-inset-bottom)); left: 8px; width: auto; height: min(76dvh, 680px); border: 1px solid #d7e0ea; border-radius: 16px; box-shadow: 0 -14px 46px rgba(22, 34, 50, .22); transform: translateY(0); }
.v2-track-rail::before { display: block; width: 40px; height: 4px; flex: 0 0 4px; margin: 8px auto 0; border-radius: 2px; background: #d5dee9; content: ''; }
.v2-track-page.is-rail-collapsed .v2-track-rail { opacity: 0; pointer-events: none; transform: translateY(calc(100% + 90px)); }
.v2-track-query { padding: 9px 12px 10px; }.v2-track-query > header { height: 30px; margin-bottom: 7px; }.v2-track-query > header strong { font-size: 14px; }.v2-track-query > header button { width: 34px; height: 34px; transform: rotate(-90deg); }
.v2-track-vehicle-picker { height: 42px; }.v2-track-vehicle-picker input, .v2-track-date-grid input, .v2-track-query select { font-size: 16px; }.v2-track-date-grid { grid-template-columns: 1fr; gap: 6px; }.v2-track-date-grid input, .v2-track-query select { height: 40px; }.v2-track-query select { margin-bottom: 7px; }.v2-track-presets { margin: 6px 0; }.v2-track-presets button { height: 29px; }.v2-track-query-button { height: 42px; font-size: 12px; }
.v2-track-rail-vehicle { min-height: 54px; }.v2-track-rail-result > nav { height: 42px; flex-basis: 42px; }.v2-track-stop-list > button, .v2-track-event-list > button { min-height: 54px; }.v2-track-stop-list button strong, .v2-track-event-list button strong { font-size: 11px; }.v2-track-stop-list button small, .v2-track-event-list button small { font-size: 9px; }
.v2-track-stage-tools { top: 8px; right: 8px; gap: 6px; }.v2-track-stage-tools button { width: 40px; height: 40px; justify-content: center; padding: 0; }.v2-track-stage-tools button > span:not(.semi-icon) { display: none; }.v2-track-stage-tools button > .semi-icon { display: inline-flex; font-size: 17px; }
.v2-track-coverage-float { top: 56px; right: 8px; left: 8px; width: auto; min-height: 36px; grid-template-columns: 7px minmax(0, 1fr); }.v2-track-coverage-float > span { white-space: nowrap; }.v2-track-coverage-float > span:not(strong) { font-size: 0; }.v2-track-coverage-float strong { font-size: 10px; }.v2-track-coverage-float em { display: none; }
.v2-track-current-card { top: auto; right: 8px; bottom: 126px; left: 8px; width: auto; border-radius: 9px; }.v2-track-current-card > header { padding: 8px 10px 6px; }.v2-track-current-card > div > span { padding: 6px 9px; }.v2-track-current-card > p { display: none; }
.v2-track-empty-state { width: min(300px, calc(100% - 28px)); transform: translate(-50%, calc(-50% - 42px)); padding: 20px 16px; }
.v2-track-loading { top: 103px; width: max-content; max-width: calc(100% - 20px); }
.v2-track-error { top: 103px; right: 8px; width: calc(100% - 16px); }
.v2-track-playback-dock { min-height: 118px; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; padding: 10px 9px; }.v2-track-dock-summary, .v2-track-dock-metrics { display: none; }.v2-track-dock-progress { align-self: center; }.v2-track-dock-controls { align-self: center; gap: 4px; }.v2-track-dock-controls > button { width: 34px; height: 34px; }.v2-track-dock-controls > button.is-primary { width: 42px; height: 42px; }.v2-track-dock-controls > button:first-child, .v2-track-dock-controls > button:nth-child(3), .v2-track-dock-controls > button:last-child { display: none; }.v2-track-dock-controls label { height: 34px; padding: 0 6px; }.v2-track-dock-controls label span { display: none; }
.v2-track-dock-progress > div span { display: none; }
}