feat(platform): consolidate production vehicle data workflows
This commit is contained in:
@@ -194,7 +194,7 @@ export interface HistorySeriesResponse { metrics: HistoryMetricDefinition[]; ser
|
||||
export interface HistoryExportRequest { keywords: string[]; category: string; protocol?: string; dateFrom?: string; dateTo?: string; metrics: string[]; format: 'csv'; }
|
||||
export interface HistoryExportJob { id: string; name: string; status: 'queued' | 'running' | 'completed' | 'failed'; progress: number; format: string; category: string; keywords: string[]; rowCount: number; totalRows: number; processedRows: number; fileSizeBytes: number; error?: string; downloadUrl?: string; createdAt: string; updatedAt: string; completedAt?: string; evidence: string; }
|
||||
|
||||
export interface AccessQuery { keyword?: string; protocol?: string; oem?: string; model?: string; provider?: string; firstSeenFrom?: string; firstSeenTo?: string; latestSeenFrom?: string; latestSeenTo?: string; onlineState?: string; delayState?: string; limit?: number; offset?: number; }
|
||||
export interface AccessQuery { keyword?: string; protocol?: string; oem?: string; model?: string; provider?: string; firstSeenFrom?: string; firstSeenTo?: string; latestSeenFrom?: string; latestSeenTo?: string; onlineState?: string; delayState?: string; connectionState?: string; limit?: number; offset?: number; }
|
||||
export interface AccessUnresolvedIdentityQuery { keyword?: string; protocol?: string; limit?: number; offset?: number; }
|
||||
export interface AccessUnresolvedIdentity {
|
||||
id: string; protocol: string; identifierMasked: string; plate: string; manufacturer: string; sourceEndpoint: string;
|
||||
@@ -207,11 +207,21 @@ export interface AccessVehicleRow {
|
||||
dataDelaySec: number | null; freshnessSec: number | null; onlineState: 'online' | 'offline' | 'never_reported' | 'unknown';
|
||||
thresholdSec: number; latestMessageType: string; latestEventId: string; latestError: string; delayAbnormal: boolean;
|
||||
firstSeenEvidence: string; firstSeenSource: string; reportIntervalEvidence: string; reportSampleCount: number;
|
||||
expectedProtocols: string[]; actualProtocols: string[]; missingProtocols: string[]; protocolStatuses: AccessProtocolStatus[];
|
||||
connectionState: 'healthy' | 'degraded' | 'incomplete' | 'offline' | 'not_connected'; expectationEvidence: string;
|
||||
}
|
||||
export interface AccessProtocolStatus {
|
||||
protocol: string; expected: boolean; connected: boolean; provider: string; firstSeenAt: string; latestEventAt: string;
|
||||
latestReceivedAt: string; reportIntervalSec: number | null; dataDelaySec: number | null; freshnessSec: number | null;
|
||||
onlineState: AccessVehicleRow['onlineState']; thresholdSec: number; delayAbnormal: boolean;
|
||||
firstSeenEvidence: string; reportIntervalEvidence: string;
|
||||
longitude?: number; latitude?: number; speedKmh?: number; socPercent?: number; totalMileageKm?: number; dailyMileageKm?: number;
|
||||
}
|
||||
export interface AccessDistribution { name: string; total: number; online: number; onlineRate: number; }
|
||||
export interface AccessSummary {
|
||||
totalVehicles: number; onlineVehicles: number; offlineVehicles: number; longOfflineVehicles: number;
|
||||
neverReported: number; unknownVehicles: number; delayAbnormal: number; reportedToday: number; onlineRate: number;
|
||||
healthyVehicles: number; incompleteVehicles: number; degradedVehicles: number;
|
||||
protocols: AccessDistribution[]; oems: AccessDistribution[]; asOf: string; thresholdVersion: number;
|
||||
}
|
||||
export interface AccessProtocolThreshold { protocol: string; thresholdSec: number; }
|
||||
|
||||
@@ -23,6 +23,7 @@ export type AMapOverlay = {
|
||||
setMap?: (map: AMapMap | null) => void;
|
||||
on?: (eventName: string, handler: () => void) => void;
|
||||
setPosition?: (position: [number, number]) => void;
|
||||
setPath?: (path: [number, number][]) => void;
|
||||
};
|
||||
|
||||
export type AMapMassMarks = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -752,6 +752,277 @@ body {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Mileage report: one query, one trend, one detail list. */
|
||||
.vp-mileage-report {
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.vp-mileage-report-filter-card {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vp-mileage-report-filter {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1.8fr) repeat(2, minmax(160px, 0.8fr)) minmax(160px, 0.8fr) auto;
|
||||
align-items: end;
|
||||
gap: 14px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-filter label {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 7px;
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-filter input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
background: var(--semi-color-fill-0);
|
||||
color: var(--vp-text);
|
||||
font: inherit;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.vp-mileage-report-filter input:hover {
|
||||
border-color: var(--semi-color-primary);
|
||||
}
|
||||
|
||||
.vp-mileage-report-filter input:focus {
|
||||
border-color: var(--semi-color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(51, 112, 255, 0.12);
|
||||
}
|
||||
|
||||
.vp-mileage-report-quick-ranges {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-quick-ranges button {
|
||||
padding: 4px 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #f2f5fa;
|
||||
color: var(--vp-text-muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-quick-ranges button:hover,
|
||||
.vp-mileage-report-quick-ranges button:focus-visible {
|
||||
background: rgba(51, 112, 255, 0.1);
|
||||
color: var(--semi-color-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vp-mileage-report-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.vp-mileage-report-kpis article {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: var(--vp-surface);
|
||||
box-shadow: var(--vp-shadow-sm);
|
||||
}
|
||||
|
||||
.vp-mileage-report-kpis article.is-primary {
|
||||
border-color: rgba(51, 112, 255, 0.26);
|
||||
background: linear-gradient(135deg, #f7faff 0%, #ffffff 72%);
|
||||
}
|
||||
|
||||
.vp-mileage-report-kpis span,
|
||||
.vp-mileage-report-kpis small {
|
||||
overflow: hidden;
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vp-mileage-report-kpis strong {
|
||||
overflow: hidden;
|
||||
color: var(--vp-text);
|
||||
font-size: clamp(22px, 2vw, 30px);
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vp-mileage-report-chart-card,
|
||||
.vp-mileage-report-table-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-card-title {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-card-title > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-card-title strong {
|
||||
color: var(--vp-text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-card-title span {
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.vp-mileage-report-chart-wrap {
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vp-mileage-report-chart-wrap svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-grid-line {
|
||||
stroke: #e8edf5;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.vp-mileage-report-axis-text {
|
||||
fill: #8491a5;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-area {
|
||||
fill: url(#mileage-area);
|
||||
}
|
||||
|
||||
.vp-mileage-report-line {
|
||||
fill: none;
|
||||
stroke: #3370ff;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.vp-mileage-report-point {
|
||||
fill: #fff;
|
||||
stroke: #3370ff;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.vp-mileage-report-empty {
|
||||
min-height: 180px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-vehicle {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-vehicle strong {
|
||||
color: var(--vp-text);
|
||||
}
|
||||
|
||||
.vp-mileage-report-vehicle span {
|
||||
overflow: hidden;
|
||||
max-width: 210px;
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vp-mileage-report-daily-value {
|
||||
color: #245bdb;
|
||||
}
|
||||
|
||||
.vp-mileage-report-footnote {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px 20px;
|
||||
padding: 12px 2px 0;
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.vp-mileage-report-filter {
|
||||
grid-template-columns: minmax(240px, 1.5fr) repeat(2, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.vp-mileage-report-filter label:nth-of-type(4) {
|
||||
grid-column: 1 / 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.vp-mileage-report-filter {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-vehicle-field,
|
||||
.vp-mileage-report-filter label:nth-of-type(4),
|
||||
.vp-mileage-report-filter > .semi-button,
|
||||
.vp-mileage-report-quick-ranges {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.vp-mileage-report-kpis {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-kpis article {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-card-title {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.vp-mileage-report-chart-wrap,
|
||||
.vp-mileage-report-chart-wrap svg {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.vp-mileage-report-footnote {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.vp-dashboard-secondary-details,
|
||||
.vp-dashboard-evidence-details {
|
||||
margin-bottom: 16px;
|
||||
|
||||
46
vehicle-data-platform/apps/web/src/test/Mileage.test.tsx
Normal file
46
vehicle-data-platform/apps/web/src/test/Mileage.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { Mileage } from '../pages/Mileage';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders date-range mileage summary, trend and daily detail list', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
const data = path.includes('/api/v2/statistics/mileage')
|
||||
? {
|
||||
dateFrom: '2026-07-01', dateTo: '2026-07-03', vehicleCount: 1, recordCount: 3, sourceCount: 1,
|
||||
periodMileageKm: 210.5, fleetLatestMileageKm: 48000, averageMileagePerVin: 210.5,
|
||||
averageDailyMileageKm: 70.2, trend: [
|
||||
{ date: '2026-07-01', mileageKm: 60, vehicles: 1 },
|
||||
{ date: '2026-07-02', mileageKm: 70, vehicles: 1 },
|
||||
{ date: '2026-07-03', mileageKm: 80.5, vehicles: 1 }
|
||||
], ranking: [], asOf: '2026-07-04 00:05:00', evidence: 'vehicle_daily_mileage'
|
||||
}
|
||||
: {
|
||||
items: [{ vin: 'VIN-MILEAGE-001', plate: '粤A12345', date: '2026-07-03', startMileageKm: 47919.5, endMileageKm: 48000, dailyMileageKm: 80.5, source: 'JT808' }],
|
||||
total: 1, limit: 20, offset: 0
|
||||
};
|
||||
return { ok: true, json: async () => ({ data, traceId: 'trace-mileage', timestamp: Date.now() }) } as Response;
|
||||
});
|
||||
|
||||
render(
|
||||
<Mileage
|
||||
initialVin="粤A12345"
|
||||
initialProtocol="JT808"
|
||||
initialFilters={{ dateFrom: '2026-07-01', dateTo: '2026-07-03' }}
|
||||
onOpenVehicle={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: '车辆里程统计' })).toBeInTheDocument();
|
||||
expect(await screen.findByLabelText('每日里程趋势图')).toBeInTheDocument();
|
||||
expect((await screen.findAllByText('210.5 km')).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('粤A12345')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('80.5').length).toBeGreaterThanOrEqual(1);
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), undefined));
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('keyword=%E7%B2%A4A12345'), undefined);
|
||||
});
|
||||
@@ -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('"示范企业"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
161
vehicle-data-platform/apps/web/src/v2/domain/mileageExport.ts
Normal file
161
vehicle-data-platform/apps/web/src/v2/domain/mileageExport.ts
Normal 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);
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '运维质量'
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user