feat: build vehicle data platform and production pipeline
This commit is contained in:
47
vehicle-data-platform/apps/web/src/v2/AppV2.tsx
Normal file
47
vehicle-data-platform/apps/web/src/v2/AppV2.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { AppShell } from './layout/AppShell';
|
||||
import { AuthGate } from './auth/AuthGate';
|
||||
import { PageLoading } from './shared/AsyncState';
|
||||
|
||||
const MonitorPage = lazy(() => import('./pages/MonitorPage'));
|
||||
const VehiclePage = lazy(() => import('./pages/VehiclePage'));
|
||||
const TrackPage = lazy(() => import('./pages/TrackPage'));
|
||||
const HistoryPage = lazy(() => import('./pages/HistoryPage'));
|
||||
const AccessPage = lazy(() => import('./pages/AccessPage'));
|
||||
const AlertsPage = lazy(() => import('./pages/AlertsPage'));
|
||||
const OperationsPage = lazy(() => import('./pages/OperationsPage'));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 15_000,
|
||||
gcTime: 5 * 60_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function AppV2() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthGate><BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Navigate to="/monitor" replace />} />
|
||||
<Route path="/monitor" element={<Suspense fallback={<PageLoading />}><MonitorPage /></Suspense>} />
|
||||
<Route path="/vehicles/:vin?" element={<Suspense fallback={<PageLoading />}><VehiclePage /></Suspense>} />
|
||||
<Route path="/tracks" element={<Suspense fallback={<PageLoading />}><TrackPage /></Suspense>} />
|
||||
<Route path="/history" element={<Suspense fallback={<PageLoading />}><HistoryPage /></Suspense>} />
|
||||
<Route path="/access" element={<Suspense fallback={<PageLoading />}><AccessPage /></Suspense>} />
|
||||
<Route path="/alerts/*" element={<Suspense fallback={<PageLoading />}><AlertsPage /></Suspense>} />
|
||||
<Route path="/operations" element={<Suspense fallback={<PageLoading />}><OperationsPage /></Suspense>} />
|
||||
<Route path="*" element={<Navigate to="/monitor" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter></AuthGate>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
50
vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx
Normal file
50
vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createContext, FormEvent, ReactNode, useContext, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { clearAccessToken, getAccessToken, PlatformSession, setAccessToken } from './session';
|
||||
|
||||
type AuthContextValue = {
|
||||
session: PlatformSession;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function usePlatformSession() {
|
||||
const value = useContext(AuthContext);
|
||||
if (!value) throw new Error('usePlatformSession must be used inside AuthGate');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const [tokenVersion, setTokenVersion] = useState(0);
|
||||
const [draftToken, setDraftToken] = useState('');
|
||||
const [attempted, setAttempted] = useState(() => Boolean(getAccessToken()));
|
||||
const session = useQuery({
|
||||
queryKey: ['platform-session', tokenVersion],
|
||||
queryFn: api.session,
|
||||
retry: false,
|
||||
staleTime: Infinity
|
||||
});
|
||||
|
||||
const login = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setAccessToken(draftToken);
|
||||
setAttempted(true);
|
||||
setTokenVersion((value) => value + 1);
|
||||
};
|
||||
const logout = () => {
|
||||
clearAccessToken();
|
||||
setDraftToken('');
|
||||
setAttempted(false);
|
||||
setTokenVersion((value) => value + 1);
|
||||
};
|
||||
|
||||
if (session.isPending) {
|
||||
return <div className="v2-auth-screen"><div className="v2-auth-card"><i className="v2-auth-spinner" /><strong>正在验证访问身份…</strong></div></div>;
|
||||
}
|
||||
if (!session.data) {
|
||||
return <div className="v2-auth-screen"><form className="v2-auth-card" onSubmit={login}><div className="v2-auth-mark">车</div><h1>车辆数据中台</h1><p>请输入运维人员分配的访问令牌。令牌仅保存在当前浏览器会话中。</p><label><span>访问令牌</span><input autoFocus required type="password" autoComplete="current-password" value={draftToken} onChange={(event) => setDraftToken(event.target.value)} placeholder="Bearer token" /></label>{attempted && session.error ? <em>{session.error.message}</em> : null}<button type="submit" disabled={!draftToken.trim()}>进入平台</button></form></div>;
|
||||
}
|
||||
return <AuthContext.Provider value={{ session: session.data, logout }}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
22
vehicle-data-platform/apps/web/src/v2/auth/session.test.ts
Normal file
22
vehicle-data-platform/apps/web/src/v2/auth/session.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { afterEach, expect, test } from 'vitest';
|
||||
import { canAdminister, canOperate, clearAccessToken, getAccessToken, setAccessToken } from './session';
|
||||
|
||||
afterEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
test('access token is scoped to the browser session and can be cleared', () => {
|
||||
setAccessToken(' secret-token ');
|
||||
expect(getAccessToken()).toBe('secret-token');
|
||||
expect(window.localStorage.length).toBe(0);
|
||||
clearAccessToken();
|
||||
expect(getAccessToken()).toBe('');
|
||||
});
|
||||
|
||||
test('role helpers follow the server permission hierarchy', () => {
|
||||
expect(canOperate({ name: 'v', role: 'viewer', authMode: 'enforce' })).toBe(false);
|
||||
expect(canOperate({ name: 'o', role: 'operator', authMode: 'enforce' })).toBe(true);
|
||||
expect(canAdminister({ name: 'o', role: 'operator', authMode: 'enforce' })).toBe(false);
|
||||
expect(canAdminister({ name: 'a', role: 'admin', authMode: 'enforce' })).toBe(true);
|
||||
});
|
||||
31
vehicle-data-platform/apps/web/src/v2/auth/session.ts
Normal file
31
vehicle-data-platform/apps/web/src/v2/auth/session.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
const TOKEN_KEY = 'vehicle-platform.access-token';
|
||||
|
||||
export type PlatformRole = 'viewer' | 'operator' | 'admin';
|
||||
|
||||
export interface PlatformSession {
|
||||
name: string;
|
||||
role: PlatformRole;
|
||||
authMode: 'disabled' | 'enforce';
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return window.sessionStorage.getItem(TOKEN_KEY) ?? '';
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string) {
|
||||
const normalized = token.trim();
|
||||
if (normalized) window.sessionStorage.setItem(TOKEN_KEY, normalized);
|
||||
else window.sessionStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function clearAccessToken() {
|
||||
window.sessionStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function canOperate(session: PlatformSession) {
|
||||
return session.role === 'operator' || session.role === 'admin';
|
||||
}
|
||||
|
||||
export function canAdminister(session: PlatformSession) {
|
||||
return session.role === 'admin';
|
||||
}
|
||||
25
vehicle-data-platform/apps/web/src/v2/domain/access.test.ts
Normal file
25
vehicle-data-platform/apps/web/src/v2/domain/access.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { accessRowsToCSV, formatSeconds, thresholdForProtocol, updateProtocolThreshold } from './access';
|
||||
|
||||
describe('access domain helpers', () => {
|
||||
it('formats duration without hiding sign or long offline windows', () => {
|
||||
expect(formatSeconds(45)).toBe('45 秒');
|
||||
expect(formatSeconds(3720)).toBe('1 小时 2 分');
|
||||
expect(formatSeconds(-3)).toBe('-3 秒');
|
||||
expect(formatSeconds(null)).toBe('—');
|
||||
});
|
||||
|
||||
it('uses protocol override and updates without duplicates', () => {
|
||||
const config = { version: 1, defaultThresholdSec: 300, delayThresholdSec: 30, longOfflineSec: 1800, updatedBy: '', updatedAt: '', audit: [], protocols: [{ protocol: 'JT808', thresholdSec: 60 }] };
|
||||
expect(thresholdForProtocol(config, 'JT808')).toBe(60);
|
||||
expect(thresholdForProtocol(config, 'GB32960')).toBe(300);
|
||||
expect(updateProtocolThreshold(config.protocols, 'JT808', 120)).toEqual([{ protocol: 'JT808', thresholdSec: 120 }]);
|
||||
});
|
||||
|
||||
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 }]);
|
||||
expect(csv).toContain('在线');
|
||||
expect(csv).toContain('"位置,数据"');
|
||||
expect(csv).toContain('"示范企业"');
|
||||
});
|
||||
});
|
||||
45
vehicle-data-platform/apps/web/src/v2/domain/access.ts
Normal file
45
vehicle-data-platform/apps/web/src/v2/domain/access.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { AccessProtocolThreshold, AccessThresholdConfig, AccessVehicleRow } from '../../api/types';
|
||||
|
||||
export const accessStateLabels: Record<AccessVehicleRow['onlineState'], string> = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
never_reported: '从未上报',
|
||||
unknown: '未知'
|
||||
};
|
||||
|
||||
export function formatSeconds(value: number | null | undefined) {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) return '—';
|
||||
const sign = value < 0 ? '-' : '';
|
||||
const seconds = Math.abs(Math.round(value));
|
||||
if (seconds < 60) return `${sign}${seconds} 秒`;
|
||||
if (seconds < 3600) return `${sign}${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`;
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${sign}${hours} 小时${minutes ? ` ${minutes} 分` : ''}`;
|
||||
}
|
||||
|
||||
export function formatAccessTime(value: string) {
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return '—';
|
||||
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(parsed).replace(/\//g, '-');
|
||||
}
|
||||
|
||||
export function thresholdForProtocol(config: AccessThresholdConfig, protocol: string) {
|
||||
return config.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? config.defaultThresholdSec;
|
||||
}
|
||||
|
||||
export function updateProtocolThreshold(items: AccessProtocolThreshold[], protocol: string, thresholdSec: number) {
|
||||
const next = items.filter((item) => item.protocol !== protocol);
|
||||
next.push({ protocol, thresholdSec });
|
||||
return next.sort((a, b) => a.protocol.localeCompare(b.protocol));
|
||||
}
|
||||
|
||||
export function accessRowsToCSV(rows: AccessVehicleRow[]) {
|
||||
const columns = ['在线状态', '车牌', 'VIN', '厂家', '车型', '企业', '协议', '接入厂家', '首次接入', '首次接入证据', '最新事件时间', '最新接收时间', '上报间隔(秒)', '持久样本数', '上报间隔证据', '数据延迟(秒)', '动态阈值(秒)', '最新消息类型', '最近错误'];
|
||||
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(','));
|
||||
return `\uFEFF${columns.map(quote).join(',')}\n${lines.join('\n')}`;
|
||||
}
|
||||
26
vehicle-data-platform/apps/web/src/v2/domain/alert.test.ts
Normal file
26
vehicle-data-platform/apps/web/src/v2/domain/alert.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { alertValue, canAct, ruleCondition, thresholdText } from './alert';
|
||||
|
||||
describe('alert domain helpers', () => {
|
||||
it('keeps trigger evidence and duration explicit', () => {
|
||||
const event = { triggerValue: 96, threshold: 80, thresholdHigh: 0, operator: 'gt', unit: 'km/h', durationSec: 60 };
|
||||
expect(alertValue(event)).toBe('96 km/h');
|
||||
expect(thresholdText(event)).toBe('> 80 km/h,持续 60 秒');
|
||||
});
|
||||
|
||||
it('enforces valid workflow transitions', () => {
|
||||
expect(canAct('unprocessed', 'acknowledge')).toBe(true);
|
||||
expect(canAct('processing', 'acknowledge')).toBe(false);
|
||||
expect(canAct('recovered', 'close')).toBe(true);
|
||||
expect(canAct('closed', 'ignore')).toBe(false);
|
||||
});
|
||||
|
||||
it('renders boolean rules without numeric fiction', () => {
|
||||
expect(ruleCondition({ valueType: 'boolean', booleanThreshold: true, metric: 'alarm_active', durationSec: 0 } as never)).toBe('协议告警位 是');
|
||||
});
|
||||
|
||||
it('renders range and state-change semantics', () => {
|
||||
expect(thresholdText({ threshold: 20, thresholdHigh: 80, operator: 'between', unit: '%', durationSec: 30 })).toBe('区间内 20–80 %,持续 30 秒');
|
||||
expect(ruleCondition({ valueType: 'boolean', metric: 'alarm_active', operator: 'changed', durationSec: 0 } as never)).toBe('协议告警位 状态变化');
|
||||
});
|
||||
});
|
||||
36
vehicle-data-platform/apps/web/src/v2/domain/alert.ts
Normal file
36
vehicle-data-platform/apps/web/src/v2/domain/alert.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { AlertEvent, AlertRule, AlertSeverity, AlertStatus } from '../../api/types';
|
||||
|
||||
export const severityLabels: Record<AlertSeverity, string> = { critical: '紧急', major: '重要', minor: '一般' };
|
||||
export const statusLabels: Record<AlertStatus, string> = { unprocessed: '未处理', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
|
||||
export const actionLabels: Record<string, string> = { trigger: '触发', acknowledge: '已确认', close: '已关闭', ignore: '已忽略', recover: '已恢复', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
|
||||
export const metricLabels: Record<string, string> = { speed_kmh: '速度', soc_percent: 'SOC', alarm_active: '协议告警位', freshness_sec: '离线时长', data_delay_sec: '数据延迟' };
|
||||
export const operatorLabels: Record<string, string> = { gt: '>', gte: '≥', lt: '<', lte: '≤', eq: '=', neq: '≠', between: '区间内', outside: '区间外', changed: '状态变化' };
|
||||
|
||||
export function formatAlertTime(value: string) {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value.replace('T', ' ').slice(0, 19);
|
||||
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date).replace(/\//g, '-');
|
||||
}
|
||||
|
||||
export function alertValue(event: Pick<AlertEvent, 'triggerValue' | 'unit'>) {
|
||||
return `${Number(event.triggerValue.toFixed(2)).toLocaleString('zh-CN')} ${event.unit}`.trim();
|
||||
}
|
||||
|
||||
export function thresholdText(event: Pick<AlertEvent, 'operator' | 'threshold' | 'thresholdHigh' | 'unit' | 'durationSec'>) {
|
||||
const duration = event.durationSec > 0 ? `,持续 ${event.durationSec} 秒` : '';
|
||||
if (event.operator === 'between' || event.operator === 'outside') return `${operatorLabels[event.operator]} ${event.threshold}–${event.thresholdHigh} ${event.unit}${duration}`.trim();
|
||||
if (event.operator === 'changed') return `状态发生变化${duration}`;
|
||||
return `${operatorLabels[event.operator] ?? event.operator} ${Number(event.threshold.toFixed(2)).toLocaleString('zh-CN')} ${event.unit}${duration}`.trim();
|
||||
}
|
||||
|
||||
export function ruleCondition(rule: AlertRule, labels: Record<string, string> = metricLabels) {
|
||||
const threshold = rule.operator === 'changed' ? '状态变化' : rule.operator === 'between' || rule.operator === 'outside' ? `${operatorLabels[rule.operator]} ${rule.threshold}–${rule.thresholdHigh}` : rule.valueType === 'boolean' ? (rule.booleanThreshold ? '是' : '否') : `${operatorLabels[rule.operator] ?? rule.operator} ${rule.threshold}`;
|
||||
return `${labels[rule.metric] ?? rule.metric} ${threshold}${rule.durationSec ? ` · ${rule.durationSec} 秒` : ''}`;
|
||||
}
|
||||
|
||||
export function canAct(status: AlertStatus, action: 'acknowledge' | 'close' | 'ignore') {
|
||||
if (action === 'acknowledge') return status === 'unprocessed';
|
||||
if (action === 'close') return status === 'unprocessed' || status === 'processing' || status === 'recovered';
|
||||
return status === 'unprocessed' || status === 'processing';
|
||||
}
|
||||
44
vehicle-data-platform/apps/web/src/v2/domain/history.test.ts
Normal file
44
vehicle-data-platform/apps/web/src/v2/domain/history.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildHistoryChartSeries, buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, parseHistoryKeywords } from './history';
|
||||
|
||||
describe('history domain', () => {
|
||||
it('parses and bounds multi-vehicle input', () => {
|
||||
expect(parseHistoryKeywords('粤A1, 粤A1;VIN2\nVIN3')).toEqual(['粤A1', 'VIN2', 'VIN3']);
|
||||
expect(parseHistoryKeywords('1,2,3,4,5,6')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('formats units without fabricating missing values', () => {
|
||||
expect(formatHistoryValue(undefined)).toBe('—');
|
||||
expect(formatHistoryValue(42.5, { unit: 'km/h' } as never)).toBe('42.5 km/h');
|
||||
});
|
||||
|
||||
it('builds only numeric series with at least two evidence points', () => {
|
||||
const rows = [
|
||||
{ values: { speedKmh: 20 }, deviceTime: '2' },
|
||||
{ values: { speedKmh: 10 }, deviceTime: '1' }
|
||||
] as never;
|
||||
const series = buildHistoryChartSeries(rows, [{ key: 'speedKmh', label: '速度', unit: 'km/h' }] as never);
|
||||
expect(series).toHaveLength(1);
|
||||
expect(series[0].path).toContain('M');
|
||||
});
|
||||
|
||||
it('builds unit-separated server aggregate panels and breaks lines across missing buckets', () => {
|
||||
const response = {
|
||||
dateFrom: '2026-07-13T16:00:00Z', dateTo: '2026-07-13T17:00:00Z',
|
||||
summary: { grainSeconds: 60 },
|
||||
series: [
|
||||
{ vin: 'VIN1', plate: '粤A1', protocol: 'GB32960', metric: 'speedKmh', label: '速度', unit: 'km/h', points: [{ time: '2026-07-13T16:00:00Z', value: 10 }, { time: '2026-07-13T16:01:00Z', value: 20 }, { time: '2026-07-13T16:10:00Z', value: 30 }] },
|
||||
{ vin: 'VIN1', plate: '粤A1', protocol: 'GB32960', metric: 'totalMileageKm', label: '总里程', unit: 'km', points: [{ time: '2026-07-13T16:00:00Z', value: 100 }, { time: '2026-07-13T16:01:00Z', value: 101 }] }
|
||||
]
|
||||
} as never;
|
||||
const panels = buildHistorySeriesPanels(response);
|
||||
expect(panels.map((panel) => panel.unit)).toEqual(['km/h', 'km']);
|
||||
expect(panels[0].lines[0].paths).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('formats persisted export file sizes compactly', () => {
|
||||
expect(formatExportFileSize(0)).toBe('—');
|
||||
expect(formatExportFileSize(1536)).toBe('1.5 KB');
|
||||
expect(formatExportFileSize(5 * 1024 * 1024)).toBe('5.0 MB');
|
||||
});
|
||||
});
|
||||
87
vehicle-data-platform/apps/web/src/v2/domain/history.ts
Normal file
87
vehicle-data-platform/apps/web/src/v2/domain/history.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { HistoryDataRow, HistoryMetricDefinition, HistorySeries, HistorySeriesResponse } from '../../api/types';
|
||||
|
||||
export function parseHistoryKeywords(value: string) {
|
||||
const seen = new Set<string>();
|
||||
return value.split(/[,;\n]/).map((item) => item.trim()).filter((item) => {
|
||||
const key = item.toLowerCase();
|
||||
if (!item || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
}).slice(0, 5);
|
||||
}
|
||||
|
||||
export function formatHistoryValue(value: unknown, metric?: HistoryMetricDefinition) {
|
||||
if (value == null || value === '') return '—';
|
||||
const formatted = typeof value === 'number' ? new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 6 }).format(value) : String(value);
|
||||
return metric?.unit ? `${formatted} ${metric.unit}` : formatted;
|
||||
}
|
||||
|
||||
export type ChartSeries = { key: string; label: string; color: string; path: string; points: number };
|
||||
const chartColors = ['#1268f3', '#12a46f', '#8b5cf6', '#f59e0b'];
|
||||
|
||||
export function buildHistoryChartSeries(rows: HistoryDataRow[], metrics: HistoryMetricDefinition[], width = 800, height = 150): ChartSeries[] {
|
||||
const ordered = [...rows].reverse();
|
||||
return metrics.slice(0, 4).flatMap((metric, seriesIndex) => {
|
||||
const values = ordered.map((row, rowIndex) => ({ rowIndex, value: row.values[metric.key] })).filter((item): item is { rowIndex: number; value: number } => typeof item.value === 'number' && Number.isFinite(item.value));
|
||||
if (values.length < 2) return [];
|
||||
let min = values[0].value;
|
||||
let max = values[0].value;
|
||||
for (const item of values) { if (item.value < min) min = item.value; if (item.value > max) max = item.value; }
|
||||
const range = max - min || 1;
|
||||
const usableWidth = width - 24;
|
||||
const usableHeight = height - 24;
|
||||
const path = values.map((item, pointIndex) => {
|
||||
const x = 12 + (ordered.length <= 1 ? 0 : item.rowIndex / (ordered.length - 1)) * usableWidth;
|
||||
const y = 12 + (1 - (item.value - min) / range) * usableHeight;
|
||||
return `${pointIndex ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
return [{ key: metric.key, label: metric.unit ? `${metric.label} (${metric.unit})` : metric.label, color: chartColors[seriesIndex], path, points: values.length }];
|
||||
});
|
||||
}
|
||||
|
||||
export type HistorySeriesLine = { key: string; label: string; color: string; paths: string[]; points: number };
|
||||
export type HistorySeriesPanel = { key: string; label: string; unit: string; minimum: number; maximum: number; start: string; end: string; lines: HistorySeriesLine[] };
|
||||
|
||||
export function buildHistorySeriesPanels(response?: HistorySeriesResponse, width = 800, height = 116): HistorySeriesPanel[] {
|
||||
if (!response) return [];
|
||||
const byMetric = new Map<string, HistorySeries[]>();
|
||||
response.series.forEach((series) => byMetric.set(series.metric, [...(byMetric.get(series.metric) ?? []), series]));
|
||||
return [...byMetric.entries()].flatMap(([metric, seriesList], panelIndex) => {
|
||||
const values = seriesList.flatMap((series) => series.points.map((point) => point.value).filter((value): value is number => typeof value === 'number' && Number.isFinite(value)));
|
||||
if (!values.length) return [];
|
||||
let minimum = Math.min(...values); let maximum = Math.max(...values);
|
||||
if (minimum === maximum) { const padding = Math.max(Math.abs(minimum) * 0.05, 1); minimum -= padding; maximum += padding; }
|
||||
const startMs = new Date(response.dateFrom).getTime(); const endMs = new Date(response.dateTo).getTime();
|
||||
const timeRange = Math.max(1, endMs - startMs); const valueRange = maximum - minimum;
|
||||
const lines = seriesList.map((series, seriesIndex) => {
|
||||
const paths: string[] = []; let current = ''; let previousMs: number | undefined;
|
||||
series.points.forEach((point) => {
|
||||
if (typeof point.value !== 'number' || !Number.isFinite(point.value)) { if (current) paths.push(current); current = ''; previousMs = undefined; return; }
|
||||
const time = new Date(point.time.replace(' ', 'T')).getTime();
|
||||
if (!Number.isFinite(time)) return;
|
||||
if (previousMs != null && time - previousMs > response.summary.grainSeconds * 1500) { if (current) paths.push(current); current = ''; }
|
||||
const x = 54 + Math.max(0, Math.min(1, (time - startMs) / timeRange)) * (width - 68);
|
||||
const y = 10 + (1 - (point.value - minimum) / valueRange) * (height - 30);
|
||||
current += `${current ? ' L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`; previousMs = time;
|
||||
});
|
||||
if (current) paths.push(current);
|
||||
return { key: `${series.vin}-${series.protocol}-${metric}`, label: `${series.plate || series.vin} · ${series.protocol}`, color: chartColors[(panelIndex * 2 + seriesIndex) % chartColors.length], paths, points: series.points.length };
|
||||
});
|
||||
const first = seriesList[0];
|
||||
return [{ key: metric, label: first.label, unit: first.unit, minimum, maximum, start: response.dateFrom, end: response.dateTo, lines }];
|
||||
});
|
||||
}
|
||||
|
||||
export function formatSeriesGrain(seconds: number) {
|
||||
if (seconds < 60) return `${seconds} 秒`;
|
||||
if (seconds < 3600) return `${seconds / 60} 分钟`;
|
||||
if (seconds < 86400) return `${seconds / 3600} 小时`;
|
||||
return `${seconds / 86400} 天`;
|
||||
}
|
||||
|
||||
export function formatExportFileSize(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '—';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
40
vehicle-data-platform/apps/web/src/v2/domain/monitor.test.ts
Normal file
40
vehicle-data-platform/apps/web/src/v2/domain/monitor.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { VehicleRealtimeRow } from '../../api/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatNumber, statusLabel, vehicleStatus } from './monitor';
|
||||
|
||||
function vehicle(overrides: Partial<VehicleRealtimeRow> = {}): VehicleRealtimeRow {
|
||||
return {
|
||||
vin: 'LTEST000000000001',
|
||||
plate: '粤A00001',
|
||||
phone: '',
|
||||
oem: '',
|
||||
protocols: ['JT808'],
|
||||
sourceStatus: [],
|
||||
sourceCount: 1,
|
||||
onlineSourceCount: 1,
|
||||
online: true,
|
||||
bindingStatus: 'bound',
|
||||
primaryProtocol: 'JT808',
|
||||
longitude: 113.2,
|
||||
latitude: 23.1,
|
||||
speedKmh: 0,
|
||||
socPercent: 80,
|
||||
totalMileageKm: 10,
|
||||
lastSeen: '2026-07-14 01:00:00',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('monitor domain', () => {
|
||||
it('keeps online, motion and unknown status semantics separate', () => {
|
||||
expect(vehicleStatus(vehicle({ online: false }))).toBe('offline');
|
||||
expect(vehicleStatus(vehicle({ speedKmh: 32 }))).toBe('driving');
|
||||
expect(vehicleStatus(vehicle({ speedKmh: 0 }))).toBe('idle');
|
||||
expect(vehicleStatus(vehicle({ lastSeen: '' }))).toBe('unknown');
|
||||
});
|
||||
|
||||
it('formats dense monitor values consistently', () => {
|
||||
expect(formatNumber(12560)).toBe('12,560');
|
||||
expect(statusLabel('driving')).toBe('行驶');
|
||||
});
|
||||
});
|
||||
35
vehicle-data-platform/apps/web/src/v2/domain/monitor.ts
Normal file
35
vehicle-data-platform/apps/web/src/v2/domain/monitor.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { VehicleRealtimeRow } from '../../api/types';
|
||||
|
||||
export type FleetStatus = 'online' | 'offline' | 'driving' | 'idle' | 'alert' | 'unknown';
|
||||
|
||||
export function vehicleStatus(vehicle: VehicleRealtimeRow): FleetStatus {
|
||||
if (!vehicle.lastSeen) return 'unknown';
|
||||
if (!vehicle.online) return 'offline';
|
||||
if (vehicle.speedKmh > 3) return 'driving';
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
export function statusLabel(status: FleetStatus) {
|
||||
return {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
driving: '行驶',
|
||||
idle: '静止',
|
||||
alert: '告警',
|
||||
unknown: '未知'
|
||||
}[status];
|
||||
}
|
||||
|
||||
export function relativeFreshness(value: string) {
|
||||
const time = Date.parse(value);
|
||||
if (!Number.isFinite(time)) return '时间未知';
|
||||
const seconds = Math.max(0, Math.round((Date.now() - time) / 1000));
|
||||
if (seconds < 60) return `${seconds} 秒前`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟前`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)} 小时前`;
|
||||
return `${Math.floor(seconds / 86400)} 天前`;
|
||||
}
|
||||
|
||||
export function formatNumber(value: number, maximumFractionDigits = 0) {
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from './profileSync';
|
||||
|
||||
describe('parseVehicleProfileSyncCSV', () => {
|
||||
it('parses quoted values, BOM, CRLF and normalizes VIN/status', () => {
|
||||
const rows = parseVehicleProfileSyncCSV(`\uFEFF${vehicleProfileSyncCSVHeader}\r\nvin001,"车型,一",重卡,示范物流,ACTIVE,车厂平台,2026-07-01T08:30:00+08:00,3600\r\n`);
|
||||
expect(rows).toEqual([expect.objectContaining({ vin: 'VIN001', modelName: '车型,一', operationStatus: 'active', runtimeSeconds: 3600 })]);
|
||||
});
|
||||
|
||||
it('rejects duplicate VINs and malformed source rows before upload', () => {
|
||||
const duplicate = `${vehicleProfileSyncCSVHeader}\nVIN001,,,,unknown,,,\nvin001,,,,unknown,,,`;
|
||||
expect(() => parseVehicleProfileSyncCSV(duplicate)).toThrow(/VIN 重复/);
|
||||
expect(() => parseVehicleProfileSyncCSV(`${vehicleProfileSyncCSVHeader}\nVIN001,,,,active,,,1.5`)).toThrow(/累计运行秒数/);
|
||||
expect(() => parseVehicleProfileSyncCSV('vin,modelName\nVIN001,车型')).toThrow(/表头/);
|
||||
expect(() => parseVehicleProfileSyncCSV(`${vehicleProfileSyncCSVHeader}\nVIN001,"车型"x,,,active,,,`)).toThrow(/引号结束/);
|
||||
});
|
||||
});
|
||||
70
vehicle-data-platform/apps/web/src/v2/domain/profileSync.ts
Normal file
70
vehicle-data-platform/apps/web/src/v2/domain/profileSync.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { VehicleProfileSyncItem } from '../../api/types';
|
||||
|
||||
const headers = ['vin', 'modelName', 'vehicleType', 'companyName', 'operationStatus', 'accessProvider', 'firstAccessAt', 'runtimeSeconds'] as const;
|
||||
const allowedStatuses = new Set(['', 'unknown', 'active', 'inactive', 'maintenance', 'retired']);
|
||||
|
||||
export const vehicleProfileSyncCSVHeader = headers.join(',');
|
||||
|
||||
export function parseVehicleProfileSyncCSV(text: string): VehicleProfileSyncItem[] {
|
||||
const rows = parseCSVRows(text.replace(/^\uFEFF/, ''));
|
||||
if (rows.length < 2) throw new Error('CSV 至少需要表头和一行车辆数据');
|
||||
const actualHeaders = rows[0].map((value) => value.trim());
|
||||
if (actualHeaders.length !== headers.length || actualHeaders.some((value, index) => value !== headers[index])) {
|
||||
throw new Error(`CSV 表头必须为:${vehicleProfileSyncCSVHeader}`);
|
||||
}
|
||||
const dataRows = rows.slice(1).filter((row) => row.some((value) => value.trim() !== ''));
|
||||
if (dataRows.length === 0 || dataRows.length > 500) throw new Error('单个 CSV 必须包含 1 至 500 辆车');
|
||||
const seen = new Set<string>();
|
||||
return dataRows.map((row, index) => {
|
||||
const line = index + 2;
|
||||
if (row.length !== headers.length) throw new Error(`CSV 第 ${line} 行列数不正确`);
|
||||
const [rawVIN, modelName, vehicleType, companyName, rawStatus, accessProvider, firstAccessAt, rawRuntime] = row.map((value) => value.trim());
|
||||
const vin = rawVIN.toUpperCase();
|
||||
if (!vin || vin.length > 32) throw new Error(`CSV 第 ${line} 行 VIN 无效`);
|
||||
if (seen.has(vin)) throw new Error(`CSV 第 ${line} 行 VIN 重复:${vin}`);
|
||||
seen.add(vin);
|
||||
const operationStatus = rawStatus.toLowerCase();
|
||||
if (!allowedStatuses.has(operationStatus)) throw new Error(`CSV 第 ${line} 行运营状态无效`);
|
||||
const runtimeSeconds = rawRuntime === '' ? null : Number(rawRuntime);
|
||||
if (runtimeSeconds !== null && (!Number.isSafeInteger(runtimeSeconds) || runtimeSeconds < 0)) throw new Error(`CSV 第 ${line} 行累计运行秒数无效`);
|
||||
return {
|
||||
vin, modelName, vehicleType, companyName,
|
||||
operationStatus: (operationStatus || 'unknown') as VehicleProfileSyncItem['operationStatus'],
|
||||
accessProvider, firstAccessAt, runtimeSeconds
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseCSVRows(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = '';
|
||||
let quoted = false;
|
||||
let closedQuote = false;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
if (quoted) {
|
||||
if (char === '"') {
|
||||
if (text[index + 1] === '"') { field += '"'; index += 1; } else { quoted = false; closedQuote = true; }
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (closedQuote && char !== ',' && char !== '\n' && char !== '\r') throw new Error('CSV 引号结束后存在无效字符');
|
||||
if (char === '"') {
|
||||
if (field !== '') throw new Error('CSV 引号格式无效');
|
||||
quoted = true;
|
||||
} else if (char === ',') {
|
||||
row.push(field); field = ''; closedQuote = false;
|
||||
} else if (char === '\n' || char === '\r') {
|
||||
if (char === '\r' && text[index + 1] === '\n') index += 1;
|
||||
row.push(field); rows.push(row); row = []; field = ''; closedQuote = false;
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
}
|
||||
if (quoted) throw new Error('CSV 存在未闭合的引号');
|
||||
if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from './telemetry';
|
||||
|
||||
describe('latest telemetry presentation', () => {
|
||||
it('formats server-authoritative scalar values without inferring metadata', () => {
|
||||
expect(formatTelemetryValue(42.567)).toBe('42.57');
|
||||
expect(formatTelemetryValue(true)).toBe('是');
|
||||
expect(formatTelemetryValue(null)).toBe('—');
|
||||
});
|
||||
|
||||
it('translates server quality states', () => {
|
||||
expect(telemetryQualityLabel('good')).toBe('正常');
|
||||
expect(telemetryQualityLabel('stale')).toBe('陈旧');
|
||||
expect(telemetryQualityLabel('warning')).toBe('异常');
|
||||
});
|
||||
|
||||
it('keeps telemetry timestamps compact for local and RFC3339 values', () => {
|
||||
expect(formatTelemetryTime('2026-07-14T09:24:34+08:00')).toBe('09:24:34');
|
||||
expect(formatTelemetryTime('2026-07-14 09:24:34')).toBe('09:24:34');
|
||||
});
|
||||
});
|
||||
19
vehicle-data-platform/apps/web/src/v2/domain/telemetry.ts
Normal file
19
vehicle-data-platform/apps/web/src/v2/domain/telemetry.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function formatTelemetryValue(value: unknown) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 2 }).format(value);
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||
if (typeof value === 'string') return value || '—';
|
||||
return value == null ? '—' : String(value);
|
||||
}
|
||||
|
||||
export function telemetryQualityLabel(quality: string) {
|
||||
if (quality === 'good') return '正常';
|
||||
if (quality === 'stale') return '陈旧';
|
||||
return '异常';
|
||||
}
|
||||
|
||||
export function formatTelemetryTime(value?: string) {
|
||||
if (!value) return '—';
|
||||
return value.match(/[T ](\d{2}:\d{2}:\d{2})/)?.[1] ?? value;
|
||||
}
|
||||
21
vehicle-data-platform/apps/web/src/v2/domain/track.test.ts
Normal file
21
vehicle-data-platform/apps/web/src/v2/domain/track.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TrackPlaybackResponse } from '../../api/types';
|
||||
import { formatDuration, sampledEventIndex, trackCsv } from './track';
|
||||
|
||||
describe('track domain', () => {
|
||||
it('formats durations and maps original event indices to sampled points', () => {
|
||||
expect(formatDuration(3671)).toBe('01:01:11');
|
||||
expect(sampledEventIndex({ index: 50 } as never, 11, 101)).toBe(5);
|
||||
expect(sampledEventIndex({ index: 50, sampledIndex: 7 } as never, 11, 101)).toBe(7);
|
||||
});
|
||||
|
||||
it('exports the current result with UTF-8 BOM and escaped values', () => {
|
||||
const track = {
|
||||
plate: '粤A,001', vin: 'VIN', summary: { startTime: '2026-07-03 10:00:00' },
|
||||
points: [{ vin: 'VIN', plate: '粤A,001', protocol: 'JT808', deviceTime: '2026-07-03 10:00:00', serverTime: '2026-07-03 10:00:01', longitude: 113.1, latitude: 23.1, speedKmh: 10, totalMileageKm: 100 }]
|
||||
} as TrackPlaybackResponse;
|
||||
const csv = trackCsv(track);
|
||||
expect(csv.startsWith('\uFEFFVIN,')).toBe(true);
|
||||
expect(csv).toContain('"粤A,001"');
|
||||
});
|
||||
});
|
||||
42
vehicle-data-platform/apps/web/src/v2/domain/track.ts
Normal file
42
vehicle-data-platform/apps/web/src/v2/domain/track.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { HistoryLocationRow, TrackPlaybackEvent, TrackPlaybackResponse } from '../../api/types';
|
||||
|
||||
export function formatDuration(seconds: number) {
|
||||
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||
const hours = Math.floor(safe / 3600);
|
||||
const minutes = Math.floor((safe % 3600) / 60);
|
||||
const remainder = safe % 60;
|
||||
return [hours, minutes, remainder].map((value) => String(value).padStart(2, '0')).join(':');
|
||||
}
|
||||
|
||||
export function sampledEventIndex(event: TrackPlaybackEvent, sampledCount: number, originalCount: number) {
|
||||
if (sampledCount <= 1 || originalCount <= 1) return 0;
|
||||
if (Number.isInteger(event.sampledIndex) && event.sampledIndex >= 0) {
|
||||
return Math.min(sampledCount - 1, event.sampledIndex);
|
||||
}
|
||||
return Math.max(0, Math.min(sampledCount - 1, Math.round(event.index * (sampledCount - 1) / (originalCount - 1))));
|
||||
}
|
||||
|
||||
function csvCell(value: unknown) {
|
||||
const text = String(value ?? '');
|
||||
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
}
|
||||
|
||||
export function trackCsv(track: TrackPlaybackResponse) {
|
||||
const headers = ['VIN', '车牌', '协议', '设备时间', '服务时间', '经度', '纬度', '速度(km/h)', '总里程(km)'];
|
||||
const rows = track.points.map((point) => [point.vin, point.plate, point.protocol, point.deviceTime, point.serverTime, point.longitude, point.latitude, point.speedKmh, point.totalMileageKm]);
|
||||
return `\uFEFF${[headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\n')}`;
|
||||
}
|
||||
|
||||
export function downloadTrackCsv(track: TrackPlaybackResponse) {
|
||||
const blob = new Blob([trackCsv(track)], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `track-${track.plate || track.vin}-${track.summary.startTime.slice(0, 10) || 'latest'}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function validTrackPoints(points: HistoryLocationRow[]) {
|
||||
return points.filter((point) => Number.isFinite(point.longitude) && Number.isFinite(point.latitude) && point.longitude >= 73 && point.longitude <= 135 && point.latitude >= 18 && point.latitude <= 54);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MONITOR_REFRESH, monitorMapQueryParams, monitorQueryParams } from './useMonitorData';
|
||||
|
||||
describe('monitor query params', () => {
|
||||
it('keeps server-owned status filters and bounded list size', () => {
|
||||
const params = monitorQueryParams({ keyword: ' 沪A ', protocol: 'JT808', status: 'driving' }, 200);
|
||||
expect(params.get('keyword')).toBe('沪A');
|
||||
expect(params.get('protocol')).toBe('JT808');
|
||||
expect(params.get('status')).toBe('driving');
|
||||
expect(params.get('online')).toBeNull();
|
||||
expect(params.get('limit')).toBe('200');
|
||||
});
|
||||
|
||||
it('retains compatibility online filter for online and offline states', () => {
|
||||
expect(monitorQueryParams({ keyword: '', protocol: '', status: 'offline' }, 200).get('online')).toBe('offline');
|
||||
});
|
||||
|
||||
it('drops stale viewport bounds for a direct vehicle search', () => {
|
||||
const viewport = { zoom: 13, bounds: '103,29,105,31' };
|
||||
expect(monitorMapQueryParams({ keyword: '粤A1', protocol: '', status: '' }, viewport).has('bounds')).toBe(false);
|
||||
expect(monitorMapQueryParams({ keyword: '', protocol: '', status: '' }, viewport).get('bounds')).toBe(viewport.bounds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monitor refresh cadence', () => {
|
||||
it('prioritizes one selected vehicle without polling the whole fleet too aggressively', () => {
|
||||
expect(MONITOR_REFRESH).toEqual({ summary: 30_000, fleet: 15_000, selected: 10_000, alerts: 15_000 });
|
||||
expect(MONITOR_REFRESH.selected).toBeLessThan(MONITOR_REFRESH.fleet);
|
||||
expect(MONITOR_REFRESH.fleet).toBeLessThan(MONITOR_REFRESH.summary);
|
||||
});
|
||||
});
|
||||
104
vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts
Normal file
104
vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../../api/client';
|
||||
import type { VehicleRealtimeRow } from '../../api/types';
|
||||
|
||||
export type MonitorFilters = {
|
||||
keyword: string;
|
||||
protocol: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type MonitorViewport = {
|
||||
zoom: number;
|
||||
bounds: string;
|
||||
};
|
||||
|
||||
export const MONITOR_REFRESH = {
|
||||
summary: 30_000,
|
||||
fleet: 15_000,
|
||||
selected: 10_000,
|
||||
alerts: 15_000
|
||||
} as const;
|
||||
|
||||
export function monitorQueryParams(filters: MonitorFilters, limit: number) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (filters.keyword.trim()) params.set('keyword', filters.keyword.trim());
|
||||
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);
|
||||
return params;
|
||||
}
|
||||
|
||||
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);
|
||||
return params;
|
||||
}
|
||||
|
||||
export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewport, selectedVin: string) {
|
||||
const params = monitorQueryParams(filters, 200);
|
||||
const mapParams = monitorMapQueryParams(filters, viewport);
|
||||
|
||||
const summary = useQuery({
|
||||
queryKey: ['monitor', 'summary', params.toString()],
|
||||
queryFn: () => api.monitorSummary(params),
|
||||
refetchInterval: MONITOR_REFRESH.summary
|
||||
});
|
||||
const vehicles = useQuery({
|
||||
queryKey: ['monitor', 'vehicles', params.toString()],
|
||||
queryFn: () => api.vehicleRealtime(params),
|
||||
refetchInterval: MONITOR_REFRESH.fleet
|
||||
});
|
||||
|
||||
const map = useQuery({
|
||||
queryKey: ['monitor', 'map', mapParams.toString()],
|
||||
queryFn: () => api.monitorMap(mapParams),
|
||||
placeholderData: (previous) => previous,
|
||||
staleTime: 5_000,
|
||||
refetchInterval: MONITOR_REFRESH.fleet
|
||||
});
|
||||
|
||||
const selectedVehicle = useQuery({
|
||||
queryKey: ['monitor', 'selected-vehicle', selectedVin],
|
||||
queryFn: () => api.vehicleRealtime(new URLSearchParams({ keyword: selectedVin, limit: '1', offset: '0' })),
|
||||
enabled: Boolean(selectedVin),
|
||||
staleTime: 5_000,
|
||||
refetchInterval: selectedVin ? MONITOR_REFRESH.selected : false
|
||||
});
|
||||
|
||||
return { summary, vehicles, map, selectedVehicle };
|
||||
}
|
||||
|
||||
export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow, activelyTracked = false) {
|
||||
const enabled = Boolean(vin);
|
||||
const longitude = vehicle?.longitude;
|
||||
const latitude = vehicle?.latitude;
|
||||
const hasCoordinate = Number.isFinite(longitude) && Number.isFinite(latitude)
|
||||
&& Math.abs(longitude ?? 0) <= 180 && Math.abs(latitude ?? 0) <= 90;
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: ['monitor', 'vehicle-card', 'detail', vin],
|
||||
queryFn: () => api.vehicleDetail(new URLSearchParams({ keyword: vin })),
|
||||
enabled,
|
||||
staleTime: 30_000
|
||||
});
|
||||
const activeAlerts = useQuery({
|
||||
queryKey: ['monitor', 'vehicle-card', 'active-alerts', vin],
|
||||
queryFn: () => api.alertEventsV2({ keyword: vin, status: 'active', limit: 20, offset: 0 }),
|
||||
enabled,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: enabled && activelyTracked ? MONITOR_REFRESH.alerts : false
|
||||
});
|
||||
const address = useQuery({
|
||||
queryKey: ['monitor', 'vehicle-card', 'address', longitude, latitude],
|
||||
queryFn: () => api.reverseGeocode(new URLSearchParams({
|
||||
longitude: longitude!.toFixed(6),
|
||||
latitude: latitude!.toFixed(6)
|
||||
})),
|
||||
enabled: enabled && hasCoordinate,
|
||||
staleTime: 60 * 60_000
|
||||
});
|
||||
|
||||
return { detail, activeAlerts, address };
|
||||
}
|
||||
88
vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx
Normal file
88
vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
IconAlarm,
|
||||
IconBarChartHStroked,
|
||||
IconBox,
|
||||
IconChevronLeft,
|
||||
IconHelpCircle,
|
||||
IconHome,
|
||||
IconMapPin,
|
||||
IconSearch,
|
||||
IconSetting,
|
||||
IconUser,
|
||||
IconExit
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
|
||||
const navigation = [
|
||||
{ to: '/monitor', label: '全局监控', icon: IconHome },
|
||||
{ to: '/vehicles', label: '车辆查询', icon: IconSearch },
|
||||
{ to: '/tracks', label: '轨迹回放', icon: IconMapPin },
|
||||
{ to: '/history', label: '历史数据', icon: IconBarChartHStroked },
|
||||
{ to: '/alerts', label: '告警中心', icon: IconAlarm },
|
||||
{ to: '/access', label: '接入管理', icon: IconBox }
|
||||
];
|
||||
|
||||
const pageNames: Record<string, string> = {
|
||||
monitor: '全局监控',
|
||||
vehicles: '车辆查询',
|
||||
tracks: '轨迹回放',
|
||||
history: '历史数据',
|
||||
alerts: '告警中心',
|
||||
access: '接入管理',
|
||||
operations: '运维质量'
|
||||
};
|
||||
|
||||
export function AppShell() {
|
||||
const location = useLocation();
|
||||
const section = location.pathname.split('/')[1] || 'monitor';
|
||||
const { session, logout } = usePlatformSession();
|
||||
const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员' }[session.role];
|
||||
|
||||
return (
|
||||
<div className="v2-shell">
|
||||
<Sidebar />
|
||||
<div className="v2-main">
|
||||
<header className="v2-topbar">
|
||||
<h1>{pageNames[section] ?? '车辆数据中台'}</h1>
|
||||
<div className="v2-topbar-actions">
|
||||
<button type="button" aria-label="帮助"><IconHelpCircle /></button>
|
||||
<span className="v2-current-user"><IconUser /><b>{session.name}</b><small>{roleLabel}</small></span>
|
||||
<button type="button" aria-label="退出登录" title="退出登录" onClick={logout}><IconExit /></button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="v2-content"><Outlet /></main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<aside className={`v2-sidebar${collapsed ? ' is-collapsed' : ''}`}>
|
||||
<div className="v2-brand">
|
||||
<span className="v2-brand-mark"><IconBox size="large" /></span>
|
||||
<strong>车辆数据中台</strong>
|
||||
</div>
|
||||
<nav className="v2-navigation" aria-label="主导航">
|
||||
{navigation.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink key={to} to={to} className={({ isActive }) => `v2-nav-item ${isActive ? 'is-active' : ''}`}>
|
||||
<Icon size="large" />
|
||||
<span className="v2-nav-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<NavLink to="/operations" className={({ isActive }) => `v2-nav-item v2-nav-operations ${isActive ? 'is-active' : ''}`}>
|
||||
<IconSetting size="large" />
|
||||
<span className="v2-nav-label">运维质量</span>
|
||||
</NavLink>
|
||||
<button className="v2-collapse" type="button" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? '展开侧栏' : '收起侧栏'}>
|
||||
<IconChevronLeft />
|
||||
<span>收起</span>
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
351
vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx
Normal file
351
vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import type { MonitorMapResponse } from '../../api/types';
|
||||
import { wgs84ToGcj02, type AMapLike, type AMapMap, type AMapMassPoint } from '../../integrations/amap';
|
||||
import { FleetMap } from './FleetMap';
|
||||
|
||||
const setData = vi.fn<(data: AMapMassPoint[]) => void>();
|
||||
const setStyle = vi.fn();
|
||||
const addLabels = vi.fn();
|
||||
const clearLabels = vi.fn();
|
||||
const setLabelsMap = vi.fn();
|
||||
const markerSetMap = vi.fn();
|
||||
const markerSetPosition = vi.fn();
|
||||
const setZoomAndCenter = vi.fn();
|
||||
const panTo = vi.fn();
|
||||
const getZoom = vi.fn(() => 5);
|
||||
const getBounds = vi.fn((): ReturnType<NonNullable<AMapMap['getBounds']>> => ({}));
|
||||
const mapHandlers = new Map<string, (event: unknown) => void>();
|
||||
const markerOptions: Record<string, unknown>[] = [];
|
||||
const mapOptions: Record<string, unknown>[] = [];
|
||||
const labelLayerOptions: Record<string, unknown>[] = [];
|
||||
|
||||
class TestMap {
|
||||
constructor(_container: HTMLDivElement, options: Record<string, unknown>) {
|
||||
mapOptions.push(options);
|
||||
}
|
||||
add = vi.fn();
|
||||
addControl = vi.fn();
|
||||
destroy = vi.fn();
|
||||
on = vi.fn((event: string, handler: (value: unknown) => void) => mapHandlers.set(event, handler));
|
||||
getZoom = getZoom;
|
||||
getBounds = getBounds;
|
||||
setZoomAndCenter = setZoomAndCenter;
|
||||
panTo = panTo;
|
||||
}
|
||||
|
||||
class TestMassMarks {
|
||||
on = vi.fn();
|
||||
setMap = vi.fn();
|
||||
setData = setData;
|
||||
setStyle = setStyle;
|
||||
}
|
||||
|
||||
class TestScale {}
|
||||
class TestToolBar {}
|
||||
class TestSize {}
|
||||
class TestPixel {}
|
||||
class TestLabelsLayer {
|
||||
constructor(options: Record<string, unknown> = {}) {
|
||||
labelLayerOptions.push(options);
|
||||
}
|
||||
add = addLabels;
|
||||
clear = clearLabels;
|
||||
setMap = setLabelsMap;
|
||||
}
|
||||
class TestLabelMarker {
|
||||
constructor(public options: Record<string, unknown>) {}
|
||||
}
|
||||
class TestMarker {
|
||||
setMap = markerSetMap;
|
||||
setPosition = markerSetPosition;
|
||||
constructor(options: Record<string, unknown>) {
|
||||
markerOptions.push(options);
|
||||
}
|
||||
}
|
||||
|
||||
const monitorMap: MonitorMapResponse = {
|
||||
mode: 'clusters',
|
||||
zoom: 5,
|
||||
total: 12,
|
||||
truncated: false,
|
||||
points: [],
|
||||
clusters: [{
|
||||
id: 'cluster-1',
|
||||
longitude: 121.1,
|
||||
latitude: 30.6,
|
||||
count: 12,
|
||||
online: 8,
|
||||
offline: 4,
|
||||
driving: 3,
|
||||
idle: 5,
|
||||
unknown: 0
|
||||
}],
|
||||
asOf: '2026-07-14T01:00:00Z'
|
||||
};
|
||||
|
||||
const pointMap: MonitorMapResponse = {
|
||||
...monitorMap,
|
||||
mode: 'points',
|
||||
zoom: 13,
|
||||
total: 2,
|
||||
clusters: [],
|
||||
points: [{
|
||||
vin: 'LTEST000000000001',
|
||||
plate: '粤A12345',
|
||||
protocol: 'JT808',
|
||||
protocols: ['JT808'],
|
||||
longitude: 113.26,
|
||||
latitude: 23.13,
|
||||
speedKmh: 42,
|
||||
socPercent: 80,
|
||||
totalMileageKm: 1234,
|
||||
lastSeen: '2026-07-14T01:00:00Z',
|
||||
status: 'driving'
|
||||
}, {
|
||||
vin: 'LTEST000000000002',
|
||||
plate: '粤B67890',
|
||||
protocol: 'JT808',
|
||||
protocols: ['JT808'],
|
||||
longitude: 113.28,
|
||||
latitude: 23.15,
|
||||
speedKmh: 0,
|
||||
socPercent: 72,
|
||||
totalMileageKm: 2234,
|
||||
lastSeen: '2026-07-14T01:00:00Z',
|
||||
status: 'idle'
|
||||
}]
|
||||
};
|
||||
|
||||
function amapMock(): AMapLike {
|
||||
return {
|
||||
Map: TestMap as unknown as AMapLike['Map'],
|
||||
Marker: TestMarker as unknown as AMapLike['Marker'],
|
||||
Polyline: class {} as unknown as AMapLike['Polyline'],
|
||||
Scale: TestScale,
|
||||
ToolBar: TestToolBar,
|
||||
Size: TestSize as unknown as AMapLike['Size'],
|
||||
Pixel: TestPixel as unknown as AMapLike['Pixel'],
|
||||
MassMarks: TestMassMarks as unknown as AMapLike['MassMarks'],
|
||||
LabelsLayer: TestLabelsLayer as unknown as NonNullable<AMapLike['LabelsLayer']>,
|
||||
LabelMarker: TestLabelMarker as unknown as NonNullable<AMapLike['LabelMarker']>
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete window.__LINGNIU_APP_CONFIG__;
|
||||
delete window.AMapLoader;
|
||||
setData.mockReset();
|
||||
setStyle.mockReset();
|
||||
addLabels.mockReset();
|
||||
clearLabels.mockReset();
|
||||
setLabelsMap.mockReset();
|
||||
markerSetMap.mockReset();
|
||||
markerSetPosition.mockReset();
|
||||
setZoomAndCenter.mockReset();
|
||||
panTo.mockReset();
|
||||
getZoom.mockReset();
|
||||
getZoom.mockReturnValue(5);
|
||||
getBounds.mockReset();
|
||||
getBounds.mockReturnValue({});
|
||||
mapHandlers.clear();
|
||||
markerOptions.length = 0;
|
||||
mapOptions.length = 0;
|
||||
labelLayerOptions.length = 0;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders data that arrives before the delayed AMap SDK is ready', async () => {
|
||||
let resolveAMap!: (value: AMapLike) => void;
|
||||
const delayedAMap = new Promise<AMapLike>((resolve) => {
|
||||
resolveAMap = resolve;
|
||||
});
|
||||
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||
window.AMapLoader = { load: vi.fn(() => delayedAMap) };
|
||||
|
||||
render(
|
||||
<FleetMap
|
||||
vehicles={[]}
|
||||
monitorMap={monitorMap}
|
||||
onSelect={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveAMap(amapMock());
|
||||
await delayedAMap;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(setData).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ id: 'cluster-1', lnglat: wgs84ToGcj02(121.1, 30.6), label: '12 辆' })
|
||||
]));
|
||||
const clusterStyles = setStyle.mock.calls[setStyle.mock.calls.length - 1]?.[0] as Array<{ url: string }>;
|
||||
expect(decodeURIComponent(clusterStyles[5].url)).toContain('>12</text>');
|
||||
expect(decodeURIComponent(clusterStyles[5].url)).not.toContain('10+');
|
||||
});
|
||||
|
||||
test('converts AMap GCJ-02 bounds back to WGS-84 before requesting monitor data', async () => {
|
||||
const [west, south] = wgs84ToGcj02(113, 22);
|
||||
const [east, north] = wgs84ToGcj02(114, 24);
|
||||
getZoom.mockReturnValue(13);
|
||||
getBounds.mockReturnValue({
|
||||
getSouthWest: () => ({ getLng: () => west, getLat: () => south }),
|
||||
getNorthEast: () => ({ getLng: () => east, getLat: () => north })
|
||||
});
|
||||
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
|
||||
const onViewportChange = vi.fn();
|
||||
|
||||
render(<FleetMap vehicles={[]} monitorMap={pointMap} onSelect={() => undefined} onViewportChange={onViewportChange} />);
|
||||
|
||||
await waitFor(() => expect(onViewportChange).toHaveBeenCalled());
|
||||
const viewport = onViewportChange.mock.calls[onViewportChange.mock.calls.length - 1]?.[0] as { zoom: number; bounds: string };
|
||||
expect(viewport.zoom).toBe(13);
|
||||
const bounds = viewport.bounds.split(',').map(Number);
|
||||
expect(Math.abs(bounds[0] - 113)).toBeLessThan(0.0005);
|
||||
expect(Math.abs(bounds[1] - 22)).toBeLessThan(0.0005);
|
||||
expect(Math.abs(bounds[2] - 114)).toBeLessThan(0.0005);
|
||||
expect(Math.abs(bounds[3] - 24)).toBeLessThan(0.0005);
|
||||
});
|
||||
|
||||
test('renders one selected plate and smoothly follows it until the map is dragged', async () => {
|
||||
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
|
||||
|
||||
const view = render(
|
||||
<FleetMap
|
||||
vehicles={[]}
|
||||
monitorMap={pointMap}
|
||||
selectedVin="LTEST000000000001"
|
||||
onSelect={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(addLabels).toHaveBeenCalled());
|
||||
const renderedLabels = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
|
||||
const selectedPlate = renderedLabels.find((marker) => (marker.options.text as { content: string }).content === '粤A12345');
|
||||
const floatingPlate = renderedLabels.find((marker) => (marker.options.text as { content: string }).content === '粤B67890');
|
||||
expect(selectedPlate).toBeDefined();
|
||||
expect(floatingPlate).toBeDefined();
|
||||
expect((selectedPlate!.options.text as { style: unknown }).style).toEqual((floatingPlate!.options.text as { style: unknown }).style);
|
||||
expect(selectedPlate!.options).toEqual(expect.objectContaining({ rank: 100, zIndex: 10 }));
|
||||
expect(floatingPlate!.options).toEqual(expect.objectContaining({ rank: 1, zIndex: 1 }));
|
||||
expect((floatingPlate!.options.text as { style: Record<string, unknown> }).style).toEqual(expect.objectContaining({
|
||||
fillColor: '#174d9f',
|
||||
backgroundColor: '#eef5ff',
|
||||
borderColor: '#7fb0fa',
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
padding: [5, 9],
|
||||
fontSize: 11,
|
||||
shadowColor: 'rgba(18, 104, 243, 0.18)',
|
||||
shadowBlur: 14,
|
||||
shadowOffsetY: 5
|
||||
}));
|
||||
expect(mapOptions).toContainEqual(expect.objectContaining({ mapStyle: 'amap://styles/whitesmoke' }));
|
||||
await waitFor(() => expect(markerOptions).toContainEqual(expect.objectContaining({
|
||||
content: expect.stringContaining('粤A12345')
|
||||
})));
|
||||
expect(markerOptions[markerOptions.length - 1]?.content).not.toContain('<span>');
|
||||
expect(markerSetMap).toHaveBeenCalled();
|
||||
expect(renderedLabels).toHaveLength(2);
|
||||
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
|
||||
expect(setZoomAndCenter).toHaveBeenLastCalledWith(15, wgs84ToGcj02(113.26, 23.13));
|
||||
|
||||
view.rerender(
|
||||
<FleetMap
|
||||
vehicles={[]}
|
||||
monitorMap={{
|
||||
...pointMap,
|
||||
asOf: '2026-07-14T01:00:15Z',
|
||||
points: [{ ...pointMap.points[0], longitude: 113.27, latitude: 23.14 }]
|
||||
}}
|
||||
selectedVin="LTEST000000000001"
|
||||
onSelect={() => undefined}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(markerSetPosition).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.14)));
|
||||
expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.14), 650);
|
||||
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
|
||||
|
||||
const follow = screen.getByRole('button', { name: '跟随车辆' });
|
||||
expect(follow).toHaveAttribute('aria-pressed', 'true');
|
||||
act(() => mapHandlers.get('dragstart')?.({}));
|
||||
expect(follow).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
const panCountAfterDrag = panTo.mock.calls.length;
|
||||
view.rerender(
|
||||
<FleetMap
|
||||
vehicles={[]}
|
||||
monitorMap={{
|
||||
...pointMap,
|
||||
asOf: '2026-07-14T01:00:30Z',
|
||||
points: [{ ...pointMap.points[0], longitude: 113.29, latitude: 23.16 }, pointMap.points[1]]
|
||||
}}
|
||||
selectedVin="LTEST000000000001"
|
||||
onSelect={() => undefined}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(markerSetPosition).toHaveBeenLastCalledWith(wgs84ToGcj02(113.29, 23.16)));
|
||||
expect(panTo).toHaveBeenCalledTimes(panCountAfterDrag);
|
||||
fireEvent.click(follow);
|
||||
await waitFor(() => expect(follow).toHaveAttribute('aria-pressed', 'true'));
|
||||
expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.29, 23.16), 650);
|
||||
|
||||
getZoom.mockReturnValue(20);
|
||||
view.rerender(
|
||||
<FleetMap
|
||||
vehicles={[]}
|
||||
monitorMap={{ ...pointMap, asOf: '2026-07-14T01:00:45Z' }}
|
||||
selectedVin="LTEST000000000002"
|
||||
onSelect={() => undefined}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.28, 23.15), 650));
|
||||
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
|
||||
|
||||
const toggle = screen.getByRole('button', { name: '悬浮车牌' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(toggle).toHaveAttribute('aria-pressed', 'false'));
|
||||
expect(clearLabels).toHaveBeenCalled();
|
||||
const selectedOnlyLabels = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
|
||||
expect(selectedOnlyLabels).toHaveLength(1);
|
||||
expect((selectedOnlyLabels[0].options.text as { content: string }).content).toBe('粤B67890');
|
||||
});
|
||||
|
||||
test('renders every nearby plate with staggered positions at maximum zoom', async () => {
|
||||
getZoom.mockReturnValue(20);
|
||||
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
|
||||
const crowdedMap: MonitorMapResponse = {
|
||||
...pointMap,
|
||||
zoom: 20,
|
||||
total: 3,
|
||||
points: [
|
||||
pointMap.points[0],
|
||||
{ ...pointMap.points[1], longitude: 113.26001, latitude: 23.13001 },
|
||||
{ ...pointMap.points[1], vin: 'LTEST000000000003', plate: '粤C24680', longitude: 113.26002, latitude: 23.13002 }
|
||||
]
|
||||
};
|
||||
|
||||
const view = render(<FleetMap vehicles={[]} monitorMap={crowdedMap} onSelect={() => undefined} />);
|
||||
|
||||
await waitFor(() => expect(addLabels).toHaveBeenCalled());
|
||||
expect(labelLayerOptions).toContainEqual(expect.objectContaining({ zooms: [19, 20], zIndex: 110, collision: false, allowCollision: true }));
|
||||
const denseMarkers = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
|
||||
expect(denseMarkers).toHaveLength(3);
|
||||
const textOffsets = denseMarkers.map((marker) => JSON.stringify((marker.options.text as { offset: [number, number] }).offset));
|
||||
expect(new Set(textOffsets).size).toBe(3);
|
||||
expect(denseMarkers.every((marker) => marker.options.icon == null)).toBe(true);
|
||||
|
||||
const labelRenderCount = addLabels.mock.calls.length;
|
||||
view.rerender(<FleetMap vehicles={[]} monitorMap={crowdedMap} selectedVin="LTEST000000000002" onSelect={() => undefined} />);
|
||||
await waitFor(() => expect(addLabels.mock.calls.length).toBeGreaterThan(labelRenderCount));
|
||||
const selectedDenseMarkers = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
|
||||
expect(selectedDenseMarkers.every((marker) => marker.options.icon == null)).toBe(true);
|
||||
expect(selectedDenseMarkers.find((marker) => marker.options.rank === 100)).toBeDefined();
|
||||
});
|
||||
442
vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx
Normal file
442
vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
import { IconEyeClosed, IconEyeOpened, IconMapPin } from '@douyinfe/semi-icons';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getAMapConfig, isAMapConfigured } from '../../config/appConfig';
|
||||
import {
|
||||
gcj02ToWgs84,
|
||||
isValidAMapCoordinate,
|
||||
loadAMap,
|
||||
wgs84ToGcj02,
|
||||
type AMapMap,
|
||||
type AMapLabelsLayer,
|
||||
type AMapLike,
|
||||
type AMapMassMarks,
|
||||
type AMapMassPoint,
|
||||
type AMapOverlay
|
||||
} from '../../integrations/amap';
|
||||
import type { MonitorMapResponse, VehicleRealtimeRow } from '../../api/types';
|
||||
import { vehicleStatus } from '../domain/monitor';
|
||||
import type { MonitorViewport } from '../hooks/useMonitorData';
|
||||
|
||||
const COLORS = ['#12a46f', '#9aa6b7', '#1677ff', '#f59e0b', '#ef4444'];
|
||||
|
||||
function dotDataUrl(color: string) {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="54" height="54" viewBox="0 0 18 18"><circle cx="9" cy="9" r="6.5" fill="${color}" stroke="white" stroke-width="2.5"/></svg>`;
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
function clusterVisual(count: number) {
|
||||
const label = count.toLocaleString('en-US');
|
||||
const diameter = Math.min(54, 34 + Math.max(0, label.length - 1) * 4);
|
||||
const center = diameter / 2;
|
||||
const fontSize = label.length >= 6 ? 9 : label.length >= 4 ? 10 : 11;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${diameter * 3}" height="${diameter * 3}" viewBox="0 0 ${diameter} ${diameter}"><circle cx="${center}" cy="${center}" r="${center - 2}" fill="#1268f3" stroke="white" stroke-width="2.5"/><circle cx="${center}" cy="${center}" r="${center - 5}" fill="none" stroke="rgba(255,255,255,.22)" stroke-width="1"/><text x="${center}" y="${center + fontSize * 0.34}" text-anchor="middle" font-family="Inter,Arial,sans-serif" font-size="${fontSize}" font-weight="800" fill="white">${label}</text></svg>`;
|
||||
return { diameter, url: `data:image/svg+xml,${encodeURIComponent(svg)}` };
|
||||
}
|
||||
|
||||
function viewportFromMap(map: AMapMap): MonitorViewport | null {
|
||||
const zoom = Math.round(map.getZoom?.() ?? 5);
|
||||
const bounds = map.getBounds?.();
|
||||
const southWest = bounds?.getSouthWest?.();
|
||||
const northEast = bounds?.getNorthEast?.();
|
||||
const values = [southWest?.getLng?.(), southWest?.getLat?.(), northEast?.getLng?.(), northEast?.getLat?.()];
|
||||
if (values.some((value) => !Number.isFinite(value))) return { zoom, bounds: '' };
|
||||
const west = Number(values[0]);
|
||||
const south = Number(values[1]);
|
||||
const east = Number(values[2]);
|
||||
const north = Number(values[3]);
|
||||
const wgsCorners = [
|
||||
gcj02ToWgs84(west, south),
|
||||
gcj02ToWgs84(west, north),
|
||||
gcj02ToWgs84(east, south),
|
||||
gcj02ToWgs84(east, north)
|
||||
];
|
||||
const longitudes = wgsCorners.map(([longitude]) => longitude);
|
||||
const latitudes = wgsCorners.map(([, latitude]) => latitude);
|
||||
return {
|
||||
zoom,
|
||||
bounds: [Math.min(...longitudes), Math.min(...latitudes), Math.max(...longitudes), Math.max(...latitudes)]
|
||||
.map((value) => value.toFixed(6)).join(',')
|
||||
};
|
||||
}
|
||||
|
||||
function styleIndex(vehicle: VehicleRealtimeRow) {
|
||||
const status = vehicleStatus(vehicle);
|
||||
return statusStyleIndex(status);
|
||||
}
|
||||
|
||||
function statusStyleIndex(status: string) {
|
||||
if (status === 'driving') return 2;
|
||||
if (status === 'idle') return 0;
|
||||
if (status === 'offline') return 1;
|
||||
if (status === 'alert') return 4;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value.replace(/[&<>'"]/g, (character) => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
||||
})[character] ?? character);
|
||||
}
|
||||
|
||||
type PlateLabelPoint = {
|
||||
vin: string;
|
||||
plate: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
};
|
||||
|
||||
function densePlatePlacements(points: PlateLabelPoint[]) {
|
||||
const buckets = new Map<string, PlateLabelPoint[]>();
|
||||
for (const point of points) {
|
||||
const key = `${Math.round(point.longitude / 0.00018)}:${Math.round(point.latitude / 0.00008)}`;
|
||||
const bucket = buckets.get(key);
|
||||
if (bucket) bucket.push(point);
|
||||
else buckets.set(key, [point]);
|
||||
}
|
||||
const placements = new Map<string, {
|
||||
direction: 'left' | 'right';
|
||||
textOffset: [number, number];
|
||||
}>();
|
||||
for (const bucket of buckets.values()) {
|
||||
bucket.sort((left, right) => left.vin.localeCompare(right.vin));
|
||||
bucket.forEach((point, index) => {
|
||||
const column = Math.floor(index / 7);
|
||||
const row = index % 7;
|
||||
const rowsInColumn = Math.min(7, bucket.length - column * 7);
|
||||
const direction = column % 2 === 0 ? 'right' : 'left';
|
||||
placements.set(point.vin, {
|
||||
direction,
|
||||
textOffset: [8 + Math.floor(column / 2) * 78, (row - (rowsInColumn - 1) / 2) * 23]
|
||||
});
|
||||
});
|
||||
}
|
||||
return placements;
|
||||
}
|
||||
|
||||
export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelectVin, onViewportChange }: {
|
||||
vehicles: VehicleRealtimeRow[];
|
||||
selectedVin?: string;
|
||||
onSelect: (vehicle: VehicleRealtimeRow) => void;
|
||||
monitorMap?: MonitorMapResponse;
|
||||
onSelectVin?: (vin: string) => void;
|
||||
onViewportChange?: (viewport: MonitorViewport) => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<AMapMap | null>(null);
|
||||
const amapRef = useRef<AMapLike | null>(null);
|
||||
const massRef = useRef<AMapMassMarks | null>(null);
|
||||
const labelsRef = useRef<AMapLabelsLayer | null>(null);
|
||||
const denseLabelsRef = useRef<AMapLabelsLayer | null>(null);
|
||||
const selectionRef = useRef<AMapOverlay | null>(null);
|
||||
const onSelectRef = useRef(onSelect);
|
||||
const onSelectVinRef = useRef(onSelectVin);
|
||||
const onViewportChangeRef = useRef(onViewportChange);
|
||||
const vehiclesRef = useRef(new Map<string, VehicleRealtimeRow>());
|
||||
const clustersRef = useRef(new Map<string, { longitude: number; latitude: number }>());
|
||||
const viewportTimerRef = useRef<number | undefined>(undefined);
|
||||
const selectionKeyRef = useRef('');
|
||||
const selectionPositionRef = useRef('');
|
||||
const centeredVinRef = useRef('');
|
||||
const followSelectedRef = useRef(true);
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'fallback' | 'error'>('loading');
|
||||
const [showLabels, setShowLabels] = useState(true);
|
||||
const [followSelected, setFollowSelected] = useState(true);
|
||||
const [mapZoom, setMapZoom] = useState(5);
|
||||
const points = useMemo(() => vehicles.filter((vehicle) => isValidAMapCoordinate(vehicle.longitude, vehicle.latitude)), [vehicles]);
|
||||
const selectedTarget = useMemo(() => selectedVin
|
||||
? monitorMap?.points.find((item) => item.vin === selectedVin) ?? points.find((item) => item.vin === selectedVin)
|
||||
: undefined, [monitorMap, points, selectedVin]);
|
||||
const renderedPointCount = monitorMap ? monitorMap.points.length : points.length;
|
||||
const renderedClusterCount = monitorMap?.clusters.length ?? 0;
|
||||
const mapComposition = monitorMap && renderedClusterCount > 0
|
||||
? `${renderedClusterCount} 个聚合 · ${renderedPointCount} 个车辆点 · ${monitorMap.total} 辆`
|
||||
: `${renderedPointCount} 个有效点位`;
|
||||
const initialSelectionRef = useRef(points.find((vehicle) => vehicle.vin === selectedVin));
|
||||
|
||||
useEffect(() => {
|
||||
vehiclesRef.current = new Map(points.map((vehicle) => [vehicle.vin, vehicle]));
|
||||
}, [points, state]);
|
||||
|
||||
useEffect(() => {
|
||||
onSelectRef.current = onSelect;
|
||||
onSelectVinRef.current = onSelectVin;
|
||||
onViewportChangeRef.current = onViewportChange;
|
||||
}, [onSelect, onSelectVin, onViewportChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !isAMapConfigured(getAMapConfig())) {
|
||||
setState('fallback');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let resizeTimer: number | undefined;
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
setState('loading');
|
||||
loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => {
|
||||
if (cancelled || !containerRef.current) return;
|
||||
const initialSelection = initialSelectionRef.current;
|
||||
const initialCenter = initialSelection
|
||||
? wgs84ToGcj02(initialSelection.longitude, initialSelection.latitude)
|
||||
: wgs84ToGcj02(105.4, 35.9);
|
||||
const map = new AMap.Map(containerRef.current, {
|
||||
zoom: initialSelection ? 13 : 5,
|
||||
center: initialCenter,
|
||||
viewMode: '2D',
|
||||
mapStyle: 'amap://styles/whitesmoke',
|
||||
showLabel: true,
|
||||
resizeEnable: true
|
||||
});
|
||||
map.addControl(new AMap.Scale());
|
||||
if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '76px' } }));
|
||||
const styles = [
|
||||
...COLORS.map((color) => ({ url: dotDataUrl(color), anchor: new AMap.Pixel(9, 9), size: new AMap.Size(18, 18) }))
|
||||
];
|
||||
const mass = new AMap.MassMarks([], { opacity: 0.96, zIndex: 120, cursor: 'pointer', style: styles, zooms: [3, 20] });
|
||||
mass.on('click', (event) => {
|
||||
const cluster = clustersRef.current.get(event.data.id);
|
||||
if (cluster) {
|
||||
map.setZoomAndCenter?.(Math.min(20, (map.getZoom?.() ?? 5) + 2), wgs84ToGcj02(cluster.longitude, cluster.latitude));
|
||||
return;
|
||||
}
|
||||
if (onSelectVinRef.current) {
|
||||
onSelectVinRef.current(event.data.id);
|
||||
return;
|
||||
}
|
||||
const vehicle = vehiclesRef.current.get(event.data.id);
|
||||
if (vehicle) onSelectRef.current(vehicle);
|
||||
});
|
||||
mass.setMap(map);
|
||||
const labels = AMap.LabelsLayer ? new AMap.LabelsLayer({ zooms: [11, 18.99], zIndex: 110, collision: true, allowCollision: false }) : null;
|
||||
const denseLabels = AMap.LabelsLayer ? new AMap.LabelsLayer({ zooms: [19, 20], zIndex: 110, collision: false, allowCollision: true }) : null;
|
||||
labels?.setMap(map);
|
||||
const notifyViewport = () => {
|
||||
setMapZoom(map.getZoom?.() ?? 5);
|
||||
window.clearTimeout(viewportTimerRef.current);
|
||||
viewportTimerRef.current = window.setTimeout(() => {
|
||||
const viewport = viewportFromMap(map);
|
||||
if (viewport) onViewportChangeRef.current?.(viewport);
|
||||
}, 300);
|
||||
};
|
||||
map.on?.('moveend', notifyViewport);
|
||||
map.on?.('zoomend', notifyViewport);
|
||||
map.on?.('dragstart', () => {
|
||||
if (!centeredVinRef.current) return;
|
||||
followSelectedRef.current = false;
|
||||
setFollowSelected(false);
|
||||
});
|
||||
mapRef.current = map;
|
||||
amapRef.current = AMap;
|
||||
massRef.current = mass;
|
||||
labelsRef.current = labels;
|
||||
denseLabelsRef.current = denseLabels;
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(() => map.resize?.(), 80);
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
setState('ready');
|
||||
notifyViewport();
|
||||
}).catch(() => {
|
||||
if (!cancelled) setState('error');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
resizeObserver?.disconnect();
|
||||
window.clearTimeout(resizeTimer);
|
||||
window.clearTimeout(viewportTimerRef.current);
|
||||
massRef.current?.setMap(null);
|
||||
labelsRef.current?.setMap(null);
|
||||
denseLabelsRef.current?.setMap(null);
|
||||
selectionRef.current?.setMap?.(null);
|
||||
mapRef.current?.destroy();
|
||||
massRef.current = null;
|
||||
labelsRef.current = null;
|
||||
denseLabelsRef.current = null;
|
||||
selectionRef.current = null;
|
||||
amapRef.current = null;
|
||||
mapRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const mass = massRef.current;
|
||||
const AMap = amapRef.current;
|
||||
if (!mass || !AMap) return;
|
||||
clustersRef.current = new Map((monitorMap?.clusters ?? []).map((cluster) => [cluster.id, cluster]));
|
||||
const baseStyles = COLORS.map((color) => ({ url: dotDataUrl(color), anchor: new AMap.Pixel(9, 9), size: new AMap.Size(18, 18) }));
|
||||
const clusterCounts = [...new Set((monitorMap?.clusters ?? []).map((cluster) => cluster.count))].sort((a, b) => a - b);
|
||||
const clusterStyles = clusterCounts.map((count) => {
|
||||
const visual = clusterVisual(count);
|
||||
return { url: visual.url, anchor: new AMap.Pixel(visual.diameter / 2, visual.diameter / 2), size: new AMap.Size(visual.diameter, visual.diameter) };
|
||||
});
|
||||
mass.setStyle?.([...baseStyles, ...clusterStyles]);
|
||||
const clusterStyleIndexes = new Map(clusterCounts.map((count, index) => [count, COLORS.length + index]));
|
||||
const data: AMapMassPoint[] = monitorMap ? [
|
||||
...monitorMap.clusters.map((cluster) => ({ lnglat: wgs84ToGcj02(cluster.longitude, cluster.latitude), style: clusterStyleIndexes.get(cluster.count) ?? COLORS.length, id: cluster.id, label: `${cluster.count} 辆` })),
|
||||
...monitorMap.points.map((point) => ({ lnglat: wgs84ToGcj02(point.longitude, point.latitude), style: point.status === 'driving' ? 2 : point.status === 'idle' ? 0 : point.status === 'offline' ? 1 : 3, id: point.vin, label: point.plate || point.vin }))
|
||||
] : points.map((vehicle) => ({
|
||||
lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin
|
||||
}));
|
||||
mass.setData(data);
|
||||
}, [monitorMap, points, state]);
|
||||
|
||||
useEffect(() => {
|
||||
const labels = labelsRef.current;
|
||||
const denseLabels = denseLabelsRef.current;
|
||||
const AMap = amapRef.current;
|
||||
const map = mapRef.current;
|
||||
if (!labels || !denseLabels || !AMap?.LabelMarker || !map) return;
|
||||
labels.clear();
|
||||
denseLabels.clear();
|
||||
if (!showLabels && !selectedVin) {
|
||||
labels.setMap(null);
|
||||
denseLabels.setMap(null);
|
||||
return;
|
||||
}
|
||||
const mapLabelPoints = (monitorMap
|
||||
? monitorMap.points
|
||||
: points.map((vehicle) => ({ ...vehicle, status: vehicleStatus(vehicle) })));
|
||||
const allLabelPoints = selectedTarget && !mapLabelPoints.some((point) => point.vin === selectedTarget.vin)
|
||||
? [...mapLabelPoints, selectedTarget]
|
||||
: mapLabelPoints;
|
||||
const labelPoints = showLabels ? allLabelPoints : allLabelPoints.filter((point) => point.vin === selectedVin);
|
||||
const showEveryPlate = mapZoom >= 19;
|
||||
const activeLabels = showEveryPlate ? denseLabels : labels;
|
||||
labels.setMap(showEveryPlate ? null : map);
|
||||
denseLabels.setMap(showEveryPlate ? map : null);
|
||||
const densePlacements = showEveryPlate ? densePlatePlacements(labelPoints) : null;
|
||||
const markers = labelPoints.map((point) => {
|
||||
const placement = densePlacements?.get(point.vin);
|
||||
return new AMap.LabelMarker!({
|
||||
name: point.plate || point.vin,
|
||||
position: wgs84ToGcj02(point.longitude, point.latitude),
|
||||
rank: point.vin === selectedVin ? 100 : 1,
|
||||
zIndex: point.vin === selectedVin ? 10 : 1,
|
||||
text: {
|
||||
content: point.plate || point.vin,
|
||||
direction: placement?.direction ?? 'right',
|
||||
offset: placement?.textOffset ?? [8, 0],
|
||||
style: {
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
fillColor: '#174d9f',
|
||||
strokeColor: 'transparent',
|
||||
strokeWidth: 0,
|
||||
padding: [5, 9],
|
||||
backgroundColor: '#eef5ff',
|
||||
borderColor: '#7fb0fa',
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
shadowColor: 'rgba(18, 104, 243, 0.18)',
|
||||
shadowBlur: 14,
|
||||
shadowOffsetY: 5
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if (markers.length) activeLabels.add(markers);
|
||||
}, [mapZoom, monitorMap, points, selectedTarget, selectedVin, showLabels, state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedVin || !mapRef.current || !amapRef.current) {
|
||||
selectionRef.current?.setMap?.(null);
|
||||
selectionRef.current = null;
|
||||
selectionKeyRef.current = '';
|
||||
selectionPositionRef.current = '';
|
||||
centeredVinRef.current = '';
|
||||
followSelectedRef.current = false;
|
||||
setFollowSelected(false);
|
||||
return;
|
||||
}
|
||||
const target = selectedTarget;
|
||||
if (!target) return;
|
||||
const label = escapeHtml(target.plate || target.vin);
|
||||
const mapPosition = wgs84ToGcj02(target.longitude, target.latitude);
|
||||
const selectionKey = `${selectedVin}|${label}`;
|
||||
const positionKey = `${target.longitude.toFixed(6)},${target.latitude.toFixed(6)}`;
|
||||
if (centeredVinRef.current !== selectedVin) {
|
||||
followSelectedRef.current = true;
|
||||
setFollowSelected(true);
|
||||
const currentZoom = mapRef.current.getZoom?.() ?? 15;
|
||||
if (currentZoom < 15) mapRef.current.setZoomAndCenter?.(15, mapPosition);
|
||||
else mapRef.current.panTo?.(mapPosition, 650);
|
||||
centeredVinRef.current = selectedVin;
|
||||
} else if (selectionPositionRef.current && selectionPositionRef.current !== positionKey && followSelectedRef.current) {
|
||||
mapRef.current.panTo?.(mapPosition, 650);
|
||||
}
|
||||
selectionPositionRef.current = positionKey;
|
||||
if (selectionKeyRef.current === selectionKey && selectionRef.current) {
|
||||
selectionRef.current.setPosition?.(mapPosition);
|
||||
return;
|
||||
}
|
||||
selectionRef.current?.setMap?.(null);
|
||||
selectionRef.current = null;
|
||||
const marker = new amapRef.current.Marker({
|
||||
position: mapPosition,
|
||||
offset: new amapRef.current.Pixel(-24, -24),
|
||||
zIndex: 300,
|
||||
content: `<div class="v2-map-selection-marker" aria-label="已选车辆 ${label}"><i></i><i></i><b></b></div>`
|
||||
});
|
||||
marker.setMap?.(mapRef.current);
|
||||
selectionRef.current = marker;
|
||||
selectionKeyRef.current = selectionKey;
|
||||
}, [selectedTarget, selectedVin, state]);
|
||||
|
||||
const toggleFollow = () => {
|
||||
const next = !followSelected;
|
||||
followSelectedRef.current = next;
|
||||
setFollowSelected(next);
|
||||
if (next && selectedTarget && mapRef.current) {
|
||||
mapRef.current.panTo?.(wgs84ToGcj02(selectedTarget.longitude, selectedTarget.latitude), 650);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="v2-fleet-map">
|
||||
<div ref={containerRef} className="v2-fleet-map-canvas" aria-label="车辆全局监控地图" />
|
||||
<div className="v2-map-controls">
|
||||
{selectedVin ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`v2-map-follow-control${followSelected ? ' is-active' : ''}`}
|
||||
aria-label="跟随车辆"
|
||||
aria-pressed={followSelected}
|
||||
title={followSelected ? '车辆移动时保持居中;拖动地图可暂停' : '恢复车辆居中跟随'}
|
||||
onClick={toggleFollow}
|
||||
>
|
||||
<IconMapPin />
|
||||
<span><strong>跟随车辆</strong><small>{followSelected ? '实时居中' : '已暂停'}</small></span>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="v2-map-layer-control"
|
||||
aria-label="悬浮车牌"
|
||||
aria-pressed={showLabels}
|
||||
title={monitorMap?.mode === 'clusters' ? '放大地图后显示车辆车牌' : '显示或隐藏车辆悬浮车牌'}
|
||||
onClick={() => setShowLabels((current) => !current)}
|
||||
>
|
||||
{showLabels ? <IconEyeOpened /> : <IconEyeClosed />}
|
||||
<span><strong>悬浮车牌</strong><small>{monitorMap?.mode === 'clusters' ? '放大后显示' : '仅明细点'}</small></span>
|
||||
<i className={showLabels ? 'is-on' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
{state !== 'ready' ? (
|
||||
<div className={`v2-map-state is-${state}`}>
|
||||
{state === 'loading' ? <><span className="v2-spinner" />高德地图加载中</> : null}
|
||||
{state === 'fallback' ? `地图未配置,当前已载入 ${renderedPointCount} 个有效坐标` : null}
|
||||
{state === 'error' ? '地图加载失败,请检查高德 Key、域名白名单和网络' : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="v2-map-legend" aria-label="车辆状态图例">
|
||||
<span><i className="is-driving" />行驶</span>
|
||||
<span><i className="is-idle" />静止</span>
|
||||
<span><i className="is-offline" />离线</span>
|
||||
<span><i className="is-alert" />告警</span>
|
||||
<b>{mapComposition}</b>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx
Normal file
108
vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { HistoryLocationRow, TrackPlaybackEvent } 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>`;
|
||||
}
|
||||
|
||||
export function TrackMap({ points, events, activeIndex, onSelectIndex }: {
|
||||
points: HistoryLocationRow[];
|
||||
events: TrackPlaybackEvent[];
|
||||
activeIndex: number;
|
||||
onSelectIndex: (index: number) => 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 selectRef = useRef(onSelectIndex);
|
||||
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(() => {
|
||||
if (!containerRef.current || !isAMapConfigured(getAMapConfig())) { setState('fallback'); return; }
|
||||
let cancelled = false;
|
||||
loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => {
|
||||
if (cancelled || !containerRef.current) return;
|
||||
const first = valid[0]?.point;
|
||||
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
|
||||
});
|
||||
map.addControl(new AMap.Scale());
|
||||
if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '22px' } }));
|
||||
mapRef.current = map;
|
||||
amapRef.current = AMap;
|
||||
setState('ready');
|
||||
}).catch(() => { if (!cancelled) setState('error'); });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
|
||||
currentMarkerRef.current?.setMap?.(null);
|
||||
mapRef.current?.destroy();
|
||||
overlaysRef.current = [];
|
||||
currentMarkerRef.current = null;
|
||||
mapRef.current = null;
|
||||
amapRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 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]);
|
||||
|
||||
useEffect(() => {
|
||||
const point = points[activeIndex];
|
||||
if (!point || !isValidAMapCoordinate(point.longitude, point.latitude)) return;
|
||||
currentMarkerRef.current?.setPosition?.(wgs84ToGcj02(point.longitude, point.latitude));
|
||||
}, [activeIndex, points]);
|
||||
|
||||
return <div className="v2-track-map">
|
||||
<div ref={containerRef} className="v2-track-map-canvas" aria-label="历史轨迹地图" />
|
||||
{state !== 'ready' ? <div className={`v2-map-state is-${state}`}>
|
||||
{state === 'loading' ? <><span className="v2-spinner" />轨迹地图加载中</> : null}
|
||||
{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>;
|
||||
}
|
||||
117
vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx
Normal file
117
vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
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 { 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'];
|
||||
|
||||
type Filters = typeof EMPTY_FILTERS;
|
||||
|
||||
function StatusLabel({ state }: { state: AccessVehicleRow['onlineState'] }) {
|
||||
return <span className={`v2-access-status is-${state}`}><i />{accessStateLabels[state]}</span>;
|
||||
}
|
||||
|
||||
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 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 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 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 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);
|
||||
}
|
||||
|
||||
export default function AccessPage() {
|
||||
const { session } = usePlatformSession(); const thresholdEditable = 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 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 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]);
|
||||
|
||||
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;
|
||||
|
||||
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>
|
||||
{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} />
|
||||
{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>;
|
||||
}
|
||||
126
vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx
Normal file
126
vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { IconAlarm, IconBell, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FormEvent, memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { AlertEvent, AlertQuery, AlertRule, AlertRuleInput, AlertStatus, MetricDefinition } from '../../api/types';
|
||||
import { actionLabels, alertValue, canAct, formatAlertTime, operatorLabels, ruleCondition, severityLabels, statusLabels, thresholdText } from '../domain/alert';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister, canOperate } from '../auth/session';
|
||||
|
||||
type Tab = 'events' | 'rules' | 'notifications';
|
||||
type Filters = { keyword: string; severity: string; status: string; ruleId: string; protocol: string; dateFrom: string; dateTo: string };
|
||||
const EMPTY_FILTERS: Filters = { keyword: '', severity: '', status: '', ruleId: '', protocol: '', dateFrom: '', dateTo: '' };
|
||||
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'];
|
||||
const NUMERIC_OPERATORS = ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'between', 'outside'];
|
||||
const BOOLEAN_OPERATORS = ['eq', 'neq', 'changed'];
|
||||
|
||||
function SeverityTag({ severity }: Pick<AlertEvent, 'severity'>) { return <span className={`v2-alert-severity is-${severity}`}><i />{severityLabels[severity]}</span>; }
|
||||
function StatusTag({ status }: Pick<AlertEvent, 'status'>) { return <span className={`v2-alert-status is-${status}`}>{statusLabels[status]}</span>; }
|
||||
|
||||
const AlertRows = memo(function AlertRows({ rows, selectedID, onSelect }: { rows: AlertEvent[]; selectedID: string; onSelect: (id: string) => void }) {
|
||||
return <>{rows.map((event) => <tr key={event.id} className={selectedID === event.id ? 'is-selected' : ''} onClick={() => onSelect(event.id)}>
|
||||
<td><input type="radio" name="alert-event" checked={selectedID === event.id} onChange={() => onSelect(event.id)} aria-label={`选择 ${event.ruleName}`} /></td>
|
||||
<td><SeverityTag severity={event.severity} /></td><td><strong>{event.plate || '—'}</strong><small>{event.vin}</small></td><td>{event.ruleName}</td><td>{event.protocol || '—'}</td>
|
||||
<td>{formatAlertTime(event.triggeredAt)}</td><td>{formatAlertTime(event.recoveredAt)}</td><td><StatusTag status={event.status} /></td><td>{alertValue(event)}</td><td>{thresholdText(event)}</td><td title={event.location}>{event.location || '—'}</td><td>{event.handler || '—'}</td>
|
||||
</tr>)}</>;
|
||||
});
|
||||
|
||||
function EventInspector({ event, note, acting, actionError, editable, onNote, onAction }: { event?: AlertEvent; note: string; acting: boolean; actionError?: string; editable: boolean; onNote: (value: string) => void; onAction: (action: 'acknowledge' | 'close' | 'ignore') => void }) {
|
||||
if (!event) return <aside className="v2-alert-inspector"><div className="v2-alert-side-empty"><IconAlarm /><strong>选择告警事件</strong><span>查看触发证据、状态时间线和处置动作。</span></div></aside>;
|
||||
return <aside className="v2-alert-inspector"><header><div><strong>{event.ruleName}</strong><span><SeverityTag severity={event.severity} /><StatusTag status={event.status} /></span></div></header>
|
||||
<section><h3>事件信息</h3><dl><div><dt>事件 ID</dt><dd>{event.id}</dd></div><div><dt>规则 / 版本</dt><dd>{event.ruleId} / v{event.ruleVersion}</dd></div><div><dt>车辆 / VIN</dt><dd>{event.plate || '—'} / {event.vin}</dd></div><div><dt>触发时间</dt><dd>{formatAlertTime(event.triggeredAt)}</dd></div><div><dt>恢复时间</dt><dd>{formatAlertTime(event.recoveredAt)}</dd></div></dl></section>
|
||||
<section><h3>证据对比</h3><div className="v2-alert-evidence"><div><small>触发值</small><strong>{alertValue(event)}</strong></div><b>VS</b><div><small>阈值条件</small><strong>{thresholdText(event)}</strong></div></div><dl><div><dt>来源事件 ID</dt><dd>{event.sourceEventId || '—'}</dd></div><div><dt>协议</dt><dd>{event.protocol || '—'}</dd></div><div><dt>事件 / 接收</dt><dd>{formatAlertTime(event.eventAt)} / {formatAlertTime(event.receivedAt)}</dd></div></dl></section>
|
||||
<section><h3>处理进度</h3><div className="v2-alert-timeline">{event.actions?.map((item) => <article key={item.id}><i /><div><strong>{actionLabels[item.action] ?? item.action}</strong><span>{item.actor} · {formatAlertTime(item.createdAt)}</span>{item.note ? <p>{item.note}</p> : null}</div></article>)}</div></section>
|
||||
<section><h3>处置与备注</h3>{editable ? <><textarea maxLength={200} placeholder="请输入处置说明(选填)" value={note} onChange={(e) => onNote(e.target.value)} /><small className="v2-alert-note-count">{note.length}/200</small>{actionError ? <p className="v2-alert-action-error">{actionError}</p> : null}<div className="v2-alert-actions"><button className="is-primary" disabled={acting || !canAct(event.status, 'acknowledge')} onClick={() => onAction('acknowledge')}>确认告警</button><button disabled={acting || !canAct(event.status, 'close')} onClick={() => onAction('close')}>关闭</button><button disabled={acting || !canAct(event.status, 'ignore')} onClick={() => onAction('ignore')}>忽略</button></div></> : <p className="v2-role-notice">当前为只读角色,可查看完整证据与处置记录。</p>}</section>
|
||||
<nav className="v2-alert-links"><Link to={`/vehicles/${encodeURIComponent(event.vin)}`}>查看车辆</Link><Link to={`/tracks?vin=${encodeURIComponent(event.vin)}`}>查看轨迹</Link><Link to={`/history?vin=${encodeURIComponent(event.vin)}`}>历史数据</Link></nav>
|
||||
</aside>;
|
||||
}
|
||||
|
||||
function EventWorkspace({ filters, setFilters, rules, unread, editable, onTab }: { filters: Filters; setFilters: (next: Filters) => void; rules: AlertRule[]; unread: number; editable: boolean; onTab: (tab: Tab) => void }) {
|
||||
const [draft, setDraft] = useState(filters); const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(20); const [selectedID, setSelectedID] = useState(''); const [note, setNote] = useState(''); const queryClient = useQueryClient();
|
||||
const query: AlertQuery = useMemo(() => ({ ...Object.fromEntries(Object.entries(filters).filter(([, value]) => value)), limit, offset }), [filters, limit, offset]);
|
||||
const baseQuery = useMemo(() => ({ ...query, limit: undefined, offset: undefined }), [query]);
|
||||
const summary = useQuery({ queryKey: ['alert-summary-v2', baseQuery], queryFn: () => api.alertSummaryV2(baseQuery), staleTime: 8_000 });
|
||||
const events = useQuery({ queryKey: ['alert-events-v2', query], queryFn: () => api.alertEventsV2(query), placeholderData: (previous) => previous, staleTime: 5_000 });
|
||||
const rows = events.data?.items ?? [];
|
||||
useEffect(() => { if (rows.length && !rows.some((item) => item.id === selectedID)) setSelectedID(rows[0].id); }, [rows, selectedID]);
|
||||
const detail = useQuery({ queryKey: ['alert-event-v2', selectedID], queryFn: () => api.alertEventV2(selectedID), enabled: Boolean(selectedID), staleTime: 3_000 });
|
||||
const action = useMutation({ mutationFn: ({ name, event }: { name: 'acknowledge' | 'close' | 'ignore'; event: AlertEvent }) => api.actOnAlertV2(event.id, { version: event.version, action: name, note }), onSuccess: async (event) => { setNote(''); queryClient.setQueryData(['alert-event-v2', event.id], event); await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-events-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] })]); } });
|
||||
const submit = (e: FormEvent) => { e.preventDefault(); setFilters(draft); setOffset(0); };
|
||||
const quickStatus = (status: string) => { const next = { ...filters, status }; setDraft(next); setFilters(next); setOffset(0); };
|
||||
const totalPages = Math.max(1, Math.ceil((events.data?.total ?? 0) / limit)); const page = Math.floor(offset / limit) + 1; const sums = summary.data;
|
||||
return <><form className="v2-alert-filter" onSubmit={submit}><label><span>关键词</span><div><IconSearch /><input value={draft.keyword} onChange={(e) => setDraft({ ...draft, keyword: e.target.value })} placeholder="车牌 / VIN / 规则名称" /></div></label><label><span>严重程度</span><select value={draft.severity} onChange={(e) => setDraft({ ...draft, severity: e.target.value })}><option value="">全部</option><option value="critical">紧急</option><option value="major">重要</option><option value="minor">一般</option></select></label><label><span>状态</span><select value={draft.status} onChange={(e) => setDraft({ ...draft, status: e.target.value })}><option value="">全部</option>{Object.entries(statusLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label><span>规则</span><select value={draft.ruleId} onChange={(e) => setDraft({ ...draft, ruleId: e.target.value })}><option value="">全部</option>{rules.map((rule) => <option key={rule.id} value={rule.id}>{rule.name}</option>)}</select></label><label><span>协议</span><select value={draft.protocol} onChange={(e) => setDraft({ ...draft, protocol: e.target.value })}><option value="">全部</option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span>起始时间</span><input type="datetime-local" value={draft.dateFrom} onChange={(e) => setDraft({ ...draft, dateFrom: e.target.value })} /></label><label><span>结束时间</span><input type="datetime-local" value={draft.dateTo} onChange={(e) => setDraft({ ...draft, dateTo: e.target.value })} /></label><button className="v2-primary-button">查询</button><button className="v2-secondary-button" type="button" onClick={() => { setDraft(EMPTY_FILTERS); setFilters(EMPTY_FILTERS); setOffset(0); }}>重置</button></form>
|
||||
{summary.isError ? <InlineError message={summary.error instanceof Error ? summary.error.message : '告警汇总读取失败'} onRetry={() => summary.refetch()} /> : null}
|
||||
<section className="v2-alert-kpis">{[['活跃告警', sums?.active, '', ''], ['未处理', sums?.unprocessed, 'unprocessed', 'unprocessed'], ['处理中', sums?.processing, 'processing', 'processing'], ['已恢复', sums?.recovered, 'recovered', 'recovered'], ['已关闭', sums?.closed, 'closed', 'closed'], ['已忽略', sums?.ignored, 'ignored', 'ignored'], ['未读通知', unread, 'notice', 'notice']].map(([label, value, tone, status]) => <button key={String(label)} type="button" className={`is-${tone}`} onClick={() => status === 'notice' ? onTab('notifications') : quickStatus(String(status))}><small>{label as string}</small><strong>{Number(value ?? 0).toLocaleString('zh-CN')}</strong></button>)}</section>
|
||||
{events.isError ? <InlineError message={events.error instanceof Error ? events.error.message : '告警事件读取失败'} onRetry={() => events.refetch()} /> : null}
|
||||
<div className="v2-alert-workspace"><section className="v2-alert-table-card"><header><strong>告警事件</strong><div><span>共 {(events.data?.total ?? 0).toLocaleString('zh-CN')} 条</span><button onClick={() => Promise.all([events.refetch(), summary.refetch(), detail.refetch()])}><IconRefresh />刷新</button></div></header><div className="v2-alert-table-scroll"><table><thead><tr><th /><th>严重程度</th><th>车牌 / VIN</th><th>规则</th><th>协议</th><th>触发时间</th><th>恢复时间</th><th>状态</th><th>触发值</th><th>阈值</th><th>位置</th><th>处理人</th></tr></thead><tbody><AlertRows rows={rows} selectedID={selectedID} onSelect={setSelectedID} /></tbody></table>{events.isFetching ? <div className="v2-alert-loading"><i />正在更新事件…</div> : null}{!events.isFetching && !rows.length ? <div className="v2-alert-empty">当前筛选条件没有告警事件</div> : null}</div><footer><span>第 {page} / {totalPages} 页</span><div><button disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}>下一页</button><select value={limit} onChange={(e) => { setLimit(Number(e.target.value)); setOffset(0); }}><option value="20">20 条/页</option><option value="50">50 条/页</option></select></div></footer></section><EventInspector event={detail.data ?? rows.find((row) => row.id === selectedID)} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} /></div></>;
|
||||
}
|
||||
|
||||
function emptyRule(): AlertRuleInput { return { id: '', name: '', description: '', severity: 'major', valueType: 'numeric', metric: 'speed_kmh', operator: 'gt', threshold: 80, thresholdHigh: 100, durationSec: 60, recoveryOperator: 'lte', recoveryThreshold: 75, repeatIntervalSec: 600, scopeProtocols: [], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [], notificationChannels: ['in_app'], enabled: true, version: 0 }; }
|
||||
function ruleDraft(rule: AlertRule): AlertRuleInput {
|
||||
return {
|
||||
id: rule.id, name: rule.name, description: rule.description, severity: rule.severity, valueType: rule.valueType,
|
||||
metric: rule.metric, operator: rule.operator, threshold: rule.threshold, thresholdHigh: rule.thresholdHigh, booleanThreshold: rule.booleanThreshold,
|
||||
durationSec: rule.durationSec, recoveryOperator: rule.recoveryOperator, recoveryThreshold: rule.recoveryThreshold,
|
||||
repeatIntervalSec: rule.repeatIntervalSec, scopeProtocols: [...(rule.scopeProtocols ?? [])], scopeVins: [...(rule.scopeVins ?? [])], scopeOems: [...(rule.scopeOems ?? [])], scopeModels: [...(rule.scopeModels ?? [])], scopeCompanies: [...(rule.scopeCompanies ?? [])],
|
||||
notificationChannels: [...(rule.notificationChannels ?? ['in_app'])], enabled: rule.enabled, version: rule.version
|
||||
};
|
||||
}
|
||||
|
||||
function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: MetricDefinition[] }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedID, setSelectedID] = useState('');
|
||||
const [draft, setDraft] = useState<AlertRuleInput>(emptyRule());
|
||||
useEffect(() => { if (!selectedID && rules[0]) { setSelectedID(rules[0].id); setDraft(ruleDraft(rules[0])); } }, [rules, selectedID]);
|
||||
const save = useMutation({ mutationFn: api.saveAlertRuleV2, onSuccess: async (rule) => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); await queryClient.invalidateQueries({ queryKey: ['alert-rules-v2'] }); } });
|
||||
const toggle = useMutation({ mutationFn: (rule: AlertRule) => api.setAlertRuleEnabledV2(rule.id, { version: rule.version, enabled: !rule.enabled }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['alert-rules-v2'] }); } });
|
||||
const operators = draft.valueType === 'boolean' ? BOOLEAN_OPERATORS : NUMERIC_OPERATORS;
|
||||
const availableMetrics = metrics.filter((metric) => metric.alertable && metric.valueType === draft.valueType);
|
||||
const catalogLabels = Object.fromEntries(metrics.map((metric) => [metric.key, metric.label]));
|
||||
const setList = (key: 'scopeProtocols' | 'scopeVins' | 'scopeOems' | 'scopeModels' | 'scopeCompanies', value: string) => setDraft({ ...draft, [key]: value.split(',').map((item) => item.trim()).filter(Boolean) });
|
||||
return <div className="v2-alert-rules">
|
||||
<section className="v2-alert-rule-list"><header><strong>规则配置</strong><button onClick={() => { setSelectedID('__new__'); setDraft(emptyRule()); }}>+ 新建规则</button></header>{rules.map((rule) => <button className={selectedID === rule.id ? 'is-selected' : ''} key={rule.id} onClick={() => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); }}><i className={`is-${rule.severity}`} /><span><strong>{rule.name}</strong><small>{ruleCondition(rule, catalogLabels)} · v{rule.version}</small></span><em className={rule.enabled ? 'is-enabled' : ''}>{rule.enabled ? '已启用' : '已停用'}</em></button>)}</section>
|
||||
<form className="v2-alert-rule-editor" onSubmit={(event) => { event.preventDefault(); save.mutate(draft); }}>
|
||||
<header><div><strong>{draft.version ? '编辑规则' : '新建规则'}</strong><span>数值、状态与主数据范围均纳入版本审计</span></div>{draft.version ? <button type="button" onClick={() => { const current = rules.find((item) => item.id === draft.id); if (current) toggle.mutate(current); }}>{draft.enabled ? '停用规则' : '启用规则'}</button> : null}</header>
|
||||
<div className="v2-rule-form-grid">
|
||||
<label><span>规则名称</span><input required maxLength={80} value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} /></label>
|
||||
<label><span>严重程度</span><select value={draft.severity} onChange={(e) => setDraft({ ...draft, severity: e.target.value as AlertRuleInput['severity'] })}><option value="critical">紧急</option><option value="major">重要</option><option value="minor">一般</option></select></label>
|
||||
<label><span>值类型</span><select value={draft.valueType} onChange={(e) => { const valueType = e.target.value as AlertRuleInput['valueType']; const metric = metrics.find((item) => item.alertable && item.valueType === valueType)?.key ?? ''; setDraft({ ...draft, valueType, operator: valueType === 'boolean' ? 'eq' : 'gt', metric }); }}><option value="numeric">数值</option><option value="boolean">布尔</option></select></label>
|
||||
<label><span>指标</span><select required disabled={!availableMetrics.length} value={draft.metric} onChange={(e) => setDraft({ ...draft, metric: e.target.value })}>{availableMetrics.map((metric) => <option key={metric.key} value={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</option>)}</select></label>
|
||||
<label><span>触发比较符</span><select value={draft.operator} onChange={(e) => setDraft({ ...draft, operator: e.target.value, durationSec: e.target.value === 'changed' ? 0 : draft.durationSec })}>{operators.map((value) => <option key={value} value={value}>{operatorLabels[value]}</option>)}</select></label>
|
||||
{draft.operator === 'changed' ? <label><span>变化语义</span><input value="false ↔ true" disabled /></label> : draft.valueType === 'boolean' ? <label><span>目标值</span><select value={draft.booleanThreshold ? 'true' : 'false'} onChange={(e) => setDraft({ ...draft, booleanThreshold: e.target.value === 'true' })}><option value="true">是</option><option value="false">否</option></select></label> : <label><span>{draft.operator === 'between' || draft.operator === 'outside' ? '区间下限' : '触发阈值'}</span><input type="number" step="0.1" value={draft.threshold} onChange={(e) => setDraft({ ...draft, threshold: Number(e.target.value) })} /></label>}
|
||||
{draft.operator === 'between' || draft.operator === 'outside' ? <label><span>区间上限</span><input type="number" step="0.1" value={draft.thresholdHigh} onChange={(e) => setDraft({ ...draft, thresholdHigh: Number(e.target.value) })} /></label> : null}
|
||||
<label><span>持续时间(秒)</span><input type="number" min="0" max="86400" disabled={draft.operator === 'changed'} value={draft.durationSec} onChange={(e) => setDraft({ ...draft, durationSec: Number(e.target.value) })} /></label>
|
||||
<label><span>恢复比较符</span><select value={draft.recoveryOperator} onChange={(e) => setDraft({ ...draft, recoveryOperator: e.target.value })}><option value="">未配置</option>{['gt', 'gte', 'lt', 'lte', 'eq', 'neq'].map((value) => <option key={value} value={value}>{operatorLabels[value]}</option>)}</select></label>
|
||||
<label><span>恢复阈值</span><input type="number" step="0.1" value={draft.recoveryThreshold} onChange={(e) => setDraft({ ...draft, recoveryThreshold: Number(e.target.value) })} /></label>
|
||||
<label><span>重复间隔(秒)</span><input type="number" min="0" max="604800" value={draft.repeatIntervalSec} onChange={(e) => setDraft({ ...draft, repeatIntervalSec: Number(e.target.value) })} /></label>
|
||||
<label className="is-wide"><span>协议范围(逗号分隔;空为全部)</span><input value={draft.scopeProtocols.join(',')} onChange={(e) => setList('scopeProtocols', e.target.value)} /></label>
|
||||
<label className="is-wide"><span>车辆 VIN 范围(逗号分隔;空为全部)</span><input value={draft.scopeVins.join(',')} onChange={(e) => setList('scopeVins', e.target.value)} /></label>
|
||||
<label className="is-wide"><span>厂家范围(逗号分隔;空为全部)</span><input value={draft.scopeOems.join(',')} onChange={(e) => setList('scopeOems', e.target.value)} /></label>
|
||||
<label className="is-wide"><span>车型范围(来自车辆主档;空为全部)</span><input value={draft.scopeModels.join(',')} onChange={(e) => setList('scopeModels', e.target.value)} /></label>
|
||||
<label className="is-wide"><span>企业范围(来自车辆主档;空为全部)</span><input value={draft.scopeCompanies.join(',')} onChange={(e) => setList('scopeCompanies', e.target.value)} /></label>
|
||||
<label className="is-wide"><span>说明</span><textarea maxLength={500} value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} /></label>
|
||||
</div>
|
||||
<footer><div><b>通知通道</b><span>站内通知已启用;短信、邮件、企微为预留 / 未启用。</span></div>{save.error ? <em>{save.error.message}</em> : null}<button className="v2-primary-button" disabled={save.isPending}>{save.isPending ? '保存中' : '保存规则'}</button></footer>
|
||||
</form>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function NotificationsWorkspace({ editable }: { editable: boolean }) {
|
||||
const queryClient = useQueryClient(); const notifications = useQuery({ queryKey: ['alert-notifications-v2', 'all'], queryFn: () => api.alertNotificationsV2(new URLSearchParams({ limit: '100' })), staleTime: 5_000 });
|
||||
const read = useMutation({ mutationFn: api.readAlertNotificationsV2, onSuccess: async () => { await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] })]); } });
|
||||
return <div className="v2-alert-notifications"><header><div><strong>站内通知</strong><span>仅站内通道具备真实送达与已读状态</span></div>{editable ? <button disabled={!notifications.data?.items.some((item) => !item.read)} onClick={() => read.mutate(notifications.data?.items.filter((item) => !item.read).map((item) => item.id) ?? [])}>全部标为已读</button> : <span className="v2-role-badge">只读</span>}</header>{notifications.isError ? <InlineError message={notifications.error.message} onRetry={() => notifications.refetch()} /> : null}<div>{notifications.data?.items.map((item) => <article className={item.read ? 'is-read' : ''} key={item.id}><i className={`is-${item.severity}`} /><div><strong>{item.title}</strong><p>{item.content}</p><span>{formatAlertTime(item.createdAt)} · {item.read ? '已读' : '未读'}</span></div>{editable && !item.read ? <button onClick={() => read.mutate([item.id])}>标为已读</button> : null}</article>)}</div><footer><b>外部通知通道</b><span>短信(SMS)— 预留 / 未启用</span><span>邮件(Email)— 预留 / 未启用</span><span>企业微信(WeCom)— 预留 / 未启用</span></footer></div>;
|
||||
}
|
||||
|
||||
export default function AlertsPage() {
|
||||
const { session } = usePlatformSession(); const operator = canOperate(session); const admin = canAdminister(session);
|
||||
const [params, setParams] = useSearchParams(); const initialTab = (params.get('tab') as Tab) || 'events'; const [tab, setTabState] = useState<Tab>(['events', 'rules', 'notifications'].includes(initialTab) ? initialTab : 'events');
|
||||
const initialFilters: Filters = { ...EMPTY_FILTERS, keyword: params.get('vin') ?? params.get('keyword') ?? '', severity: params.get('severity') ?? '', status: params.get('status') ?? '', ruleId: params.get('ruleId') ?? '', protocol: params.get('protocol') ?? '' };
|
||||
const [filters, setFilterState] = useState(initialFilters); const rules = useQuery({ queryKey: ['alert-rules-v2'], queryFn: api.alertRulesV2, staleTime: 30_000 }); const metrics = useQuery({ queryKey: ['metric-catalog-v2'], queryFn: api.metricCatalog, staleTime: 300_000, enabled: admin }); const notices = useQuery({ queryKey: ['alert-notifications-v2', 'unread'], queryFn: () => api.alertNotificationsV2(new URLSearchParams({ unreadOnly: 'true', limit: '100' })), staleTime: 5_000 });
|
||||
const setTab = (next: Tab) => { setTabState(next); const copy = new URLSearchParams(params); copy.set('tab', next); setParams(copy, { replace: true }); };
|
||||
const setFilters = (next: Filters) => { setFilterState(next); const copy = new URLSearchParams(); if (tab !== 'events') copy.set('tab', tab); Object.entries(next).forEach(([key, value]) => { if (value) copy.set(key, value); }); setParams(copy, { replace: true }); };
|
||||
const activeTab = tab === 'rules' && !admin ? 'events' : tab;
|
||||
return <div className="v2-alert-page"><header className="v2-alert-heading"><div><h2>告警中心</h2><p>统一监控告警事件,快速发现并处置车辆运行异常,保留数据质量与处置证据。</p></div></header><nav className="v2-alert-tabs"><button className={activeTab === 'events' ? 'is-active' : ''} onClick={() => setTab('events')}><IconAlarm />告警事件</button>{admin ? <button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setTab('rules')}>规则配置</button> : null}<button className={activeTab === 'notifications' ? 'is-active' : ''} onClick={() => setTab('notifications')}><IconBell />站内通知{(notices.data?.total ?? 0) > 0 ? <b>{notices.data?.total}</b> : null}</button></nav>{rules.isError ? <InlineError message={rules.error.message} onRetry={() => rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? <InlineError message={metrics.error.message} onRetry={() => metrics.refetch()} /> : null}{activeTab === 'events' ? <EventWorkspace filters={filters} setFilters={setFilters} rules={rules.data ?? []} unread={notices.data?.total ?? 0} editable={operator} onTab={setTab} /> : activeTab === 'rules' ? <RulesWorkspace rules={rules.data ?? []} metrics={metrics.data?.metrics ?? []} /> : <NotificationsWorkspace editable={operator} />}</div>;
|
||||
}
|
||||
132
vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx
Normal file
132
vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types';
|
||||
import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, parseHistoryKeywords } from '../domain/history';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
|
||||
function HistoryTrend({ response, category, loading, error }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string }) {
|
||||
const panels = useMemo(() => buildHistorySeriesPanels(response), [response]);
|
||||
if (category !== 'location') return <section className="v2-history-trend"><header><strong>聚合趋势</strong></header><div className="v2-history-chart-empty">{category === 'raw' ? '原始报文是离散证据,不生成可能误导的连续趋势;请使用明细与导出。' : '日里程按自然日展示,当前请使用明细表核对起止里程。'}</div></section>;
|
||||
const summary = response?.summary;
|
||||
const coverage = summary?.expectedBucketCount ? Math.max(0, (summary.expectedBucketCount - summary.missingBucketCount) / summary.expectedBucketCount * 100) : 0;
|
||||
return <section className="v2-history-trend"><header><strong>聚合趋势</strong><div>{summary ? <><span>{formatSeriesGrain(summary.grainSeconds)}粒度</span><span>覆盖 {coverage.toFixed(1)}%</span><span>{summary.rawPointCount.toLocaleString('zh-CN')} 原始点</span></> : null}</div></header>
|
||||
{error ? <div className="v2-history-chart-empty">趋势加载失败:{error}</div> : loading && !response ? <div className="v2-history-chart-empty">正在聚合时间序列…</div> : panels.length ? <div className="v2-history-trend-panels">{panels.map((panel) => <article key={panel.key}><header><strong>{panel.label}</strong><span>{panel.unit || '数值'} · {panel.lines.reduce((sum, line) => sum + line.points, 0)} 个桶点</span></header><svg viewBox="0 0 800 116" role="img" aria-label={`${panel.label}按时间变化趋势`}>
|
||||
<g className="v2-chart-grid"><line x1="54" y1="10" x2="786" y2="10" /><line x1="54" y1="53" x2="786" y2="53" /><line x1="54" y1="96" x2="786" y2="96" /></g>
|
||||
<g className="v2-chart-axis"><text x="49" y="14">{panel.maximum.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</text><text x="49" y="100">{panel.minimum.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</text><text x="54" y="112">{formatAxisTime(panel.start)}</text><text x="786" y="112" textAnchor="end">{formatAxisTime(panel.end)}</text></g>
|
||||
{panel.lines.flatMap((line) => line.paths.map((path, index) => <path key={`${line.key}-${index}`} d={path} fill="none" stroke={line.color} strokeWidth="2" vectorEffect="non-scaling-stroke"><title>{line.label}</title></path>))}
|
||||
</svg><footer>{panel.lines.map((line) => <span key={line.key}><i style={{ background: line.color }} />{line.label}</span>)}</footer></article>)}</div> : <div className="v2-history-chart-empty">当前时间窗没有可聚合的数值点;空窗未补值。</div>}
|
||||
{summary ? <small className="v2-history-trend-evidence">{summary.evidence} · 缺失 {summary.missingBucketCount.toLocaleString('zh-CN')} / {summary.expectedBucketCount.toLocaleString('zh-CN')} 桶 · 查询 {summary.queryDurationMs} ms</small> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function formatAxisTime(value: string) {
|
||||
const parsed = new Date(value);
|
||||
if (!Number.isFinite(parsed.getTime())) return value.replace('T', ' ').slice(5, 16);
|
||||
return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(parsed).replace('/', '-');
|
||||
}
|
||||
function currentHistoryWindow() {
|
||||
const now = new Date(); const pad = (value: number) => String(value).padStart(2, '0');
|
||||
const day = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
return { dateFrom: `${day}T00:00`, dateTo: `${day}T${pad(now.getHours())}:${pad(now.getMinutes())}` };
|
||||
}
|
||||
|
||||
function CreateExportButton({ request, disabled }: { request: HistoryExportRequest; disabled: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({ mutationFn: api.createHistoryExport, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['history-exports'] }) });
|
||||
const label = mutation.isPending ? '任务排队中' : mutation.isError ? '导出失败,重试' : '创建导出';
|
||||
return <button className="v2-secondary-button" type="button" disabled={disabled || mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '最多 100 万行;任务按创建顺序单并发流式执行'} onClick={() => mutation.mutate(request)}><IconDownload />{label}</button>;
|
||||
}
|
||||
|
||||
function ExportJobsPanel() {
|
||||
const query = useQuery({ queryKey: ['history-exports'], queryFn: api.historyExports, refetchInterval: (current) => current.state.data?.some((job) => job.status === 'queued' || job.status === 'running') ? 1000 : false });
|
||||
const jobs = query.data ?? [];
|
||||
return <section className="v2-export-jobs"><header><strong>导出任务</strong><span>{jobs.length}</span></header><div>{jobs.slice(0, 6).map((job) => <article key={job.id} title={job.evidence}><i className={`is-${job.status}`} /><div><strong>{job.name}</strong><small>{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')} 行 · ${job.progress}%` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)} · 已完成` : job.error || '失败'}</small></div>{job.downloadUrl ? <a href={job.downloadUrl}><IconDownload />下载</a> : <em>{job.status === 'running' ? `${job.progress}%` : '—'}</em>}</article>)}{query.isError ? <div className="v2-history-side-empty">导出任务加载失败</div> : !jobs.length ? <div className="v2-history-side-empty">尚未创建导出任务</div> : null}</div></section>;
|
||||
}
|
||||
|
||||
function EvidencePanel({ row, metrics, onClose }: { row?: HistoryDataRow; metrics: HistoryMetricDefinition[]; onClose: () => void }) {
|
||||
return <section className="v2-history-evidence"><header><strong>行证据</strong>{row ? <button onClick={onClose} type="button" aria-label="关闭行证据"><IconClose /></button> : null}</header>
|
||||
{row ? <><dl><div><dt>设备时间</dt><dd>{row.deviceTime}</dd></div><div><dt>服务时间</dt><dd>{row.serverTime}</dd></div><div><dt>车牌</dt><dd>{row.plate || '—'}</dd></div><div><dt>VIN</dt><dd>{row.vin}</dd></div><div><dt>数据来源</dt><dd>{row.protocol}</dd></div><div><dt>数据质量</dt><dd><i className={`is-${row.quality}`} />{row.quality === 'normal' ? '正常' : row.quality}</dd></div></dl><div className="v2-evidence-values"><strong>解析字段</strong>{metrics.slice(0, 12).map((metric) => <div key={metric.key}><span>{metric.label}<small>{metric.key}</small></span><b>{formatHistoryValue(row.values[metric.key], metric)}</b></div>)}</div><footer><span>RAW 证据</span><b>{row.evidenceId || '该数据类型没有独立 RAW 帧 ID'}</b></footer></> : <div className="v2-history-side-empty">选择一行查看来源、时间和解析字段证据。</div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export default function HistoryPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const today = useMemo(currentHistoryWindow, []);
|
||||
const initial = { keywords: searchParams.get('vin') || searchParams.get('keywords') || '', dateFrom: searchParams.get('dateFrom') || today.dateFrom, dateTo: searchParams.get('dateTo') || today.dateTo, category: searchParams.get('category') || 'location', protocol: searchParams.get('protocol') || '' };
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const [criteria, setCriteria] = useState(initial);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [visibleByCategory, setVisibleByCategory] = useState<Record<string, string[]>>({});
|
||||
const [selectedRow, setSelectedRow] = useState<HistoryDataRow>();
|
||||
const [density, setDensity] = useState<'compact' | 'comfortable'>('compact');
|
||||
const keywords = useMemo(() => parseHistoryKeywords(criteria.keywords), [criteria.keywords]);
|
||||
const params = useMemo(() => {
|
||||
const next = new URLSearchParams({ keywords: keywords.join(','), category: criteria.category, limit: String(limit), offset: String(offset) });
|
||||
if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom);
|
||||
if (criteria.dateTo) next.set('dateTo', criteria.dateTo);
|
||||
if (criteria.protocol) next.set('protocol', criteria.protocol);
|
||||
return next;
|
||||
}, [criteria, keywords, limit, offset]);
|
||||
const seriesParams = useMemo(() => {
|
||||
const next = new URLSearchParams({ keywords: keywords.join(','), category: criteria.category, metrics: 'speedKmh,totalMileageKm', targetPoints: '240' });
|
||||
if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom);
|
||||
if (criteria.dateTo) next.set('dateTo', criteria.dateTo);
|
||||
if (criteria.protocol) next.set('protocol', criteria.protocol);
|
||||
return next;
|
||||
}, [criteria, keywords]);
|
||||
const catalogQuery = useQuery({ queryKey: ['history-metric-catalog'], queryFn: api.historyMetricCatalog, staleTime: 30 * 60_000 });
|
||||
const dataQuery = useQuery({ queryKey: ['history-data', params.toString()], enabled: keywords.length > 0, queryFn: () => api.historyData(params), placeholderData: (previous) => previous });
|
||||
const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location', queryFn: () => api.historySeries(seriesParams), placeholderData: (previous) => previous });
|
||||
const result = dataQuery.data;
|
||||
const allMetrics = result?.columns ?? catalogQuery.data?.metrics.filter((metric) => metric.category === criteria.category) ?? [];
|
||||
const visibleKeys = visibleByCategory[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key);
|
||||
const visibleMetrics = allMetrics.filter((metric) => visibleKeys.includes(metric.key));
|
||||
|
||||
useEffect(() => { setSelectedRow(result?.rows[0]); }, [result?.asOf]);
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const parsed = parseHistoryKeywords(draft.keywords);
|
||||
if (!parsed.length) return;
|
||||
const next = { ...draft, keywords: parsed.join(',') };
|
||||
setCriteria(next); setOffset(0);
|
||||
const url = new URLSearchParams({ keywords: next.keywords, category: next.category });
|
||||
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 reset = () => { const next = { keywords: '', ...currentHistoryWindow(), category: 'location', protocol: '' }; setDraft(next); setCriteria(next); setOffset(0); setSearchParams({}, { replace: true }); };
|
||||
const toggleMetric = (key: string) => setVisibleByCategory((current) => {
|
||||
const baseline = current[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key);
|
||||
const next = baseline.includes(key) ? baseline.filter((item) => item !== key) : [...baseline, key];
|
||||
return { ...current, [criteria.category]: next };
|
||||
});
|
||||
const totalPages = Math.max(1, Math.ceil((result?.total ?? 0) / limit));
|
||||
const page = Math.floor(offset / limit) + 1;
|
||||
|
||||
return <div className="v2-history-page">
|
||||
<form className="v2-history-toolbar" onSubmit={submit}>
|
||||
<label className="v2-history-vehicles"><span>车辆(最多 5 台)</span><div><IconSearch /><input value={draft.keywords} onChange={(event) => setDraft((value) => ({ ...value, keywords: 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.category} onChange={(event) => setDraft((value) => ({ ...value, category: event.target.value }))}>{(catalogQuery.data?.categories ?? [{ key: 'location', label: '位置数据' }, { key: 'raw', label: '原始报文' }, { key: 'mileage', label: '日里程' }]).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></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={!parseHistoryKeywords(draft.keywords).length}>查询</button><button className="v2-secondary-button" type="button" onClick={reset}>重置</button><CreateExportButton disabled={!result?.rows.length} request={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} />
|
||||
</form>
|
||||
<div className="v2-history-metrics"><strong>指标字段</strong>{allMetrics.map((metric) => <button type="button" className={visibleKeys.includes(metric.key) ? 'is-active' : ''} onClick={() => toggleMetric(metric.key)} key={metric.key}><i />{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</button>)}{!allMetrics.length ? <span>查询后加载可用指标</span> : null}</div>
|
||||
{dataQuery.isError ? <InlineError message={dataQuery.error instanceof Error ? dataQuery.error.message : '历史查询失败'} onRetry={() => dataQuery.refetch()} /> : null}
|
||||
<div className="v2-history-workspace">
|
||||
<div className="v2-history-main">
|
||||
<div className="v2-history-summary"><div><small>结果行数</small><strong>{result?.total.toLocaleString('zh-CN') ?? 0}</strong></div><div><small>车辆数</small><strong>{result?.summary.vehicleCount ?? 0}</strong></div><div><small>数据源</small><strong>{result?.summary.sources.join('、') || '—'}</strong></div><div><small>查询耗时</small><strong>{result ? `${result.summary.queryDurationMs} ms` : '—'}</strong></div></div>
|
||||
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} />
|
||||
<section className={`v2-history-table-card is-${density}`}><header><strong>数据明细</strong><div><button type="button"><IconSetting />列设置</button><select value={density} onChange={(event) => setDensity(event.target.value as typeof density)}><option value="compact">紧凑</option><option value="comfortable">舒适</option></select><button type="button" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据"><IconRefresh /></button></div></header><div className="v2-history-table-scroll"><table><thead><tr><th aria-label="选择行" /><th>设备时间</th><th>服务时间</th><th>车牌</th><th>VIN</th><th>协议</th>{visibleMetrics.map((metric) => <th key={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</th>)}<th>质量</th><th>操作</th></tr></thead><tbody>{result?.rows.map((row) => <tr className={selectedRow?.id === row.id ? 'is-selected' : ''} key={row.id}><td><input type="checkbox" checked={selectedRow?.id === row.id} onChange={() => setSelectedRow(selectedRow?.id === row.id ? undefined : row)} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} /></td><td>{row.deviceTime}</td><td>{row.serverTime}</td><td>{row.plate || '—'}</td><td title={row.vin}>{row.vin}</td><td>{row.protocol}</td>{visibleMetrics.map((metric) => <td key={metric.key}>{formatHistoryValue(row.values[metric.key], metric)}</td>)}<td><span className={`v2-quality is-${row.quality}`}><i />{row.quality === 'normal' ? '正常' : row.quality}</span></td><td><button type="button" onClick={() => setSelectedRow(row)}>查看证据</button></td></tr>)}</tbody></table>{!result?.rows.length ? <div className="v2-history-empty">{keywords.length ? '当前条件没有历史记录' : '输入车辆并查询历史数据'}</div> : null}</div><footer><span>第 {page} / {totalPages} 页,共 {result?.total ?? 0} 条</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>
|
||||
</div>
|
||||
<aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={allMetrics} onClose={() => setSelectedRow(undefined)} /><ExportJobsPanel /></aside>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import type { VehicleRealtimeRow } from '../../api/types';
|
||||
import MonitorPage from './MonitorPage';
|
||||
|
||||
const vehicles = [{
|
||||
vin: 'LTEST000000000001', plate: '粤A12345', protocols: ['JT808'], primaryProtocol: 'JT808',
|
||||
longitude: 113.26, latitude: 23.13, speedKmh: 42, socPercent: 80, totalMileageKm: 1234,
|
||||
lastSeen: '2026-07-14T01:00:00Z', online: true, sourceCount: 1, onlineSourceCount: 1
|
||||
}, {
|
||||
vin: 'LTEST000000000002', plate: '粤B67890', protocols: ['JT808'], primaryProtocol: 'JT808',
|
||||
longitude: 113.28, latitude: 23.15, speedKmh: 0, socPercent: 72, totalMileageKm: 2234,
|
||||
lastSeen: '2026-07-14T01:00:00Z', online: true, sourceCount: 1, onlineSourceCount: 1
|
||||
}] as VehicleRealtimeRow[];
|
||||
const monitorMap = { clusters: [], points: [], total: 2 };
|
||||
const fleetMapRenderSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../map/FleetMap', () => ({
|
||||
FleetMap: ({ selectedVin, onSelectVin }: { selectedVin?: string; onSelectVin?: (vin: string) => void }) => {
|
||||
fleetMapRenderSpy(selectedVin);
|
||||
return <div data-testid="fleet-map" data-selected-vin={selectedVin ?? ''}>
|
||||
<button type="button" onClick={() => onSelectVin?.('LTEST000000000002')}>选择地图车辆</button>
|
||||
</div>;
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useMonitorData', () => ({
|
||||
MONITOR_REFRESH: { selected: 10_000, fleet: 15_000, summary: 30_000 },
|
||||
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 },
|
||||
map: { data: monitorMap },
|
||||
selectedVehicle: { data: { items: [] } }
|
||||
}),
|
||||
useMonitorVehicleCard: () => ({ detail: {}, activeAlerts: {}, address: {} })
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test('starts without a selection and supports expand, collapse, reselection, and clear', () => {
|
||||
const view = render(<MemoryRouter><MonitorPage /></MemoryRouter>);
|
||||
const workspace = view.container.querySelector('.v2-monitor-workspace')!;
|
||||
const firstVehicle = screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ });
|
||||
const secondVehicle = screen.getByRole('button', { name: /粤B67890 LTEST000000000002/ });
|
||||
|
||||
expect(workspace).not.toHaveClass('is-detail-open');
|
||||
expect(workspace).not.toHaveClass('is-detail-collapsed');
|
||||
expect(firstVehicle).not.toHaveClass('is-selected');
|
||||
expect(screen.queryByRole('button', { name: '取消选择车辆' })).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
|
||||
|
||||
fireEvent.click(firstVehicle);
|
||||
expect(workspace).toHaveClass('is-detail-open');
|
||||
expect(firstVehicle).toHaveClass('is-selected');
|
||||
expect(screen.getByRole('button', { name: '取消选择车辆' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', 'LTEST000000000001');
|
||||
const mapRendersAfterSelection = fleetMapRenderSpy.mock.calls.length;
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起车辆详情' }));
|
||||
expect(workspace).toHaveClass('is-detail-collapsed');
|
||||
expect(firstVehicle).toHaveClass('is-selected');
|
||||
expect(screen.getByRole('button', { name: '展开车辆详情' })).toBeInTheDocument();
|
||||
expect(fleetMapRenderSpy).toHaveBeenCalledTimes(mapRendersAfterSelection);
|
||||
|
||||
fireEvent.click(secondVehicle);
|
||||
expect(workspace).toHaveClass('is-detail-open');
|
||||
expect(secondVehicle).toHaveClass('is-selected');
|
||||
expect(screen.queryByRole('button', { name: '展开车辆详情' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消选择车辆' }));
|
||||
expect(workspace).not.toHaveClass('is-detail-open');
|
||||
expect(workspace).not.toHaveClass('is-detail-collapsed');
|
||||
expect(secondVehicle).not.toHaveClass('is-selected');
|
||||
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择地图车辆' }));
|
||||
expect(workspace).toHaveClass('is-detail-open');
|
||||
expect(secondVehicle).toHaveClass('is-selected');
|
||||
});
|
||||
207
vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
Normal file
207
vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { memo, useCallback, useDeferredValue, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
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';
|
||||
|
||||
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
|
||||
const statuses = ['', 'online', 'offline', 'driving', 'idle'];
|
||||
|
||||
const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { vehicle: VehicleRealtimeRow; selected: boolean; onSelect: (vin: string) => void }) {
|
||||
const status = vehicleStatus(vehicle);
|
||||
return (
|
||||
<button type="button" className={`v2-vehicle-row ${selected ? 'is-selected' : ''}`} onClick={() => onSelect(vehicle.vin)}>
|
||||
<i className={`v2-status-dot is-${status}`} />
|
||||
<span className="v2-vehicle-identity"><strong>{vehicle.plate || '未绑定车牌'}</strong><small>{vehicle.vin}</small></span>
|
||||
<span className="v2-vehicle-motion"><strong>{formatNumber(vehicle.speedKmh, 1)} <small>km/h</small></strong><small>{statusLabel(status)}</small></span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
const MemoFleetMap = memo(FleetMap);
|
||||
|
||||
function VehicleDetailCard({
|
||||
vehicle,
|
||||
detail,
|
||||
activeAlerts,
|
||||
address,
|
||||
onCollapse,
|
||||
onClear
|
||||
}: {
|
||||
vehicle: VehicleRealtimeRow;
|
||||
detail?: VehicleDetailData;
|
||||
activeAlerts?: { items: AlertEvent[]; total: number };
|
||||
address?: MapReverseGeocode;
|
||||
onCollapse: () => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const status = vehicleStatus(vehicle);
|
||||
const dailyMileage = detail?.mileage.items[0]?.dailyMileageKm;
|
||||
const latestAlert = activeAlerts?.items[0];
|
||||
return (
|
||||
<aside className="v2-vehicle-detail">
|
||||
<div className="v2-detail-controls">
|
||||
<button type="button" aria-label="收起车辆详情" title="收起到地图右侧" onClick={onCollapse}><IconChevronRight /></button>
|
||||
<button type="button" aria-label="取消选择车辆" title="取消选择车辆" onClick={onClear}><IconClose /></button>
|
||||
</div>
|
||||
<div className="v2-detail-title">
|
||||
<div><strong>{vehicle.plate || '未绑定车牌'}</strong><span className={`v2-status-text is-${status}`}>{statusLabel(status)}</span></div>
|
||||
<small>{vehicle.vin}</small>
|
||||
</div>
|
||||
<div className="v2-detail-actions">
|
||||
<Link to={`/vehicles/${encodeURIComponent(vehicle.vin)}`}>单车详情</Link>
|
||||
<Link to={`/tracks?vin=${encodeURIComponent(vehicle.vin)}`}>轨迹回放</Link>
|
||||
<Link to={`/history?vin=${encodeURIComponent(vehicle.vin)}`}>历史数据</Link>
|
||||
</div>
|
||||
<section>
|
||||
<h3>车辆信息</h3>
|
||||
<dl className="v2-detail-list">
|
||||
<div><dt>VIN</dt><dd>{vehicle.vin}</dd></div>
|
||||
<div><dt>厂家</dt><dd>{vehicle.oem || '待补充'}</dd></div>
|
||||
<div><dt>协议</dt><dd>{vehicle.primaryProtocol || vehicle.protocols.join('、') || '未知'}</dd></div>
|
||||
<div><dt>数据来源</dt><dd>{detail?.sources.join('、') || vehicle.protocols.join('、') || '未知'}</dd></div>
|
||||
<div><dt>接入供应商</dt><dd>{detail?.profile?.accessProvider || '待补充'}</dd></div>
|
||||
<div><dt>来源覆盖</dt><dd>{vehicle.onlineSourceCount}/{vehicle.sourceCount}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h3>实时状态</h3>
|
||||
<div className="v2-metric-grid">
|
||||
<div><small>速度</small><strong>{formatNumber(vehicle.speedKmh, 1)}<em>km/h</em></strong></div>
|
||||
<div><small>SOC</small><strong>{formatNumber(vehicle.socPercent, 1)}<em>%</em></strong></div>
|
||||
<div><small>总里程</small><strong>{formatNumber(vehicle.totalMileageKm, 1)}<em>km</em></strong></div>
|
||||
<div><small>今日里程</small><strong>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></strong></div>
|
||||
<div><small>状态</small><strong>{statusLabel(status)}</strong></div>
|
||||
<div><small>当前告警</small><strong>{formatNumber(activeAlerts?.total ?? 0)}<em>条</em></strong></div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>最新上报</h3>
|
||||
<dl className="v2-detail-list">
|
||||
<div><dt>时间</dt><dd>{vehicle.lastSeen || '暂无'}</dd></div>
|
||||
<div><dt>新鲜度</dt><dd>{relativeFreshness(vehicle.lastSeen)}</dd></div>
|
||||
<div><dt>坐标</dt><dd>{vehicle.longitude.toFixed(6)}, {vehicle.latitude.toFixed(6)}</dd></div>
|
||||
<div><dt>位置</dt><dd>{address?.formattedAddress || '位置解析中'}</dd></div>
|
||||
<div><dt>告警状态</dt><dd>{latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MonitorPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const deferredKeyword = useDeferredValue(keyword);
|
||||
const [protocol, setProtocol] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [selectedVin, setSelectedVin] = useState('');
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [viewport, setViewport] = useState<MonitorViewport>({ zoom: 5, bounds: '' });
|
||||
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 rows = useMemo(() => {
|
||||
const data = vehicles.data?.items ?? [];
|
||||
if (status === 'driving' || status === 'idle') return data.filter((vehicle) => vehicleStatus(vehicle) === status);
|
||||
return data;
|
||||
}, [status, vehicles.data?.items]);
|
||||
const selected = selectedVin
|
||||
? rows.find((vehicle) => vehicle.vin === selectedVin) ?? selectedVehicle.data?.items[0]
|
||||
: undefined;
|
||||
const selectVehicle = useCallback((vin: string) => {
|
||||
setSelectedVin(vin);
|
||||
setDetailOpen(true);
|
||||
}, []);
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedVin('');
|
||||
setDetailOpen(false);
|
||||
}, []);
|
||||
const selectMapVehicle = useCallback((vehicle: VehicleRealtimeRow) => selectVehicle(vehicle.vin), [selectVehicle]);
|
||||
const collapseDetail = useCallback(() => setDetailOpen(false), []);
|
||||
const expandDetail = useCallback(() => setDetailOpen(true), []);
|
||||
const card = useMonitorVehicleCard(selected?.vin ?? '', selected, Boolean(selectedVin));
|
||||
const driving = rows.filter((vehicle) => vehicleStatus(vehicle) === 'driving').length;
|
||||
const idle = rows.filter((vehicle) => vehicleStatus(vehicle) === 'idle').length;
|
||||
const offline = rows.filter((vehicle) => vehicleStatus(vehicle) === 'offline').length;
|
||||
|
||||
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="协议">
|
||||
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
|
||||
</select>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value)} 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-primary-button"><IconFilter />筛选</button>
|
||||
</section>
|
||||
|
||||
<section className="v2-kpis" aria-label="车辆整体统计">
|
||||
{[
|
||||
['接入车辆', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'],
|
||||
['当前在线', formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), 'online'],
|
||||
['当前离线', formatNumber(summary.data?.offlineVehicles ?? offline), 'offline'],
|
||||
['行驶车辆', formatNumber(summary.data?.drivingVehicles ?? driving), 'driving'],
|
||||
['静止车辆', formatNumber(summary.data?.idleVehicles ?? idle), 'idle'],
|
||||
['告警车辆', summary.data?.alertDataAvailable ? formatNumber(summary.data.alertVehicles) : '—', 'alert'],
|
||||
['今日上报', formatNumber(summary.data?.frameToday ?? 0), 'today']
|
||||
].map(([label, value, tone]) => <div key={label} className={`v2-kpi is-${tone}`}><small>{label}</small><strong>{value}</strong></div>)}
|
||||
</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' : ''}`}>
|
||||
<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-vehicle-scroll">
|
||||
{vehicles.isLoading ? <div className="v2-list-loading"><span className="v2-spinner" />加载车辆</div> : null}
|
||||
{!vehicles.isLoading && rows.length === 0 ? <EmptyState /> : null}
|
||||
{rows.map((vehicle) => <VehicleRow key={vehicle.vin} vehicle={vehicle} selected={vehicle.vin === selectedVin} onSelect={selectVehicle} />)}
|
||||
</div>
|
||||
<footer>当前载入 {rows.length} / {vehicles.data?.total ?? rows.length} 辆</footer>
|
||||
</div>
|
||||
<MemoFleetMap
|
||||
vehicles={rows}
|
||||
monitorMap={map.data}
|
||||
selectedVin={selectedVin || undefined}
|
||||
onSelect={selectMapVehicle}
|
||||
onSelectVin={selectVehicle}
|
||||
onViewportChange={updateViewport}
|
||||
/>
|
||||
{selected && detailOpen ? (
|
||||
<VehicleDetailCard
|
||||
vehicle={selected}
|
||||
detail={card.detail.data}
|
||||
activeAlerts={card.activeAlerts.data}
|
||||
address={card.address.data}
|
||||
onCollapse={collapseDetail}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
) : null}
|
||||
{selected && !detailOpen ? (
|
||||
<aside className="v2-detail-peek" aria-label="已收起的车辆详情">
|
||||
<button type="button" aria-label="展开车辆详情" title={`展开 ${selected.plate || selected.vin} 的车辆详情`} onClick={expandDetail}>
|
||||
<IconChevronLeft />
|
||||
<i className={`v2-status-dot is-${vehicleStatus(selected)}`} />
|
||||
<span>{selected.plate || '未绑定车牌'}</span>
|
||||
</button>
|
||||
</aside>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IconRefresh } from '@douyinfe/semi-icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../../api/client';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status;
|
||||
}
|
||||
|
||||
export default function OperationsPage() {
|
||||
const health = useQuery({ queryKey: ['ops-health-v2'], queryFn: api.opsHealth, refetchInterval: 15_000, staleTime: 8_000 });
|
||||
const readiness = useQuery({ queryKey: ['ops-source-readiness-v2'], queryFn: api.sourceReadiness, refetchInterval: 30_000, staleTime: 15_000 });
|
||||
const data = health.data; const sources = readiness.data;
|
||||
const refresh = () => Promise.all([health.refetch(), readiness.refetch()]);
|
||||
return <div className="v2-ops-page">
|
||||
<header className="v2-ops-heading"><div><h2>运维质量</h2><p>所有状态均来自当前 ECS 健康接口和生产投影,不用前端估算。</p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh />刷新证据</button></header>
|
||||
{health.isError ? <InlineError message={health.error.message} onRetry={refresh} /> : null}
|
||||
<section className="v2-ops-kpis">
|
||||
<article><small>运行版本</small><strong>{data?.runtime.platformRelease || '未注入'}</strong><span className={data?.runtime.dataMode === 'production' ? 'is-ok' : 'is-error'}>{data?.runtime.dataMode || 'unknown'}</span></article>
|
||||
<article><small>活跃连接</small><strong>{data?.activeConnections?.toLocaleString('zh-CN') ?? '—'}</strong><span>capacity-check</span></article>
|
||||
<article><small>Kafka Lag</small><strong>{data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'}</strong><span className={data?.kafkaLag === 0 ? 'is-ok' : 'is-warning'}>{data?.kafkaLag === 0 ? '已回零' : '需检查'}</span></article>
|
||||
<article><small>Redis 在线 Key</small><strong>{data?.redisOnlineKeys?.toLocaleString('zh-CN') ?? '—'}</strong><span>实时探针</span></article>
|
||||
<article><small>车辆 / 在线</small><strong>{sources ? `${sources.onlineVehicles} / ${sources.totalVehicles}` : '—'}</strong><span>统一车辆视角</span></article>
|
||||
</section>
|
||||
<div className="v2-ops-grid"><section className="v2-ops-links"><header><strong>数据链路</strong><span>15 秒自动刷新</span></header><div>{data?.linkHealth.map((item) => <article key={item.name}><i className={`is-${item.status}`} /><div><strong>{item.name}</strong><p>{item.detail || '无补充信息'}</p></div><span className={`is-${item.status}`}>{statusLabel(item.status)}</span></article>)}</div></section>
|
||||
<section className="v2-ops-runtime"><header><strong>运行时安全</strong></header><dl><div><dt>生产数据模式</dt><dd>{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'}</dd></div><div><dt>MySQL 写探针</dt><dd className={data?.mysqlWritable ? 'is-ok' : 'is-error'}>{data?.mysqlWritable ? '正常' : '异常'}</dd></div><div><dt>TDengine 写探针</dt><dd className={data?.tdengineWritable ? 'is-ok' : 'is-error'}>{data?.tdengineWritable ? '正常' : '异常'}</dd></div><div><dt>请求超时</dt><dd>{data?.runtime.requestTimeoutMs ?? '—'} ms</dd></div><div><dt>高德安全代理</dt><dd className={data?.runtime.amapSecurityProxyEnabled && !data?.runtime.amapSecurityCodeExposed ? 'is-ok' : 'is-warning'}>{data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'}</dd></div></dl>{data?.capacityFindings?.length ? <div className="v2-ops-findings">{data.capacityFindings.map((item) => <p key={item}>{item}</p>)}</div> : <p className="v2-ops-clear">容量检查无待处理项</p>}</section></div>
|
||||
<section className="v2-ops-sources"><header><strong>协议来源就绪度</strong><span>验收口径与处置建议</span></header><div>{sources?.sources.map((source) => <article key={source.protocol}><div><i className={`is-${source.severity}`} /><strong>{source.protocol}</strong><span>{source.role}</span></div><b>{source.online} / {source.total} 在线</b><p>{source.evidence}</p><p>{source.action}</p><em>{source.acceptance}</em></article>)}</div></section>
|
||||
</div>;
|
||||
}
|
||||
191
vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx
Normal file
191
vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IconBox, IconChevronLeft, IconChevronRight, IconDownload, IconPause, IconPlay, IconSearch
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { TrackPlaybackEvent, TrackPlaybackResponse } 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;
|
||||
|
||||
function number(value: number, digits = 1) {
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: digits }).format(Number.isFinite(value) ? value : 0);
|
||||
}
|
||||
|
||||
function direction(value?: number) {
|
||||
if (value === undefined || !Number.isFinite(value)) return '方向 —';
|
||||
const normalized = ((value % 360) + 360) % 360;
|
||||
const names = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];
|
||||
return `${number(normalized, 0)}° ${names[Math.round(normalized / 45) % names.length]}`;
|
||||
}
|
||||
|
||||
function alarm(value?: number) {
|
||||
if (value === undefined) return '报警 —';
|
||||
return value === 0 ? '无报警' : `报警 0x${Math.trunc(value).toString(16).toUpperCase().padStart(8, '0')}`;
|
||||
}
|
||||
|
||||
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) || '—';
|
||||
}
|
||||
|
||||
function localDateTime(value: Date) {
|
||||
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function defaultTrackWindow() {
|
||||
const now = new Date();
|
||||
const start = new Date(now);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return { dateFrom: localDateTime(start), dateTo: localDateTime(now) };
|
||||
}
|
||||
|
||||
function eventTone(type: string) {
|
||||
if (type === 'start') return 'start';
|
||||
if (type === 'end' || type === 'braking' || type === 'gap') return 'end';
|
||||
if (type === 'acceleration' || type === 'stop') return 'warning';
|
||||
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 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>
|
||||
</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>
|
||||
</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>
|
||||
</aside>;
|
||||
}
|
||||
|
||||
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 [activeIndex, setActiveIndex] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState<(typeof speedOptions)[number]>(1);
|
||||
|
||||
const params = useMemo(() => {
|
||||
const next = new URLSearchParams({ keyword: criteria.keyword, maxPoints: '1200' });
|
||||
if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom);
|
||||
if (criteria.dateTo) next.set('dateTo', criteria.dateTo);
|
||||
if (criteria.protocol) next.set('protocol', criteria.protocol);
|
||||
return next;
|
||||
}, [criteria]);
|
||||
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 [addressPoint, setAddressPoint] = useState<{ longitude: number; latitude: number }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (playing || !current) return;
|
||||
const timer = window.setTimeout(() => setAddressPoint({ longitude: current.longitude, latitude: current.latitude }), 350);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [current?.latitude, current?.longitude, playing]);
|
||||
const addressQuery = useQuery({
|
||||
queryKey: ['track-address', addressPoint?.longitude.toFixed(6), addressPoint?.latitude.toFixed(6)],
|
||||
enabled: Boolean(addressPoint), staleTime: 60 * 60 * 1000,
|
||||
queryFn: () => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(6), latitude: addressPoint!.latitude.toFixed(6) }))
|
||||
});
|
||||
|
||||
useEffect(() => { setActiveIndex(0); setPlaying(false); }, [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);
|
||||
}, [playing, playbackSpeed, points.length]);
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const keyword = draft.keyword.trim();
|
||||
if (!keyword) return;
|
||||
const next = { ...draft, keyword };
|
||||
setCriteria(next);
|
||||
const url = new URLSearchParams({ vin: 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);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{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>
|
||||
{track && points.length ? <TripInspector track={track} onEvent={selectEvent} /> : <aside className="v2-track-inspector is-empty"><strong>行程检查器</strong><p>查询后显示车辆、行程摘要、来源证据和轨迹事件。</p></aside>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
205
vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx
Normal file
205
vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IconAlarm, IconArrowRight, IconBox, IconCalendar, IconClock, IconCopy,
|
||||
IconMapPin, IconSearch, IconTickCircle
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult } from '../../api/types';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister } from '../auth/session';
|
||||
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from '../domain/telemetry';
|
||||
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
|
||||
import { FleetMap } from '../map/FleetMap';
|
||||
import { InlineError, PageLoading } from '../shared/AsyncState';
|
||||
|
||||
function fmt(value?: string) { return value?.trim() || '—'; }
|
||||
function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(value) : fallback; }
|
||||
function timeOnly(value?: string) { if (!value) return '—'; const parts = value.split(' '); return parts[parts.length - 1] || value; }
|
||||
function issueTone(issue: QualityIssueRow) { return issue.severity === 'error' ? 'error' : 'warning'; }
|
||||
function durationHours(seconds?: number | null) { return seconds == null ? '—' : `${new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(seconds / 3600)} 小时`; }
|
||||
function localDateTime(value?: string) { return value ? value.slice(0, 16) : ''; }
|
||||
const operationStatusLabels = { unknown: '待维护', active: '运营中', inactive: '停运', maintenance: '维保中', retired: '已退役' } as const;
|
||||
|
||||
function ProfileSyncPanel({ onClose }: { onClose: () => void }) {
|
||||
const [sourceSystem, setSourceSystem] = useState('');
|
||||
const [sourceVersion, setSourceVersion] = useState('');
|
||||
const [conflictPolicy, setConflictPolicy] = useState<'preserve' | 'overwrite'>('preserve');
|
||||
const [items, setItems] = useState<VehicleProfileSyncItem[]>([]);
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [parseError, setParseError] = useState('');
|
||||
const sync = useMutation<VehicleProfileSyncResult, Error, boolean>({
|
||||
mutationFn: (dryRun) => api.syncVehicleProfiles({ sourceSystem: sourceSystem.trim(), sourceVersion: sourceVersion.trim(), conflictPolicy, dryRun, items })
|
||||
});
|
||||
const readFile = async (file?: File) => {
|
||||
sync.reset(); setItems([]); setFileName(file?.name ?? ''); setParseError('');
|
||||
if (!file) return;
|
||||
try { setItems(parseVehicleProfileSyncCSV(await file.text())); } catch (error) { setParseError(error instanceof Error ? error.message : 'CSV 解析失败'); }
|
||||
};
|
||||
const ready = sourceSystem.trim() !== '' && sourceVersion.trim() !== '' && items.length > 0 && !sync.isPending;
|
||||
const issues = sync.data?.items.filter((item) => item.status.startsWith('conflict_') || item.status === 'missing_vehicle').slice(0, 20) ?? [];
|
||||
const applied = sync.data && !sync.data.dryRun;
|
||||
return <section className="v2-profile-sync-panel" aria-label="车辆主档批量同步">
|
||||
<header><div><strong>批量同步车辆主档</strong><p>CSV 最多 500 辆;先预演,再写入。默认保留人工档案和其他来源。</p></div><button type="button" onClick={onClose}>关闭</button></header>
|
||||
<div className="v2-profile-sync-fields">
|
||||
<label><span>来源系统标识</span><input value={sourceSystem} onChange={(event) => { setSourceSystem(event.target.value); sync.reset(); }} placeholder="例如 oem-tsp" maxLength={64} /></label>
|
||||
<label><span>来源版本</span><input value={sourceVersion} onChange={(event) => { setSourceVersion(event.target.value); sync.reset(); }} placeholder="例如 snapshot-20260714-01" maxLength={128} /></label>
|
||||
<label><span>冲突策略</span><select value={conflictPolicy} onChange={(event) => { setConflictPolicy(event.target.value as 'preserve' | 'overwrite'); sync.reset(); }}><option value="preserve">保护现有来源</option><option value="overwrite">显式覆盖现有来源</option></select></label>
|
||||
<label className="is-file"><span>CSV 文件</span><input type="file" accept=".csv,text/csv" onChange={(event) => { void readFile(event.target.files?.[0]); }} /></label>
|
||||
</div>
|
||||
<p className="v2-profile-sync-format">表头:<code>{vehicleProfileSyncCSVHeader}</code></p>
|
||||
{fileName ? <p className="v2-profile-sync-file">{fileName} · 已读取 {items.length} 辆</p> : null}
|
||||
{parseError ? <p className="v2-profile-sync-error">{parseError}</p> : null}
|
||||
{sync.isError ? <p className="v2-profile-sync-error">{sync.error.message}</p> : null}
|
||||
{sync.data ? <div className="v2-profile-sync-result">
|
||||
<div><span>收到<strong>{sync.data.received}</strong></span><span>新增<strong>{sync.data.created}</strong></span><span>更新<strong>{sync.data.updated}</strong></span><span>未变化<strong>{sync.data.unchanged}</strong></span><span>冲突<strong>{sync.data.conflicted}</strong></span><span>身份缺失<strong>{sync.data.missing}</strong></span></div>
|
||||
{issues.length ? <ul>{issues.map((item) => <li key={item.vin}><b>{item.vin}</b><span>{item.status === 'missing_vehicle' ? '网关身份不存在' : item.status === 'conflict_source_version' ? '同来源版本内容不一致' : `现有来源 ${item.previousSource || '未知'} 已保护`}</span></li>)}</ul> : <p>未发现来源冲突或身份缺失。</p>}
|
||||
</div> : null}
|
||||
{conflictPolicy === 'overwrite' ? <p className="v2-profile-sync-warning">覆盖模式会接管人工或其他系统维护的补充主档,请先确认预演结果。</p> : null}
|
||||
<footer><button type="button" onClick={() => sync.mutate(true)} disabled={!ready}>{sync.isPending ? '处理中…' : '预演同步'}</button><button className="is-primary" type="button" onClick={() => sync.mutate(false)} disabled={!ready || !sync.data?.dryRun}>{applied ? '已完成写入' : '确认写入'}</button></footer>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function VehicleSearch() {
|
||||
const navigate = useNavigate();
|
||||
const { session } = usePlatformSession();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [syncOpen, setSyncOpen] = useState(false);
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const value = keyword.trim();
|
||||
if (value) navigate(`/vehicles/${encodeURIComponent(value)}`);
|
||||
};
|
||||
return <section className={`v2-vehicle-search-page ${syncOpen ? 'has-sync-panel' : ''}`}>
|
||||
<div className="v2-vehicle-search-card">
|
||||
<span className="v2-search-hero-icon"><IconBox size="extra-large" /></span>
|
||||
<h2>查询单车数字档案</h2>
|
||||
<p>通过车牌、VIN 或终端手机号定位车辆,并查看统一身份、实时状态和来源证据。</p>
|
||||
<form onSubmit={submit}>
|
||||
<IconSearch /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="输入车牌 / VIN / 终端手机号" autoFocus />
|
||||
<button type="submit">查询车辆 <IconArrowRight /></button>
|
||||
</form>
|
||||
{canAdminister(session) ? <button className="v2-profile-sync-open" type="button" onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起批量同步' : '批量同步主档'}</button> : null}
|
||||
</div>
|
||||
{syncOpen ? <ProfileSyncPanel onClose={() => setSyncOpen(false)} /> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function Archive({ detail, editable, onUpdated }: { detail: VehicleDetail; editable: boolean; onUpdated: () => void }) {
|
||||
const profile = detail.profile;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState({ modelName: '', vehicleType: '', companyName: '', operationStatus: 'unknown', accessProvider: '', firstAccessAt: '', runtimeHours: '' });
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.updateVehicleProfile(detail.vin, {
|
||||
modelName: draft.modelName.trim(), vehicleType: draft.vehicleType.trim(), companyName: draft.companyName.trim(),
|
||||
operationStatus: draft.operationStatus as NonNullable<typeof profile>['operationStatus'], accessProvider: draft.accessProvider.trim(), firstAccessAt: draft.firstAccessAt,
|
||||
runtimeSeconds: draft.runtimeHours.trim() === '' ? null : Math.round(Number(draft.runtimeHours) * 3600), version: profile?.version ?? 0
|
||||
}),
|
||||
onSuccess: () => { setEditing(false); onUpdated(); }
|
||||
});
|
||||
const startEditing = () => {
|
||||
setDraft({ modelName: profile?.modelName ?? '', vehicleType: profile?.vehicleType ?? '', companyName: profile?.companyName ?? '', operationStatus: profile?.operationStatus ?? 'unknown', accessProvider: profile?.accessProvider ?? '', firstAccessAt: localDateTime(profile?.firstAccessAt), runtimeHours: profile?.runtimeSeconds == null ? '' : String(profile.runtimeSeconds / 3600) });
|
||||
save.reset(); setEditing(true);
|
||||
};
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); if (draft.runtimeHours === '' || Number.isFinite(Number(draft.runtimeHours))) save.mutate(); };
|
||||
return <section className="v2-record-card v2-archive-card">
|
||||
<header><strong>车辆主档</strong><span className="v2-profile-heading">完整度 {profile?.completeness ?? 0}%{editable && !editing ? <button type="button" onClick={startEditing}>维护档案</button> : null}</span></header>
|
||||
{editing ? <form className="v2-profile-form" onSubmit={submit}>
|
||||
<label><span>车型</span><input maxLength={128} value={draft.modelName} onChange={(event) => setDraft({ ...draft, modelName: event.target.value })} /></label>
|
||||
<label><span>车辆类型</span><input maxLength={64} value={draft.vehicleType} onChange={(event) => setDraft({ ...draft, vehicleType: event.target.value })} /></label>
|
||||
<label><span>所属企业</span><input maxLength={128} value={draft.companyName} onChange={(event) => setDraft({ ...draft, companyName: event.target.value })} /></label>
|
||||
<label><span>运营状态</span><select value={draft.operationStatus} onChange={(event) => setDraft({ ...draft, operationStatus: event.target.value })}>{Object.entries(operationStatusLabels).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label>
|
||||
<label><span>接入服务商</span><input maxLength={128} value={draft.accessProvider} onChange={(event) => setDraft({ ...draft, accessProvider: event.target.value })} /></label>
|
||||
<label><span>首次接入</span><input type="datetime-local" value={draft.firstAccessAt} onChange={(event) => setDraft({ ...draft, firstAccessAt: event.target.value })} /></label>
|
||||
<label><span>累计运行(小时)</span><input type="number" min="0" step="0.1" value={draft.runtimeHours} onChange={(event) => setDraft({ ...draft, runtimeHours: event.target.value })} /></label>
|
||||
{save.isError ? <p>{save.error.message}</p> : null}<footer><button type="button" onClick={() => setEditing(false)}>取消</button><button className="is-primary" type="submit" disabled={save.isPending}>{save.isPending ? '保存中' : '保存档案'}</button></footer>
|
||||
</form> : <><dl className="v2-record-list">
|
||||
<div><dt>车型 / 类型</dt><dd>{[profile?.modelName, profile?.vehicleType].filter(Boolean).join(' / ') || '—'}</dd></div>
|
||||
<div><dt>所属企业</dt><dd>{fmt(profile?.companyName)}</dd></div>
|
||||
<div><dt>运营状态</dt><dd>{operationStatusLabels[profile?.operationStatus ?? 'unknown']}</dd></div>
|
||||
<div><dt>接入服务商</dt><dd>{fmt(profile?.accessProvider)}</dd></div>
|
||||
<div><dt>首次接入</dt><dd>{fmt(profile?.firstAccessAt)}</dd></div>
|
||||
<div><dt>累计运行</dt><dd>{durationHours(profile?.runtimeSeconds)}</dd></div>
|
||||
</dl><p className="v2-record-note">身份字段来自网关;补充主档来源 {profile?.sourceSystem || '未配置'}{profile?.updatedAt ? ` · v${profile.version} · ${profile.updatedBy} 更新` : ''}</p></>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function Events({ detail }: { detail: VehicleDetail }) {
|
||||
const events = [
|
||||
...detail.quality.items.slice(0, 3).map((item) => ({ tone: issueTone(item), title: item.severity === 'error' ? '质量异常' : '质量提醒', detail: item.detail, time: item.lastSeen })),
|
||||
...detail.sourceStatus.slice(0, 3).map((item) => ({ tone: item.online ? 'success' : 'muted', title: item.online ? '数据上报' : '来源离线', detail: `${item.protocol} · ${item.online ? '当前在线' : '暂无在线数据'}`, time: item.lastSeen }))
|
||||
].slice(0, 5);
|
||||
return <section className="v2-record-card v2-events-card">
|
||||
<header><strong>最近事件</strong><Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}>查看全部</Link></header>
|
||||
<div className="v2-event-list">{events.length ? events.map((event, index) => <div className={`v2-event-row is-${event.tone}`} key={`${event.title}-${event.time}-${index}`}>
|
||||
<span className="v2-event-icon">{event.tone === 'success' ? <IconTickCircle /> : <IconAlarm />}</span>
|
||||
<div><strong>{event.title}</strong><p>{event.detail}</p></div><time>{fmt(event.time)}</time>
|
||||
</div>) : <div className="v2-empty-compact">暂无可用事件证据</div>}</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryResponse; pending: boolean; error?: string }) {
|
||||
const [selectedCategory, setSelectedCategory] = useState('vehicle');
|
||||
const indexed = useMemo(() => {
|
||||
const valuesByCategory = new Map<string, LatestTelemetryResponse['values']>();
|
||||
const sources = new Map<string, { protocol: string; endpoint?: string }>();
|
||||
for (const value of data?.values ?? []) {
|
||||
const values = valuesByCategory.get(value.category);
|
||||
if (values) values.push(value); else valuesByCategory.set(value.category, [value]);
|
||||
const sourceKey = `${value.protocol}\u0000${value.sourceEndpoint ?? ''}`;
|
||||
if (!sources.has(sourceKey)) sources.set(sourceKey, { protocol: value.protocol, endpoint: value.sourceEndpoint });
|
||||
}
|
||||
return { valuesByCategory, sources: [...sources.values()] };
|
||||
}, [data]);
|
||||
const categories = data?.categories ?? [];
|
||||
const activeCategory = indexed.valuesByCategory.has(selectedCategory) ? selectedCategory : categories[0]?.key ?? '';
|
||||
const visibleMetrics = indexed.valuesByCategory.get(activeCategory) ?? [];
|
||||
return <section className="v2-record-card v2-telemetry-card">
|
||||
<nav>{categories.map((item) => <button className={activeCategory === item.key ? 'is-active' : ''} onClick={() => setSelectedCategory(item.key)} type="button" key={item.key}>{item.label}<span>{item.count}</span></button>)}</nav>
|
||||
<div className="v2-telemetry-list">
|
||||
{pending ? <div className="v2-empty-compact">正在读取最新遥测…</div> : error ? <div className="v2-empty-compact is-error">最新遥测不可用:{error}</div> : visibleMetrics.length ? visibleMetrics.map((item) => <div key={item.key}>
|
||||
<span>{item.label}<small title={item.sourceField}>{item.sourceField} · {item.protocol}{item.sourceEndpoint ? ` · ${item.sourceEndpoint}` : ''}</small></span>
|
||||
<strong>{formatTelemetryValue(item.value)} <em>{item.unit}</em></strong>
|
||||
<time title={`设备时间 ${item.deviceTime || '缺失'};接收时间 ${item.serverTime || '缺失'};${item.qualityReason};帧 ${item.frameId}`}><i className={`is-${item.quality}`}>{telemetryQualityLabel(item.quality)}</i>{formatTelemetryTime(item.deviceTime || item.serverTime)}</time>
|
||||
</div>) : <div className="v2-empty-compact">最近 {data?.scannedFrames ?? 0} 帧没有可展示的标量遥测</div>}
|
||||
</div>
|
||||
<footer><b>来源</b>{indexed.sources.map((source) => <span key={`${source.protocol}-${source.endpoint ?? ''}`} title={source.endpoint}>{source.protocol}</span>)}<small>扫描 {data?.scannedFrames ?? 0} 帧 · 截至 {formatTelemetryTime(data?.asOf)}</small></footer>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function VehicleRecord({ detail, telemetry, telemetryPending, telemetryError, onUpdated }: { detail: VehicleDetail; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; onUpdated: () => void }) {
|
||||
const { session } = usePlatformSession();
|
||||
const realtime = detail.realtimeSummary;
|
||||
const identity = detail.identity;
|
||||
const mapVehicles = realtime ? [realtime] : [];
|
||||
const lastMileage = detail.mileage.items[0];
|
||||
return <div className="v2-vehicle-record-page">
|
||||
<section className="v2-identity-band">
|
||||
<div className="v2-identity-primary"><span className="v2-plate"><IconBox />{fmt(identity?.plate || realtime?.plate)}</span><span className={`v2-online-label ${realtime?.online ? 'is-online' : ''}`}><i />{realtime?.online ? '在线' : '离线'}</span><small>VIN</small><b>{detail.vin}</b><button type="button" title="复制 VIN" onClick={() => navigator.clipboard?.writeText(detail.vin)}><IconCopy /></button></div>
|
||||
<div className="v2-identity-meta"><div><small>接入来源</small><p>{detail.sources.map((source) => <span key={source}>{source}</span>)}</p></div><div><small>最后上报时间</small><strong>{fmt(realtime?.lastSeen || identity?.lastSeen)}</strong></div></div>
|
||||
<div className="v2-identity-actions"><Link to={`/tracks?vin=${encodeURIComponent(detail.vin)}`}><IconMapPin />轨迹回放</Link><Link to={`/history?vin=${encodeURIComponent(detail.vin)}`}><IconCalendar />历史数据</Link><Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><IconAlarm />告警事件</Link></div>
|
||||
</section>
|
||||
|
||||
<div className="v2-record-grid">
|
||||
<section className="v2-single-map-card"><FleetMap vehicles={mapVehicles} selectedVin={detail.vin} onSelect={() => undefined} /><footer><span><IconMapPin />{realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</span><time>更新时间:{fmt(realtime?.lastSeen)}</time></footer></section>
|
||||
<Archive detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} />
|
||||
<section className="v2-record-card v2-live-card"><header><strong>实时指标</strong><span><IconClock />{timeOnly(realtime?.lastSeen)}</span></header><div className="v2-live-grid">
|
||||
<div><small>速度</small><strong>{metric(realtime?.speedKmh)}<em>km/h</em></strong></div><div><small>SOC</small><strong>{metric(realtime?.socPercent)}<em>%</em></strong></div><div><small>总里程</small><strong>{metric(realtime?.totalMileageKm)}<em>km</em></strong></div><div><small>当日里程</small><strong>{metric(lastMileage?.dailyMileageKm)}<em>km</em></strong></div><div><small>在线来源</small><strong>{realtime?.onlineSourceCount ?? 0}<em>个</em></strong></div><div><small>数据源总数</small><strong>{detail.sourceStatus.length}<em>个</em></strong></div>
|
||||
</div></section>
|
||||
<TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} />
|
||||
<Events detail={detail} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function VehiclePage() {
|
||||
const { vin } = useParams();
|
||||
const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: Boolean(vin), queryFn: () => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' })) });
|
||||
const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: () => api.latestTelemetry(vin!), staleTime: 10_000, refetchInterval: 20_000, refetchIntervalInBackground: false });
|
||||
if (!vin) return <VehicleSearch />;
|
||||
if (query.isPending) return <PageLoading />;
|
||||
if (query.isError) return <div className="v2-page-error"><InlineError message={query.error instanceof Error ? query.error.message : '车辆档案加载失败'} onRetry={() => query.refetch()} /></div>;
|
||||
if (!query.data.lookupResolved) return <section className="v2-not-found"><IconSearch size="extra-large" /><h2>未找到车辆</h2><p>没有匹配“{vin}”的车牌、VIN 或终端记录。</p><Link to="/vehicles">重新查询</Link></section>;
|
||||
return <VehicleRecord detail={query.data} telemetry={telemetry.data} telemetryPending={telemetry.isPending} telemetryError={telemetry.isError ? (telemetry.error instanceof Error ? telemetry.error.message : '请求失败') : undefined} onUpdated={() => { void query.refetch(); void telemetry.refetch(); }} />;
|
||||
}
|
||||
19
vehicle-data-platform/apps/web/src/v2/shared/AsyncState.tsx
Normal file
19
vehicle-data-platform/apps/web/src/v2/shared/AsyncState.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { IconAlertTriangle, IconRefresh } from '@douyinfe/semi-icons';
|
||||
|
||||
export function PageLoading({ label = '正在加载车辆数据' }: { label?: string }) {
|
||||
return <div className="v2-page-state"><span className="v2-spinner" />{label}</div>;
|
||||
}
|
||||
|
||||
export function InlineError({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||
return (
|
||||
<div className="v2-inline-state is-error" role="alert">
|
||||
<IconAlertTriangle />
|
||||
<span>{message}</span>
|
||||
{onRetry ? <button type="button" onClick={onRetry}><IconRefresh />重试</button> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ title = '暂无符合条件的车辆' }: { title?: string }) {
|
||||
return <div className="v2-inline-state"><span>{title}</span></div>;
|
||||
}
|
||||
980
vehicle-data-platform/apps/web/src/v2/styles/v2.css
Normal file
980
vehicle-data-platform/apps/web/src/v2/styles/v2.css
Normal file
@@ -0,0 +1,980 @@
|
||||
:root {
|
||||
--v2-bg: #f4f7fb;
|
||||
--v2-surface: #ffffff;
|
||||
--v2-text: #152033;
|
||||
--v2-muted: #718096;
|
||||
--v2-border: #e4eaf2;
|
||||
--v2-blue: #1268f3;
|
||||
--v2-blue-soft: #edf4ff;
|
||||
--v2-green: #12a46f;
|
||||
--v2-orange: #f59e0b;
|
||||
--v2-red: #ef4444;
|
||||
--v2-shadow: 0 8px 30px rgba(21, 32, 51, 0.06);
|
||||
--v2-radius: 10px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--v2-bg); color: var(--v2-text); font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; }
|
||||
button, input, select { font: inherit; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
.v2-auth-screen { display: grid; min-height: 100vh; place-items: center; background: radial-gradient(circle at 50% 10%, #edf4ff 0, #f4f7fb 42%, #eef2f7 100%); padding: 20px; }
|
||||
.v2-auth-card { display: flex; width: min(390px, 100%); flex-direction: column; align-items: stretch; border: 1px solid var(--v2-border); border-radius: 16px; background: #fff; padding: 34px; box-shadow: 0 22px 70px rgba(21, 32, 51, .12); }
|
||||
.v2-auth-card > strong { margin-top: 12px; text-align: center; }.v2-auth-mark { display: grid; width: 46px; height: 46px; margin: 0 auto 16px; place-items: center; border-radius: 13px; background: var(--v2-blue); color: #fff; font-size: 20px; font-weight: 800; box-shadow: 0 9px 20px rgba(18, 104, 243, .22); }
|
||||
.v2-auth-card h1 { margin: 0; text-align: center; font-size: 22px; }.v2-auth-card p { margin: 10px 0 24px; color: var(--v2-muted); text-align: center; font-size: 12px; line-height: 1.7; }
|
||||
.v2-auth-card label { display: flex; flex-direction: column; gap: 7px; color: #58667a; font-size: 12px; font-weight: 600; }.v2-auth-card input { height: 40px; border: 1px solid #d7e0ec; border-radius: 8px; padding: 0 11px; outline: 0; }.v2-auth-card input:focus { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
|
||||
.v2-auth-card em { margin-top: 9px; color: var(--v2-red); font-size: 11px; font-style: normal; }.v2-auth-card button { height: 40px; margin-top: 18px; border: 0; border-radius: 8px; background: var(--v2-blue); color: #fff; cursor: pointer; font-weight: 700; }.v2-auth-card button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.v2-auth-spinner { width: 24px; height: 24px; margin: auto; border: 3px solid #d8e5fa; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; }
|
||||
|
||||
.v2-shell { height: 100vh; height: 100dvh; overflow: hidden; background: var(--v2-bg); }
|
||||
.v2-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 50; contain: layout paint; display: flex; width: 188px; flex-direction: column; border-right: 1px solid var(--v2-border); background: #fff; }
|
||||
.v2-brand { display: flex; height: 64px; align-items: center; gap: 11px; border-bottom: 1px solid var(--v2-border); padding: 0 16px; white-space: nowrap; overflow: hidden; }
|
||||
.v2-brand strong { font-size: 16px; letter-spacing: -.02em; }
|
||||
.v2-brand-mark { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 9px; background: var(--v2-blue); color: #fff; box-shadow: 0 6px 14px rgba(18, 104, 243, .22); }
|
||||
.v2-navigation { display: flex; flex: 1; flex-direction: column; gap: 4px; padding: 14px 10px; }
|
||||
.v2-nav-item { position: relative; display: flex; height: 44px; align-items: center; gap: 12px; border-radius: 8px; padding: 0 14px; color: #59677c; text-decoration: none; white-space: nowrap; overflow: hidden; font-size: 13px; font-weight: 600; transition: background .16s, color .16s; }
|
||||
.v2-nav-item:hover { background: #f7f9fc; color: var(--v2-text); }
|
||||
.v2-nav-item.is-active { background: var(--v2-blue-soft); color: var(--v2-blue); }
|
||||
.v2-nav-item.is-active::before { position: absolute; left: 0; width: 3px; height: 24px; border-radius: 0 3px 3px 0; background: var(--v2-blue); content: ""; }
|
||||
.v2-nav-operations { margin: 0 10px 8px; }
|
||||
.v2-collapse { display: flex; height: 48px; align-items: center; gap: 10px; border: 0; border-top: 1px solid var(--v2-border); background: #fff; padding: 0 22px; color: #64748b; cursor: pointer; }
|
||||
.v2-main { display: flex; height: 100vh; height: 100dvh; min-width: 0; flex-direction: column; margin-left: 188px; }
|
||||
.v2-topbar { position: sticky; top: 0; z-index: 40; display: flex; height: 64px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); background: rgba(255,255,255,.94); padding: 0 24px; backdrop-filter: blur(16px); }
|
||||
.v2-topbar h1 { margin: 0; font-size: 20px; letter-spacing: -.035em; }
|
||||
.v2-topbar-actions { display: flex; align-items: center; gap: 4px; }
|
||||
.v2-topbar-actions button { display: grid; width: 34px; height: 34px; place-items: center; border: 0; border-radius: 8px; background: transparent; color: #64748b; cursor: pointer; }
|
||||
.v2-topbar-actions button:hover { background: #f1f5f9; }
|
||||
.v2-current-user { display: flex; align-items: center; gap: 6px; margin: 0 5px; color: #59677c; font-size: 11px; }.v2-current-user b { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-current-user small, .v2-role-badge { border-radius: 10px; background: var(--v2-blue-soft); padding: 2px 6px; color: var(--v2-blue); font-size: 9px; }
|
||||
.v2-role-notice { margin: 6px 0; border-radius: 6px; background: #f6f8fb; padding: 8px; color: var(--v2-muted); font-size: 8px; line-height: 1.5; }
|
||||
.v2-content { min-width: 0; min-height: 0; flex: 1; overflow: auto; }
|
||||
.v2-sidebar.is-collapsed { width: 68px; }
|
||||
.v2-sidebar.is-collapsed + .v2-main { margin-left: 68px; }
|
||||
.v2-sidebar.is-collapsed .v2-brand strong, .v2-sidebar.is-collapsed .v2-nav-label, .v2-sidebar.is-collapsed .v2-collapse span { display: none; }
|
||||
.v2-sidebar.is-collapsed .v2-brand { padding: 0 17px; }
|
||||
.v2-sidebar.is-collapsed .v2-nav-item { justify-content: center; padding: 0; }
|
||||
.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-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-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-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; }
|
||||
.v2-kpi + .v2-kpi::before { position: absolute; inset: 12px auto 12px 0; width: 1px; background: var(--v2-border); content: ""; }
|
||||
.v2-kpi small { display: block; color: var(--v2-muted); font-size: 11px; }
|
||||
.v2-kpi strong { display: block; margin-top: 5px; overflow: hidden; color: var(--v2-text); font-size: clamp(16px, 1.45vw, 22px); line-height: 1; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.v2-kpi.is-online strong, .v2-kpi.is-idle strong { color: var(--v2-green); }
|
||||
.v2-kpi.is-driving strong, .v2-kpi.is-today strong { color: var(--v2-blue); }
|
||||
.v2-kpi.is-alert strong { color: var(--v2-red); }
|
||||
.v2-kpi.is-offline strong { color: #7b8798; }
|
||||
|
||||
.v2-monitor-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: clamp(244px, 14vw, 300px) minmax(480px, 1fr); grid-template-rows: minmax(0, 1fr); overflow: hidden; border: 1px solid #dce4ee; border-radius: 8px; background: #fff; box-shadow: 0 4px 16px rgba(21,32,51,.04); }
|
||||
.v2-monitor-workspace.is-detail-open { grid-template-columns: clamp(244px, 14vw, 300px) minmax(480px, 1fr) clamp(300px, 17vw, 360px); }
|
||||
.v2-monitor-workspace.is-detail-collapsed { grid-template-columns: clamp(244px, 14vw, 300px) minmax(480px, 1fr) 44px; }
|
||||
.v2-vehicle-rail { display: flex; min-width: 0; min-height: 0; flex-direction: column; border-right: 1px solid var(--v2-border); }
|
||||
.v2-vehicle-rail > header { display: flex; height: 46px; align-items: center; justify-content: space-between; padding: 0 12px; }
|
||||
.v2-vehicle-rail > header strong { font-size: 13px; }
|
||||
.v2-vehicle-rail > header span, .v2-vehicle-rail > footer { color: var(--v2-muted); font-size: 10px; }
|
||||
.v2-rail-search { display: flex; height: 34px; align-items: center; gap: 7px; margin: 0 9px 8px; border: 1px solid var(--v2-border); border-radius: 7px; padding: 0 9px; color: #8a98aa; font-size: 10px; }
|
||||
.v2-vehicle-scroll { min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; content-visibility: auto; }
|
||||
.v2-vehicle-row { display: grid; width: 100%; min-height: 60px; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 8px; border: 0; border-top: 1px solid #eef2f7; background: #fff; padding: 8px 10px; text-align: left; cursor: pointer; }
|
||||
.v2-vehicle-row:hover { background: #f8fbff; }
|
||||
.v2-vehicle-row.is-selected { position: relative; z-index: 1; background: var(--v2-blue-soft); box-shadow: inset 3px 0 var(--v2-blue); }
|
||||
.v2-status-dot { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }
|
||||
.v2-status-dot.is-online, .v2-status-dot.is-idle { background: var(--v2-green); }
|
||||
.v2-status-dot.is-driving { background: var(--v2-blue); }
|
||||
.v2-status-dot.is-offline { background: #aab3c0; }
|
||||
.v2-status-dot.is-alert { background: var(--v2-red); }
|
||||
.v2-vehicle-identity, .v2-vehicle-motion { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.v2-vehicle-identity strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-vehicle-identity small, .v2-vehicle-motion small { overflow: hidden; color: #8996a8; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-vehicle-motion { align-items: flex-end; }
|
||||
.v2-vehicle-motion strong { font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.v2-vehicle-motion strong small { font-size: 8px; font-weight: 500; }
|
||||
.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-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; }
|
||||
.v2-map-layer-control { display: grid; height: 44px; grid-template-columns: auto auto auto; align-items: center; gap: 8px; border: 1px solid #d7e1ee; border-radius: 8px; background: #fff; padding: 0 10px; color: #435168; box-shadow: 0 5px 16px rgba(21,32,51,.1); cursor: pointer; }
|
||||
.v2-map-layer-control > svg { color: var(--v2-blue); font-size: 15px; }
|
||||
.v2-map-layer-control span { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; }
|
||||
.v2-map-layer-control strong { font-size: 10px; line-height: 1.2; }
|
||||
.v2-map-layer-control small { color: #7b8ba0; font-size: 8px; }
|
||||
.v2-map-layer-control > i { position: relative; width: 24px; height: 14px; border-radius: 8px; background: #cbd5e1; transition: background .16s; }
|
||||
.v2-map-layer-control > i::after { position: absolute; top: 2px; left: 2px; width: 10px; height: 10px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.25); content: ""; transition: transform .16s; }
|
||||
.v2-map-layer-control > i.is-on { background: var(--v2-blue); }
|
||||
.v2-map-layer-control > i.is-on::after { transform: translateX(10px); }
|
||||
.v2-map-follow-control { display: grid; height: 44px; grid-template-columns: auto auto; align-items: center; gap: 8px; border: 1px solid #d7e1ee; border-radius: 8px; background: #fff; padding: 0 11px; color: #607086; box-shadow: 0 5px 16px rgba(21,32,51,.1); cursor: pointer; transition: border-color .16s, background .16s, color .16s; }
|
||||
.v2-map-follow-control > svg { font-size: 16px; }
|
||||
.v2-map-follow-control span { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; }
|
||||
.v2-map-follow-control strong { font-size: 10px; line-height: 1.2; }
|
||||
.v2-map-follow-control small { color: #8a98aa; font-size: 8px; }
|
||||
.v2-map-follow-control.is-active { border-color: #9ec0f7; background: #eef5ff; color: #1268f3; }
|
||||
.v2-map-follow-control.is-active small { color: #4e82cf; }
|
||||
.v2-map-selection-marker { position: relative; width: 48px; height: 48px; pointer-events: none; }
|
||||
.v2-map-selection-marker > i { position: absolute; inset: 4px; border: 2px solid rgba(18,104,243,.6); border-radius: 50%; animation: v2-map-ripple 2s ease-out infinite; }
|
||||
.v2-map-selection-marker > i:nth-child(2) { animation-delay: 1s; }
|
||||
.v2-map-selection-marker > b { position: absolute; top: 18px; left: 18px; width: 12px; height: 12px; border: 3px solid #fff; border-radius: 50%; background: var(--v2-blue); box-shadow: 0 2px 8px rgba(18,104,243,.45); }
|
||||
.v2-map-state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 9px; background: #eef3f7; color: #637083; font-size: 12px; }
|
||||
.v2-map-state.is-loading { background: rgba(255,255,255,.82); backdrop-filter: blur(2px); }
|
||||
.v2-map-state.is-error { color: #b42318; }
|
||||
.v2-map-legend { position: absolute; bottom: 12px; left: 50%; display: flex; width: max-content; max-width: calc(100% - 28px); height: 36px; align-items: center; justify-content: center; gap: 18px; border: 1px solid #d8e2ed; border-radius: 8px; background: #fff; padding: 0 16px; color: #5f6e82; box-shadow: 0 6px 18px rgba(21,32,51,.1); font-size: 9px; transform: translateX(-50%); }
|
||||
.v2-map-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.v2-map-legend i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }
|
||||
.v2-map-legend .is-driving { background: var(--v2-blue); }
|
||||
.v2-map-legend .is-idle { background: var(--v2-green); }
|
||||
.v2-map-legend .is-alert { background: var(--v2-red); }
|
||||
.v2-map-legend b { margin-left: 6px; color: #4d5c70; font-weight: 600; }
|
||||
|
||||
.v2-vehicle-detail { position: relative; min-width: 0; min-height: 0; contain: layout paint; overflow: auto; border-left: 1px solid var(--v2-border); background: #fff; padding: 14px; animation: v2-panel-enter .14s ease-out; }
|
||||
.v2-detail-controls { position: absolute; top: 10px; right: 10px; z-index: 1; display: flex; gap: 4px; }
|
||||
.v2-detail-controls button { display: grid; width: 28px; height: 28px; place-items: center; border: 1px solid #dce4ef; border-radius: 6px; background: #fff; color: #68768a; cursor: pointer; transition: border-color .16s, background .16s, color .16s; }
|
||||
.v2-detail-controls button:hover { border-color: #a9c5f2; background: #f1f6ff; color: var(--v2-blue); }
|
||||
.v2-detail-controls button:last-child:hover { border-color: #f0b9b9; background: #fff5f5; color: var(--v2-red); }
|
||||
.v2-detail-title { padding: 2px 68px 12px 0; border-bottom: 1px solid var(--v2-border); }
|
||||
.v2-detail-title > div { display: flex; align-items: center; gap: 8px; }
|
||||
.v2-detail-title strong { font-size: 16px; }
|
||||
.v2-detail-title small { display: block; margin-top: 4px; color: var(--v2-muted); font-size: 9px; }
|
||||
.v2-status-text { color: var(--v2-muted); font-size: 10px; font-weight: 700; }
|
||||
.v2-status-text.is-driving { color: var(--v2-blue); }
|
||||
.v2-status-text.is-idle, .v2-status-text.is-online { color: var(--v2-green); }
|
||||
.v2-status-text.is-alert { color: var(--v2-red); }
|
||||
.v2-detail-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; padding: 10px 0; }
|
||||
.v2-detail-actions a { display: flex; height: 30px; align-items: center; justify-content: center; border: 1px solid #dce4ef; border-radius: 6px; color: #59677c; text-decoration: none; font-size: 9px; font-weight: 700; }
|
||||
.v2-detail-actions a:first-child { border-color: var(--v2-blue); background: var(--v2-blue); color: #fff; }
|
||||
.v2-detail-peek { min-width: 0; min-height: 0; contain: layout paint; border-left: 1px solid var(--v2-border); background: #fff; animation: v2-panel-enter .12s ease-out; }
|
||||
.v2-detail-peek button { display: flex; width: 100%; height: 100%; align-items: center; flex-direction: column; gap: 10px; border: 0; background: #fff; padding: 14px 8px; color: #65758a; cursor: pointer; transition: background .16s, color .16s; }
|
||||
.v2-detail-peek button:hover { background: #f2f7ff; color: var(--v2-blue); }
|
||||
.v2-detail-peek button > svg { flex: 0 0 auto; font-size: 15px; }
|
||||
.v2-detail-peek button > span { overflow: hidden; writing-mode: vertical-rl; color: #435168; font-size: 10px; font-weight: 700; letter-spacing: 1px; text-overflow: ellipsis; }
|
||||
.v2-vehicle-detail section { padding: 10px 0; border-top: 1px solid var(--v2-border); }
|
||||
.v2-vehicle-detail h3 { margin: 0 0 9px; font-size: 11px; }
|
||||
.v2-detail-list { margin: 0; }
|
||||
.v2-detail-list > div { display: grid; grid-template-columns: 78px minmax(0, 1fr); gap: 8px; padding: 5px 0; font-size: 9px; }
|
||||
.v2-detail-list dt { color: var(--v2-muted); }
|
||||
.v2-detail-list dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #3d4a5e; }
|
||||
.v2-metric-grid { display: grid; grid-template-columns: repeat(2, 1fr); border: 1px solid var(--v2-border); border-radius: 7px; }
|
||||
.v2-metric-grid > div { min-width: 0; padding: 9px; }
|
||||
.v2-metric-grid > div:nth-child(even) { border-left: 1px solid var(--v2-border); }
|
||||
.v2-metric-grid > div:nth-child(n+3) { border-top: 1px solid var(--v2-border); }
|
||||
.v2-metric-grid small { display: block; color: var(--v2-muted); font-size: 8px; }
|
||||
.v2-metric-grid strong { display: block; margin-top: 4px; overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-metric-grid em { margin-left: 2px; color: var(--v2-muted); font-size: 7px; font-style: normal; font-weight: 500; }
|
||||
|
||||
.v2-event-strip { display: flex; min-height: 48px; align-items: center; gap: 22px; border: 1px solid #dfe6ef; border-radius: 8px; background: #fff; padding: 0 16px; box-shadow: 0 3px 12px rgba(21,32,51,.035); color: #6d7a8d; font-size: 10px; }
|
||||
.v2-event-strip strong { color: var(--v2-text); font-size: 11px; }
|
||||
.v2-event-strip span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.v2-event-strip i { width: 7px; height: 7px; border-radius: 50%; background: var(--v2-green); }
|
||||
.v2-event-strip time { margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||
.v2-refresh-cadence { border-left: 1px solid var(--v2-border); padding-left: 18px; }
|
||||
.v2-refresh-cadence b { color: var(--v2-blue); font-weight: 700; }
|
||||
|
||||
.v2-page-state, .v2-inline-state { display: flex; align-items: center; justify-content: center; gap: 9px; color: var(--v2-muted); font-size: 12px; }
|
||||
.v2-page-state { min-height: calc(100vh - 64px); }
|
||||
.v2-inline-state { min-height: 76px; border: 1px dashed var(--v2-border); border-radius: 8px; padding: 12px; }
|
||||
.v2-inline-state.is-error { justify-content: flex-start; min-height: 42px; border-style: solid; border-color: #fecaca; background: #fff5f5; color: #b42318; }
|
||||
.v2-inline-state button { display: inline-flex; align-items: center; gap: 5px; margin-left: auto; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 11px; }
|
||||
.v2-spinner { width: 14px; height: 14px; border: 2px solid #cfe0fb; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; }
|
||||
.v2-module-stage { margin: 18px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 28px; box-shadow: var(--v2-shadow); }
|
||||
.v2-module-stage h2 { margin: 0; font-size: 20px; }
|
||||
.v2-module-stage p { margin: 10px 0 0; color: var(--v2-muted); font-size: 13px; }
|
||||
|
||||
.v2-ops-page { display: flex; min-height: 100%; flex-direction: column; gap: 10px; padding: 12px 16px 16px; }
|
||||
.v2-ops-heading { display: flex; align-items: center; justify-content: space-between; }.v2-ops-heading h2 { margin: 0; font-size: 18px; }.v2-ops-heading p { margin: 4px 0 0; color: var(--v2-muted); font-size: 10px; }.v2-ops-heading button { display: flex; height: 32px; align-items: center; gap: 6px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 11px; color: #526176; cursor: pointer; font-size: 10px; }
|
||||
.v2-ops-kpis { display: grid; grid-template-columns: repeat(5,1fr); border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }.v2-ops-kpis article { position: relative; min-width: 0; padding: 13px 15px; }.v2-ops-kpis article + article::before { position: absolute; inset: 12px auto 12px 0; width: 1px; background: var(--v2-border); content: ''; }.v2-ops-kpis small { display: block; color: var(--v2-muted); font-size: 9px; }.v2-ops-kpis strong { display: block; margin: 6px 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 16px; }.v2-ops-kpis span { color: #8793a5; font-size: 8px; }
|
||||
.v2-ops-grid { display: grid; min-height: 280px; grid-template-columns: minmax(460px,1.5fr) minmax(280px,.8fr); gap: 10px; }.v2-ops-links, .v2-ops-runtime, .v2-ops-sources { border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }.v2-ops-links > header, .v2-ops-runtime > header, .v2-ops-sources > header { display: flex; height: 42px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 13px; }.v2-ops-links header strong, .v2-ops-runtime header strong, .v2-ops-sources header strong { font-size: 11px; }.v2-ops-links header span, .v2-ops-sources header span { color: var(--v2-muted); font-size: 8px; }
|
||||
.v2-ops-links article { display: grid; min-height: 46px; grid-template-columns: 8px minmax(0,1fr) auto; align-items: center; gap: 9px; border-bottom: 1px solid #eef2f7; padding: 7px 13px; }.v2-ops-links article > i, .v2-ops-sources article i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }.v2-ops-links article > i.is-ok, .v2-ops-sources article i.is-ok { background: var(--v2-green); }.v2-ops-links article > i.is-warning, .v2-ops-sources article i.is-warning { background: var(--v2-orange); }.v2-ops-links article > i.is-error, .v2-ops-sources article i.is-error { background: var(--v2-red); }.v2-ops-links article strong { font-size: 9px; }.v2-ops-links article p { margin: 3px 0 0; color: var(--v2-muted); font-size: 8px; }.v2-ops-links article > span { border-radius: 10px; background: #f1f5f9; padding: 3px 7px; color: #64748b; font-size: 8px; }
|
||||
.v2-ops-runtime dl { margin: 0; padding: 7px 13px; }.v2-ops-runtime dl div { display: flex; min-height: 31px; align-items: center; justify-content: space-between; border-bottom: 1px solid #eef2f7; font-size: 9px; }.v2-ops-runtime dt { color: var(--v2-muted); }.v2-ops-runtime dd { margin: 0; }.v2-ops-clear, .v2-ops-findings { margin: 4px 13px 12px; border-radius: 6px; background: #f1fbf7; padding: 8px; color: #17815d; font-size: 8px; }.v2-ops-findings { background: #fff7ed; color: #b45309; }.v2-ops-findings p { margin: 3px 0; }
|
||||
.v2-ops-sources > div { display: grid; grid-template-columns: repeat(3,1fr); }.v2-ops-sources article { min-width: 0; padding: 12px 14px; }.v2-ops-sources article + article { border-left: 1px solid var(--v2-border); }.v2-ops-sources article > div { display: flex; align-items: center; gap: 7px; }.v2-ops-sources article strong { font-size: 10px; }.v2-ops-sources article span { margin-left: auto; color: var(--v2-muted); font-size: 8px; }.v2-ops-sources article b { display: block; margin-top: 9px; font-size: 14px; }.v2-ops-sources article p { margin: 6px 0 0; color: #68768a; font-size: 8px; line-height: 1.45; }.v2-ops-sources article em { display: block; margin-top: 7px; color: var(--v2-blue); font-size: 8px; font-style: normal; }.is-ok { color: var(--v2-green) !important; }.is-warning { color: #b87900 !important; }.is-error { color: var(--v2-red) !important; }
|
||||
|
||||
.v2-vehicle-search-page, .v2-not-found { display: grid; min-height: 100%; place-items: center; padding: 28px; }
|
||||
.v2-vehicle-search-card { width: min(660px, 100%); border: 1px solid var(--v2-border); border-radius: 16px; background: #fff; padding: 54px; text-align: center; box-shadow: var(--v2-shadow); }
|
||||
.v2-search-hero-icon { display: grid; width: 54px; height: 54px; margin: 0 auto 18px; place-items: center; border-radius: 15px; background: var(--v2-blue-soft); color: var(--v2-blue); }
|
||||
.v2-vehicle-search-card h2, .v2-not-found h2 { margin: 0; font-size: 22px; }
|
||||
.v2-vehicle-search-card > p, .v2-not-found p { margin: 10px auto 26px; color: var(--v2-muted); font-size: 13px; line-height: 1.7; }
|
||||
.v2-vehicle-search-card form { display: flex; height: 46px; align-items: center; gap: 10px; border: 1px solid #cfd9e7; border-radius: 9px; padding-left: 14px; color: #8a98aa; }
|
||||
.v2-vehicle-search-card form:focus-within { border-color: #8bb6fb; box-shadow: 0 0 0 4px rgba(18,104,243,.08); }
|
||||
.v2-vehicle-search-card input { min-width: 0; flex: 1; border: 0; outline: 0; color: var(--v2-text); }
|
||||
.v2-vehicle-search-card button, .v2-not-found a { display: inline-flex; height: 46px; align-items: center; gap: 7px; border: 0; border-radius: 8px; background: var(--v2-blue); padding: 0 20px; color: #fff; text-decoration: none; cursor: pointer; font-weight: 700; }
|
||||
.v2-vehicle-search-card .v2-profile-sync-open { height: 32px; margin-top: 16px; border: 1px solid #d5e3f7; background: #f6f9fe; color: var(--v2-blue); font-size: 10px; font-weight: 600; }
|
||||
.v2-vehicle-search-page.has-sync-panel { align-content: center; gap: 14px; overflow: auto; }
|
||||
.v2-profile-sync-panel { width: min(900px, 100%); border: 1px solid var(--v2-border); border-radius: 12px; background: #fff; padding: 16px; box-shadow: var(--v2-shadow); }
|
||||
.v2-profile-sync-panel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }.v2-profile-sync-panel > header strong { font-size: 14px; }.v2-profile-sync-panel > header p { margin: 5px 0 0; color: var(--v2-muted); font-size: 9px; }.v2-profile-sync-panel > header button { border: 0; background: transparent; color: var(--v2-muted); cursor: pointer; font-size: 9px; }
|
||||
.v2-profile-sync-fields { display: grid; grid-template-columns: 1fr 1fr .8fr 1.2fr; gap: 10px; margin-top: 14px; }.v2-profile-sync-fields label { display: grid; min-width: 0; gap: 5px; color: var(--v2-muted); font-size: 9px; }.v2-profile-sync-fields input, .v2-profile-sync-fields select { min-width: 0; height: 34px; border: 1px solid var(--v2-border); border-radius: 6px; background: #fff; padding: 0 9px; color: var(--v2-text); font-size: 10px; }.v2-profile-sync-fields input[type="file"] { padding: 6px; }
|
||||
.v2-profile-sync-format, .v2-profile-sync-file, .v2-profile-sync-error, .v2-profile-sync-warning { margin: 10px 0 0; color: var(--v2-muted); font-size: 9px; line-height: 1.5; }.v2-profile-sync-format code { overflow-wrap: anywhere; color: #53657c; }.v2-profile-sync-file { color: var(--v2-blue); }.v2-profile-sync-error { color: var(--v2-red); }.v2-profile-sync-warning { border-radius: 6px; background: #fff7ed; padding: 7px 9px; color: #b45309; }
|
||||
.v2-profile-sync-result { margin-top: 12px; border: 1px solid #e4ebf4; border-radius: 8px; background: #fbfcfe; padding: 10px; }.v2-profile-sync-result > div { display: grid; grid-template-columns: repeat(6,1fr); }.v2-profile-sync-result > div span { color: var(--v2-muted); text-align: center; font-size: 8px; }.v2-profile-sync-result > div strong { display: block; margin-top: 4px; color: var(--v2-text); font-size: 14px; }.v2-profile-sync-result > p { margin: 8px 0 0; color: var(--v2-green); font-size: 9px; }.v2-profile-sync-result ul { max-height: 104px; margin: 9px 0 0; overflow: auto; border-top: 1px solid #e7edf5; padding: 5px 0 0; list-style: none; }.v2-profile-sync-result li { display: flex; justify-content: space-between; gap: 10px; padding: 4px 2px; font-size: 8px; }.v2-profile-sync-result li span { color: var(--v2-muted); }
|
||||
.v2-profile-sync-panel > footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }.v2-profile-sync-panel > footer button { height: 32px; border: 1px solid var(--v2-border); border-radius: 6px; background: #fff; padding: 0 12px; color: var(--v2-text); cursor: pointer; font-size: 9px; }.v2-profile-sync-panel > footer button.is-primary { border-color: var(--v2-blue); background: var(--v2-blue); color: #fff; }.v2-profile-sync-panel > footer button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.v2-not-found { align-content: center; text-align: center; color: var(--v2-muted); }
|
||||
.v2-not-found a { height: 38px; margin: 0 auto; }
|
||||
.v2-page-error { padding: 18px; }
|
||||
|
||||
.v2-vehicle-record-page { display: flex; min-height: 100%; flex-direction: column; gap: 12px; padding: 12px 16px 16px; }
|
||||
.v2-identity-band { display: grid; min-height: 96px; grid-template-columns: minmax(260px, 1fr) minmax(340px, 1.25fr) auto; align-items: center; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-identity-primary, .v2-identity-meta, .v2-identity-actions { min-width: 0; padding: 15px 18px; }
|
||||
.v2-identity-primary { display: grid; grid-template-columns: auto auto 1fr; align-items: center; gap: 6px 14px; }
|
||||
.v2-plate { display: inline-flex; min-height: 36px; align-items: center; gap: 8px; border: 1px solid #b9d2fb; border-radius: 7px; background: var(--v2-blue-soft); padding: 0 12px; color: var(--v2-blue); font-size: 16px; font-weight: 800; }
|
||||
.v2-online-label { display: inline-flex; align-items: center; gap: 6px; color: #8793a5; font-size: 11px; font-weight: 700; }
|
||||
.v2-online-label i { width: 7px; height: 7px; border-radius: 50%; background: #aab3c0; }
|
||||
.v2-online-label.is-online { color: var(--v2-green); }
|
||||
.v2-online-label.is-online i { background: var(--v2-green); }
|
||||
.v2-identity-primary small { grid-row: 2; color: var(--v2-muted); font-size: 9px; }
|
||||
.v2-identity-primary b { grid-row: 2; overflow: hidden; color: #445166; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-identity-primary button { grid-row: 2; border: 0; background: transparent; color: #718096; cursor: pointer; }
|
||||
.v2-identity-meta { display: grid; grid-template-columns: 1fr 1fr; align-self: stretch; align-items: center; border-right: 1px solid var(--v2-border); border-left: 1px solid var(--v2-border); }
|
||||
.v2-identity-meta > div + div { border-left: 1px solid var(--v2-border); padding-left: 22px; }
|
||||
.v2-identity-meta small { color: var(--v2-muted); font-size: 10px; }
|
||||
.v2-identity-meta p { display: flex; flex-wrap: wrap; gap: 5px; margin: 8px 0 0; }
|
||||
.v2-identity-meta p span, .v2-telemetry-card footer span { border: 1px solid #dde5ef; border-radius: 5px; background: #f7f9fc; padding: 3px 7px; color: #526176; font-size: 9px; }
|
||||
.v2-identity-meta strong { display: block; margin-top: 8px; color: #445166; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.v2-identity-actions { display: flex; gap: 8px; }
|
||||
.v2-identity-actions a { display: inline-flex; height: 38px; align-items: center; gap: 7px; border: 1px solid #dce4ef; border-radius: 7px; padding: 0 14px; color: #536177; text-decoration: none; white-space: nowrap; font-size: 10px; font-weight: 700; }
|
||||
.v2-identity-actions a:hover { border-color: #a8c7f9; color: var(--v2-blue); }
|
||||
|
||||
.v2-record-grid { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(520px, 1.75fr) minmax(300px, 1fr); grid-template-rows: minmax(300px, 1.05fr) auto minmax(270px, .95fr); gap: 12px; }
|
||||
.v2-record-card, .v2-single-map-card { min-width: 0; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-record-card > header { display: flex; height: 40px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 14px; }
|
||||
.v2-record-card > header strong { font-size: 12px; }
|
||||
.v2-record-card > header span, .v2-record-card > header a { color: var(--v2-muted); text-decoration: none; font-size: 9px; }
|
||||
.v2-single-map-card { position: relative; display: flex; min-height: 300px; flex-direction: column; }
|
||||
.v2-single-map-card .v2-fleet-map { flex: 1; }
|
||||
.v2-single-map-card .v2-map-legend { display: none; }
|
||||
.v2-single-map-card footer { display: flex; min-height: 38px; align-items: center; justify-content: space-between; gap: 16px; padding: 0 14px; color: #64748b; font-size: 9px; }
|
||||
.v2-single-map-card footer span { display: inline-flex; min-width: 0; align-items: center; gap: 7px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-single-map-card footer time { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.v2-archive-card { grid-column: 2; grid-row: 1; }
|
||||
.v2-record-list { margin: 0; padding: 8px 14px 4px; }
|
||||
.v2-record-list > div { display: grid; grid-template-columns: 92px minmax(0, 1fr); padding: 6px 0; font-size: 10px; }
|
||||
.v2-record-list dt { color: var(--v2-muted); }
|
||||
.v2-record-list dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.v2-record-list dd i { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: #aab3c0; }
|
||||
.v2-record-list dd i.is-online { background: var(--v2-green); }
|
||||
.v2-record-note { margin: 3px 14px 12px; border-radius: 6px; background: #f7f9fc; padding: 8px 10px; color: #79869a; font-size: 8px; line-height: 1.5; }
|
||||
.v2-profile-heading { display: flex; align-items: center; gap: 8px; }.v2-profile-heading button { border: 0; border-radius: 5px; background: var(--v2-blue-soft); padding: 4px 7px; color: var(--v2-blue); font-size: 9px; cursor: pointer; }.v2-profile-form { display: grid; grid-template-columns: 1fr 1fr; gap: 7px 10px; padding: 10px 14px 12px; }.v2-profile-form label { display: grid; gap: 3px; color: var(--v2-muted); font-size: 8px; }.v2-profile-form input, .v2-profile-form select { min-width: 0; height: 29px; border: 1px solid var(--v2-border); border-radius: 5px; background: #fff; padding: 0 7px; color: var(--v2-text); font-size: 9px; }.v2-profile-form > p { grid-column: 1 / -1; margin: 0; color: var(--v2-red); font-size: 8px; }.v2-profile-form footer { display: flex; grid-column: 1 / -1; justify-content: flex-end; gap: 7px; }.v2-profile-form footer button { height: 28px; border: 1px solid var(--v2-border); border-radius: 5px; background: #fff; padding: 0 10px; font-size: 9px; cursor: pointer; }.v2-profile-form footer button.is-primary { border-color: var(--v2-blue); background: var(--v2-blue); color: #fff; }
|
||||
.v2-live-card { grid-column: 2; grid-row: 2; }
|
||||
.v2-live-card > header span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.v2-live-grid { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.v2-live-grid > div { min-width: 0; padding: 12px 14px; }
|
||||
.v2-live-grid > div + div { border-left: 1px solid var(--v2-border); }
|
||||
.v2-live-grid > div:nth-child(4) { border-left: 0; }
|
||||
.v2-live-grid > div:nth-child(n+4) { border-top: 1px solid var(--v2-border); }
|
||||
.v2-live-grid small { display: block; color: var(--v2-muted); font-size: 9px; }
|
||||
.v2-live-grid strong { display: block; margin-top: 5px; overflow: hidden; font-size: 17px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.v2-live-grid em { margin-left: 3px; color: var(--v2-muted); font-size: 8px; font-style: normal; font-weight: 500; }
|
||||
.v2-telemetry-card { grid-column: 1; grid-row: 2 / span 2; }
|
||||
.v2-telemetry-card nav { display: flex; height: 42px; align-items: stretch; border-bottom: 1px solid var(--v2-border); padding: 0 8px; overflow-x: auto; }
|
||||
.v2-telemetry-card nav button { position: relative; min-width: 78px; border: 0; background: transparent; color: #64748b; cursor: pointer; font-size: 10px; }
|
||||
.v2-telemetry-card nav button.is-active { color: var(--v2-blue); font-weight: 700; }
|
||||
.v2-telemetry-card nav button.is-active::after { position: absolute; right: 10px; bottom: 0; left: 10px; height: 2px; border-radius: 2px; background: var(--v2-blue); content: ''; }
|
||||
.v2-telemetry-card nav button span { margin-left: 3px; color: #9aa6b7; font-size: 8px; }
|
||||
.v2-telemetry-list { display: grid; grid-template-columns: 1fr 1fr; padding: 6px 14px; }
|
||||
.v2-telemetry-list > div { display: grid; min-width: 0; grid-template-columns: minmax(130px, 1fr) auto 58px; align-items: center; gap: 10px; border-bottom: 1px solid #eef2f7; padding: 9px 8px; font-size: 10px; }
|
||||
.v2-telemetry-list > div:nth-child(odd) { border-right: 1px solid var(--v2-border); }
|
||||
.v2-telemetry-list > div > span { min-width: 0; color: #556276; }
|
||||
.v2-telemetry-list > div > span small { display: block; margin-top: 3px; overflow: hidden; color: #a0aaba; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-telemetry-list strong { font-size: 10px; font-variant-numeric: tabular-nums; }
|
||||
.v2-telemetry-list strong em { color: #8a96a8; font-size: 8px; font-style: normal; font-weight: 500; }
|
||||
.v2-telemetry-list time { display: grid; justify-items: end; gap: 3px; color: #8a96a8; font-size: 8px; text-align: right; }
|
||||
.v2-telemetry-list time i { border-radius: 8px; background: #eef2f7; padding: 1px 5px; color: #778397; font-size: 7px; font-style: normal; }
|
||||
.v2-telemetry-list time i.is-good { background: #e8f8f1; color: #16845b; }
|
||||
.v2-telemetry-list time i.is-stale { background: #fff6df; color: #a66b00; }
|
||||
.v2-telemetry-list time i.is-warning { background: #fff0f0; color: var(--v2-red); }
|
||||
.v2-telemetry-list .v2-empty-compact { display: flex; min-height: 94px; grid-column: 1 / -1; justify-content: center; border: 0; }
|
||||
.v2-telemetry-list .v2-empty-compact.is-error { color: var(--v2-red); }
|
||||
.v2-telemetry-card footer { display: flex; align-items: center; gap: 6px; margin: 8px 10px 10px; border: 1px solid var(--v2-border); border-radius: 7px; padding: 8px 10px; color: #718096; font-size: 9px; }
|
||||
.v2-telemetry-card footer b { color: #526176; font-size: 9px; }
|
||||
.v2-telemetry-card footer small { margin-left: auto; color: #8a96a8; font-size: 8px; }
|
||||
.v2-events-card { grid-column: 2; grid-row: 3; }
|
||||
.v2-events-card > header a { color: var(--v2-blue); }
|
||||
.v2-event-list { padding: 2px 14px 8px; }
|
||||
.v2-event-row { position: relative; display: grid; min-height: 40px; grid-template-columns: 23px minmax(0, 1fr) auto; align-items: center; gap: 7px; }
|
||||
.v2-event-row:not(:last-child)::after { position: absolute; top: 31px; bottom: -9px; left: 10px; width: 1px; background: var(--v2-border); content: ''; }
|
||||
.v2-event-icon { z-index: 1; display: grid; width: 20px; height: 20px; place-items: center; border-radius: 50%; background: #eef3f8; color: #8492a6; }
|
||||
.v2-event-row.is-success .v2-event-icon { background: #e8f8f1; color: var(--v2-green); }
|
||||
.v2-event-row.is-error .v2-event-icon { background: #fff0f0; color: var(--v2-red); }
|
||||
.v2-event-row.is-warning .v2-event-icon { background: #fff7e5; color: var(--v2-orange); }
|
||||
.v2-event-row strong { font-size: 9px; }
|
||||
.v2-event-row p { margin: 2px 0 0; overflow: hidden; color: #7f8b9d; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-event-row time { color: #8d99aa; font-size: 8px; font-variant-numeric: tabular-nums; }
|
||||
.v2-empty-compact { display: flex; min-height: 80px; align-items: center; color: var(--v2-muted); font-size: 10px; }
|
||||
|
||||
.v2-track-page { display: flex; height: 100%; min-height: 0; flex-direction: column; gap: 12px; overflow: hidden; padding: 12px 16px 16px; }
|
||||
.v2-track-toolbar { display: grid; flex: 0 0 auto; grid-template-columns: minmax(210px, 1.15fr) minmax(170px, .85fr) minmax(170px, .85fr) minmax(140px, .65fr) auto auto; align-items: end; gap: 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 11px 13px; box-shadow: var(--v2-shadow); }
|
||||
.v2-track-toolbar label { display: flex; min-width: 0; flex-direction: column; gap: 6px; color: var(--v2-muted); font-size: 9px; }
|
||||
.v2-track-toolbar label > div { display: flex; height: 36px; align-items: center; gap: 7px; border: 1px solid #dce4ef; border-radius: 7px; padding: 0 10px; color: #8996a8; }
|
||||
.v2-track-toolbar input, .v2-track-toolbar select { min-width: 0; height: 36px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 9px; color: #435168; outline: 0; font-size: 10px; }
|
||||
.v2-track-toolbar label > div input { height: auto; flex: 1; border: 0; padding: 0; }
|
||||
.v2-track-toolbar input:focus, .v2-track-toolbar select:focus, .v2-track-toolbar label > div:focus-within { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
|
||||
.v2-track-toolbar button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.v2-track-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(540px, 1fr) 330px; gap: 12px; }
|
||||
.v2-track-main { display: grid; min-width: 0; min-height: 0; grid-template-rows: 34px minmax(280px, 1fr) 58px 108px; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-track-coverage { display: flex; min-width: 0; align-items: center; gap: 10px; border-bottom: 1px solid var(--v2-border); padding: 0 13px; background: #f8fbff; color: #617086; font-size: 8px; }
|
||||
.v2-track-coverage::before { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: var(--v2-green); content: ''; }
|
||||
.v2-track-coverage.is-limited { background: #fff8ed; color: #8b5b19; }
|
||||
.v2-track-coverage.is-limited::before { background: var(--v2-orange); }
|
||||
.v2-track-coverage.is-empty::before { background: #9aa7b8; }
|
||||
.v2-track-coverage strong { color: inherit; font-size: 9px; white-space: nowrap; }
|
||||
.v2-track-coverage span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-track-coverage em { margin-left: auto; color: inherit; font-style: normal; white-space: nowrap; }
|
||||
.v2-track-canvas-wrap { position: relative; min-height: 0; overflow: hidden; }
|
||||
.v2-track-map, .v2-track-map-canvas { position: absolute; inset: 0; }
|
||||
.v2-track-loading { position: absolute; z-index: 20; top: 12px; left: 50%; display: flex; height: 32px; align-items: center; gap: 7px; transform: translateX(-50%); border: 1px solid var(--v2-border); border-radius: 7px; background: rgba(255,255,255,.94); padding: 0 12px; color: #637083; box-shadow: var(--v2-shadow); font-size: 9px; }
|
||||
.v2-track-map-legend { position: absolute; right: 14px; bottom: 12px; display: flex; height: 32px; align-items: center; gap: 12px; border: 1px solid rgba(220,228,239,.9); border-radius: 7px; background: rgba(255,255,255,.92); padding: 0 11px; color: #637083; box-shadow: 0 7px 18px rgba(21,32,51,.08); backdrop-filter: blur(8px); font-size: 8px; }
|
||||
.v2-track-map-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.v2-track-map-legend i { width: 7px; height: 7px; border-radius: 50%; background: var(--v2-blue); }
|
||||
.v2-track-map-legend i.is-start { background: var(--v2-green); }
|
||||
.v2-track-map-legend i.is-end { background: var(--v2-red); }
|
||||
.v2-track-map-legend b { margin-left: 3px; font-weight: 600; }
|
||||
.v2-track-marker { display: grid; width: 24px; height: 24px; place-items: center; border: 3px solid #fff; border-radius: 50%; background: var(--v2-blue); color: #fff; box-shadow: 0 3px 10px rgba(21,32,51,.25); font-size: 8px; font-weight: 800; }
|
||||
.v2-track-marker.is-start { background: var(--v2-green); }
|
||||
.v2-track-marker.is-end { background: var(--v2-red); }
|
||||
.v2-track-marker.is-event { width: 21px; height: 21px; border-width: 2px; }
|
||||
.v2-track-current-marker { display: grid; width: 28px; height: 28px; place-items: center; border: 2px solid rgba(18,104,243,.22); border-radius: 50%; background: rgba(18,104,243,.15); box-shadow: 0 0 0 6px rgba(18,104,243,.08); }
|
||||
.v2-track-current-marker span { width: 12px; height: 12px; border: 3px solid #fff; border-radius: 50%; background: var(--v2-blue); box-shadow: 0 2px 7px rgba(18,104,243,.45); }
|
||||
.v2-track-empty { display: flex; height: 100%; align-items: center; justify-content: center; flex-direction: column; color: #8b98aa; text-align: center; }
|
||||
.v2-track-empty strong { margin-top: 10px; color: #4e5b6f; font-size: 13px; }
|
||||
.v2-track-empty p { margin: 6px 0 0; font-size: 9px; }
|
||||
.v2-track-timeline { display: grid; min-width: 0; grid-template-columns: 142px minmax(0, 1fr); align-items: center; gap: 10px; border-top: 1px solid var(--v2-border); border-bottom: 1px solid var(--v2-border); padding: 7px 13px; color: #657286; font-size: 8px; }
|
||||
.v2-track-timeline header { display: flex; flex-direction: column; gap: 3px; }
|
||||
.v2-track-timeline header strong { color: #3f4e64; font-size: 9px; }
|
||||
.v2-track-timeline header span { color: #8793a5; }
|
||||
.v2-track-timeline > div { display: flex; min-width: 0; height: 24px; overflow: hidden; border-radius: 5px; background: #edf2f7; }
|
||||
.v2-track-timeline button { display: flex; min-width: 8px; flex: 1; align-items: center; justify-content: center; gap: 4px; overflow: hidden; border: 0; border-right: 1px solid rgba(255,255,255,.8); background: #dff3e8; padding: 0 4px; color: #26734d; cursor: pointer; }
|
||||
.v2-track-timeline button:hover { filter: brightness(.96); }
|
||||
.v2-track-timeline button.is-stopped { background: #fff0cf; color: #92600d; }
|
||||
.v2-track-timeline button.is-gap { background: #fde4e4; color: #a53b3b; }
|
||||
.v2-track-timeline button i { width: 5px; height: 5px; flex: 0 0 5px; border-radius: 50%; background: currentColor; }
|
||||
.v2-track-timeline button span { overflow: hidden; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-track-timeline > em { grid-column: 2; margin-top: -5px; color: #9a6b20; font-size: 7px; font-style: normal; }
|
||||
.v2-track-timeline.is-empty { display: flex; justify-content: center; color: #8996a8; }
|
||||
.v2-track-playback { display: grid; grid-template-columns: 220px minmax(230px, 1fr) minmax(320px, 1.3fr); align-items: center; min-width: 0; padding: 10px 13px; }
|
||||
.v2-play-controls { min-width: 0; }
|
||||
.v2-play-controls small { display: block; margin-bottom: 6px; color: #64748b; font-size: 9px; }
|
||||
.v2-play-controls p { display: flex; gap: 6px; margin: 0; }
|
||||
.v2-play-controls button, .v2-play-controls select { display: grid; width: 34px; height: 32px; place-items: center; border: 1px solid #dce4ef; border-radius: 6px; background: #fff; color: #536177; cursor: pointer; }
|
||||
.v2-play-controls button:first-child { border-color: var(--v2-blue); background: var(--v2-blue); color: #fff; }
|
||||
.v2-play-controls button:disabled { opacity: .35; cursor: not-allowed; }
|
||||
.v2-play-controls select { display: block; width: 55px; padding: 0 7px; font-size: 9px; }
|
||||
.v2-play-progress { min-width: 0; border-left: 1px solid var(--v2-border); padding: 0 18px; }
|
||||
.v2-play-progress header { display: flex; justify-content: space-between; color: #7f8b9d; font-size: 8px; }
|
||||
.v2-play-progress header strong { color: #48566b; font-size: 10px; }
|
||||
.v2-play-progress input { width: 100%; height: 4px; margin: 10px 0 6px; accent-color: var(--v2-blue); cursor: pointer; }
|
||||
.v2-play-progress footer { color: #738095; font-size: 8px; }
|
||||
.v2-current-metrics { display: grid; min-width: 0; grid-template-columns: .7fr .9fr 1.6fr .8fr; margin: 0; border-left: 1px solid var(--v2-border); }
|
||||
.v2-current-metrics > div { min-width: 0; padding: 4px 11px; }
|
||||
.v2-current-metrics > div + div { border-left: 1px solid var(--v2-border); }
|
||||
.v2-current-metrics dt { color: #8290a3; font-size: 8px; }
|
||||
.v2-current-metrics dd { margin: 7px 0 0; overflow: hidden; font-size: 12px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.v2-current-metrics em { margin-left: 3px; color: #8390a2; font-size: 7px; font-style: normal; font-weight: 500; }
|
||||
.v2-track-inspector { min-width: 0; min-height: 0; overflow: auto; overscroll-behavior: contain; }
|
||||
.v2-track-inspector > section, .v2-track-inspector.is-empty { overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-track-inspector > section + section { margin-top: 10px; }
|
||||
.v2-track-inspector section > header { display: flex; height: 38px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 13px; }
|
||||
.v2-track-inspector section > header strong { font-size: 11px; }
|
||||
.v2-track-inspector section > header span, .v2-track-inspector section > header b { color: #8491a3; font-size: 8px; font-weight: 500; }
|
||||
.v2-track-vehicle { display: flex; align-items: center; gap: 10px; padding: 13px; }
|
||||
.v2-track-vehicle > span { display: grid; width: 30px; height: 30px; flex: 0 0 30px; place-items: center; border-radius: 7px; background: var(--v2-blue-soft); color: var(--v2-blue); }
|
||||
.v2-track-vehicle > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.v2-track-vehicle strong { font-size: 13px; }
|
||||
.v2-track-vehicle small { overflow: hidden; color: #778498; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-track-summary { margin: 0; padding: 8px 13px; }
|
||||
.v2-track-summary > div { display: grid; grid-template-columns: 88px minmax(0, 1fr); padding: 5px 0; font-size: 9px; }
|
||||
.v2-track-summary dt { color: #8190a4; }
|
||||
.v2-track-summary dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.v2-track-sources > div { display: flex; flex-wrap: wrap; gap: 7px; padding: 10px 13px; }
|
||||
.v2-track-sources > div span { display: flex; min-width: 128px; flex: 1; flex-direction: column; gap: 3px; border: 1px solid #e2e8f0; border-radius: 6px; background: #f8fafc; padding: 7px 8px; }
|
||||
.v2-track-sources > div strong { font-size: 9px; }
|
||||
.v2-track-sources > div small { color: #8390a2; font-size: 7px; }
|
||||
.v2-track-sources > p { margin: 0 13px 10px; border-radius: 5px; background: #fff7ed; padding: 7px 8px; color: #a16207; font-size: 8px; line-height: 1.5; }
|
||||
.v2-track-quality > p { margin: 0 13px 10px; border-radius: 5px; background: #f1f8f4; padding: 7px 8px; color: #3d7356; font-size: 8px; line-height: 1.5; }
|
||||
.v2-track-quality.is-warning > p { background: #fff7ed; color: #9a620e; }
|
||||
.v2-track-events > div { padding: 3px 11px 8px; }
|
||||
.v2-track-events button { display: grid; width: 100%; min-height: 36px; grid-template-columns: 20px minmax(0, 1fr) auto; align-items: center; gap: 8px; border: 0; background: #fff; padding: 3px 0; text-align: left; cursor: pointer; }
|
||||
.v2-track-events button:hover { background: #f8fbff; }
|
||||
.v2-track-events button > i { display: grid; width: 18px; height: 18px; place-items: center; border-radius: 50%; background: var(--v2-blue); color: #fff; font-size: 7px; font-style: normal; font-weight: 800; }
|
||||
.v2-track-events button > i.is-start { background: var(--v2-green); }
|
||||
.v2-track-events button > i.is-end { background: var(--v2-red); }
|
||||
.v2-track-events button > i.is-warning { background: var(--v2-orange); }
|
||||
.v2-track-events button > span { display: flex; min-width: 0; justify-content: space-between; gap: 8px; }
|
||||
.v2-track-events button strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-track-events button small { color: #8491a3; font-size: 8px; }
|
||||
.v2-track-events button em { color: #718096; font-size: 8px; font-style: normal; }
|
||||
.v2-track-inspector.is-empty { display: flex; align-items: center; justify-content: center; flex-direction: column; color: #78869a; text-align: center; }
|
||||
.v2-track-inspector.is-empty strong { color: #4c596d; font-size: 12px; }
|
||||
.v2-track-inspector.is-empty p { max-width: 220px; margin: 7px 0 0; font-size: 9px; line-height: 1.6; }
|
||||
|
||||
.v2-history-page { display: flex; height: 100%; min-height: 0; flex-direction: column; gap: 10px; overflow: hidden; padding: 12px 16px 16px; }
|
||||
.v2-history-toolbar { display: grid; flex: 0 0 auto; grid-template-columns: minmax(230px, 1.2fr) minmax(160px, .8fr) minmax(160px, .8fr) 120px 130px auto auto auto; align-items: end; gap: 8px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 10px 12px; box-shadow: var(--v2-shadow); }
|
||||
.v2-history-toolbar label { display: flex; min-width: 0; flex-direction: column; gap: 5px; color: var(--v2-muted); font-size: 8px; }
|
||||
.v2-history-toolbar label > div { display: flex; height: 34px; align-items: center; gap: 7px; border: 1px solid #dce4ef; border-radius: 7px; padding: 0 9px; color: #8996a8; }
|
||||
.v2-history-toolbar input, .v2-history-toolbar select { min-width: 0; height: 34px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 8px; color: #435168; outline: 0; font-size: 9px; }
|
||||
.v2-history-toolbar label > div input { height: auto; flex: 1; border: 0; padding: 0; }
|
||||
.v2-history-toolbar input:focus, .v2-history-toolbar select:focus, .v2-history-toolbar label > div:focus-within { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
|
||||
.v2-history-toolbar button { height: 34px; padding: 0 11px; white-space: nowrap; }
|
||||
.v2-history-toolbar button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.v2-history-metrics { display: flex; min-height: 42px; flex: 0 0 auto; align-items: center; gap: 7px; overflow-x: auto; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 6px 11px; box-shadow: var(--v2-shadow); }
|
||||
.v2-history-metrics > strong { margin-right: 4px; white-space: nowrap; font-size: 10px; }
|
||||
.v2-history-metrics button { display: inline-flex; height: 27px; flex: 0 0 auto; align-items: center; gap: 6px; border: 1px solid #dfe6ef; border-radius: 6px; background: #fff; padding: 0 9px; color: #657286; cursor: pointer; font-size: 8px; }
|
||||
.v2-history-metrics button i { width: 6px; height: 6px; border: 1px solid #9aa6b7; border-radius: 50%; }
|
||||
.v2-history-metrics button.is-active { border-color: #b8d1fa; background: var(--v2-blue-soft); color: var(--v2-blue); }
|
||||
.v2-history-metrics button.is-active i { border-color: var(--v2-blue); background: var(--v2-blue); }
|
||||
.v2-history-metrics > span { color: var(--v2-muted); font-size: 8px; }
|
||||
.v2-history-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(660px, 1fr) 310px; gap: 10px; }
|
||||
.v2-history-main { display: grid; min-width: 0; min-height: 0; grid-template-rows: 58px 205px minmax(260px, 1fr); gap: 9px; }
|
||||
.v2-history-summary { display: grid; grid-template-columns: repeat(4, 1fr); overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-history-summary > div { position: relative; display: flex; min-width: 0; justify-content: center; flex-direction: column; padding: 0 16px; }
|
||||
.v2-history-summary > div + div::before { position: absolute; inset: 12px auto 12px 0; width: 1px; background: var(--v2-border); content: ''; }
|
||||
.v2-history-summary small { color: var(--v2-muted); font-size: 8px; }
|
||||
.v2-history-summary strong { margin-top: 5px; overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-history-trend { display: flex; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-history-trend > header { display: flex; min-height: 37px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 12px; }
|
||||
.v2-history-trend > header strong { font-size: 10px; }
|
||||
.v2-history-trend > header div { display: flex; gap: 14px; color: #657286; font-size: 8px; }
|
||||
.v2-history-trend > header span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.v2-history-trend > header i { width: 14px; height: 2px; border-radius: 2px; }
|
||||
.v2-history-trend-panels { display: grid; min-height: 0; flex: 1; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; overflow: auto; padding: 8px 10px 5px; }
|
||||
.v2-history-trend-panels article { min-width: 0; overflow: hidden; border: 1px solid #e6ebf2; border-radius: 6px; }
|
||||
.v2-history-trend-panels article > header { display: flex; height: 25px; align-items: center; justify-content: space-between; padding: 0 8px; background: #f8fafc; }
|
||||
.v2-history-trend-panels article > header strong { font-size: 9px; }
|
||||
.v2-history-trend-panels article > header span { color: var(--v2-muted); font-size: 7px; }
|
||||
.v2-history-trend-panels svg { display: block; width: 100%; height: 105px; }
|
||||
.v2-history-trend-panels article > footer { display: flex; min-height: 22px; align-items: center; gap: 5px 10px; overflow-x: auto; padding: 0 8px; color: #657286; font-size: 7px; white-space: nowrap; }
|
||||
.v2-history-trend-panels article > footer span { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.v2-history-trend-panels article > footer i { width: 10px; height: 2px; flex: 0 0 auto; }
|
||||
.v2-chart-grid line { stroke: #e9eef5; stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||||
.v2-chart-axis text { fill: #7b8798; font-size: 8px; font-variant-numeric: tabular-nums; }
|
||||
.v2-chart-axis text:first-child, .v2-chart-axis text:nth-child(2) { text-anchor: end; }
|
||||
.v2-history-chart-empty { display: flex; flex: 1; align-items: center; justify-content: center; color: var(--v2-muted); font-size: 9px; }
|
||||
.v2-history-trend-evidence { display: block; flex: 0 0 auto; overflow: hidden; border-top: 1px solid #eef2f7; padding: 4px 10px; color: var(--v2-muted); font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-history-table-card { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-history-table-card > header { display: flex; min-height: 38px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 10px 0 12px; }
|
||||
.v2-history-table-card > header strong { font-size: 10px; }
|
||||
.v2-history-table-card > header div { display: flex; gap: 6px; }
|
||||
.v2-history-table-card > header button, .v2-history-table-card > header select { display: inline-flex; height: 27px; align-items: center; gap: 5px; border: 1px solid #dfe6ef; border-radius: 5px; background: #fff; padding: 0 8px; color: #657286; cursor: pointer; font-size: 8px; }
|
||||
.v2-history-table-scroll { position: relative; min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
|
||||
.v2-history-table-scroll table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; color: #4e5b6f; font-size: 8px; }
|
||||
.v2-history-table-scroll th { position: sticky; z-index: 3; top: 0; height: 31px; border-bottom: 1px solid var(--v2-border); background: #f8fafc; color: #607086; text-align: left; white-space: nowrap; font-weight: 700; }
|
||||
.v2-history-table-scroll td, .v2-history-table-scroll th { max-width: 170px; border-right: 1px solid #eef2f7; padding: 0 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-history-table-scroll td { height: 30px; border-bottom: 1px solid #eef2f7; content-visibility: auto; font-variant-numeric: tabular-nums; }
|
||||
.v2-history-table-card.is-comfortable .v2-history-table-scroll td { height: 40px; }
|
||||
.v2-history-table-scroll tr:hover td, .v2-history-table-scroll tr.is-selected td { background: #f2f7ff; }
|
||||
.v2-history-table-scroll td button { border: 0; background: transparent; color: var(--v2-blue); cursor: pointer; font-size: 8px; }
|
||||
.v2-history-table-scroll input { width: 13px; height: 13px; accent-color: var(--v2-blue); }
|
||||
.v2-quality { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.v2-quality i { width: 6px; height: 6px; border-radius: 50%; background: var(--v2-green); }
|
||||
.v2-quality:not(.is-normal) i { background: var(--v2-orange); }
|
||||
.v2-history-empty { position: sticky; left: 0; display: flex; min-height: 90px; align-items: center; justify-content: center; color: var(--v2-muted); font-size: 9px; }
|
||||
.v2-history-table-card > footer { display: flex; min-height: 38px; align-items: center; justify-content: space-between; border-top: 1px solid var(--v2-border); padding: 0 10px; color: #657286; font-size: 8px; }
|
||||
.v2-history-table-card > footer div { display: flex; gap: 5px; }
|
||||
.v2-history-table-card > footer button, .v2-history-table-card > footer select { height: 26px; border: 1px solid #dfe6ef; border-radius: 5px; background: #fff; padding: 0 8px; color: #5f6e83; font-size: 8px; }
|
||||
.v2-history-table-card > footer button:disabled { opacity: .35; }
|
||||
.v2-history-side { min-width: 0; min-height: 0; overflow: auto; overscroll-behavior: contain; }
|
||||
.v2-history-side > section { overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-history-side > section + section { margin-top: 9px; }
|
||||
.v2-history-side section > header { display: flex; height: 38px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 12px; }
|
||||
.v2-history-side section > header strong { font-size: 10px; }
|
||||
.v2-history-side section > header span { color: var(--v2-muted); font-size: 8px; }
|
||||
.v2-history-evidence > header button { border: 0; background: transparent; color: #718096; cursor: pointer; }
|
||||
.v2-history-evidence > dl { margin: 0; padding: 8px 12px; border-bottom: 1px solid var(--v2-border); }
|
||||
.v2-history-evidence > dl > div { display: grid; grid-template-columns: 78px minmax(0, 1fr); padding: 5px 0; font-size: 8px; }
|
||||
.v2-history-evidence dt { color: #7f8c9f; }
|
||||
.v2-history-evidence dd { margin: 0; overflow-wrap: anywhere; text-align: right; }
|
||||
.v2-history-evidence dd i { display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: var(--v2-green); }
|
||||
.v2-evidence-values { padding: 10px 12px; }
|
||||
.v2-evidence-values > strong { display: block; margin-bottom: 5px; font-size: 9px; }
|
||||
.v2-evidence-values > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #eef2f7; padding: 6px 0; }
|
||||
.v2-evidence-values span { min-width: 0; color: #617087; font-size: 8px; }
|
||||
.v2-evidence-values span small { display: block; margin-top: 2px; overflow: hidden; color: #a0aaba; font-size: 6px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-evidence-values b { font-size: 8px; font-weight: 600; }
|
||||
.v2-history-evidence > footer { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid var(--v2-border); padding: 9px 12px; color: #718096; font-size: 8px; }
|
||||
.v2-history-evidence > footer b { overflow: hidden; color: var(--v2-blue); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-history-side-empty { display: flex; min-height: 92px; align-items: center; justify-content: center; padding: 14px; color: var(--v2-muted); text-align: center; font-size: 8px; line-height: 1.6; }
|
||||
.v2-export-jobs > div { padding: 4px 11px 8px; }
|
||||
.v2-export-jobs article { display: grid; min-height: 42px; grid-template-columns: 7px minmax(0, 1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #eef2f7; }
|
||||
.v2-export-jobs article > i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }
|
||||
.v2-export-jobs article > i.is-running { background: var(--v2-blue); }
|
||||
.v2-export-jobs article > i.is-completed { background: var(--v2-green); }
|
||||
.v2-export-jobs article > i.is-failed { background: var(--v2-red); }
|
||||
.v2-export-jobs article > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.v2-export-jobs article strong { overflow: hidden; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-export-jobs article small { overflow: hidden; color: #8290a3; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-export-jobs article a { display: inline-flex; align-items: center; gap: 4px; color: var(--v2-blue); text-decoration: none; font-size: 7px; }
|
||||
.v2-export-jobs article em { color: #7f8c9f; font-size: 7px; font-style: normal; }
|
||||
|
||||
.v2-access-page { display: flex; height: 100%; min-height: 0; flex-direction: column; gap: 9px; overflow: hidden; padding: 12px 14px 14px; }
|
||||
.v2-access-filter { display: grid; flex: 0 0 auto; grid-template-columns: minmax(210px, 1.15fr) repeat(4, minmax(120px, .7fr)) auto auto; align-items: end; gap: 8px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 9px 11px; box-shadow: var(--v2-shadow); }
|
||||
.v2-access-filter label { display: flex; min-width: 0; flex-direction: column; gap: 5px; color: var(--v2-muted); font-size: 8px; }
|
||||
.v2-access-filter label > div { display: flex; height: 34px; align-items: center; gap: 7px; border: 1px solid #dce4ef; border-radius: 6px; padding: 0 9px; color: #8996a8; }
|
||||
.v2-access-filter input, .v2-access-filter select { min-width: 0; height: 34px; border: 1px solid #dce4ef; border-radius: 6px; background: #fff; padding: 0 8px; color: #435168; outline: 0; font-size: 9px; }
|
||||
.v2-access-filter label > div input { height: auto; flex: 1; border: 0; padding: 0; }
|
||||
.v2-access-filter label > div:focus-within, .v2-access-filter select:focus { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
|
||||
.v2-access-filter button { height: 34px; white-space: nowrap; }
|
||||
.v2-access-advanced { grid-column: 1 / -1; border-top: 1px solid var(--v2-border); padding-top: 6px; }
|
||||
.v2-access-advanced summary { width: fit-content; color: var(--v2-blue); font-size: 9px; cursor: pointer; }
|
||||
.v2-access-advanced > div { display: grid; grid-template-columns: repeat(6, minmax(130px, 1fr)); gap: 8px; padding-top: 8px; }
|
||||
.v2-access-kpis { display: grid; min-height: 68px; flex: 0 0 auto; grid-template-columns: repeat(7, minmax(82px, 1fr)); overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-access-kpis button { position: relative; display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr) auto; align-content: center; border: 0; background: #fff; padding: 9px 15px; text-align: left; cursor: pointer; }
|
||||
.v2-access-kpis button + button::before { position: absolute; inset: 12px auto 12px 0; width: 1px; background: var(--v2-border); content: ''; }
|
||||
.v2-access-kpis button:hover { background: #f8fbff; }
|
||||
.v2-access-kpis small { grid-column: 1 / -1; color: var(--v2-muted); font-size: 9px; }
|
||||
.v2-access-kpis strong { margin-top: 6px; overflow: hidden; font-size: 19px; line-height: 1; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.v2-access-kpis em { align-self: end; color: #7f8c9f; font-size: 8px; font-style: normal; }
|
||||
.v2-access-kpis .is-online strong, .v2-access-kpis .is-today strong { color: var(--v2-green); }
|
||||
.v2-access-kpis .is-delay strong { color: var(--v2-orange); }
|
||||
.v2-access-kpis .is-identity strong { color: #a65a12; }
|
||||
.v2-access-kpis .is-never strong, .v2-access-kpis .is-offline strong { color: #64748b; }
|
||||
.v2-access-protocols { flex: 0 0 auto; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 9px 12px 10px; box-shadow: var(--v2-shadow); }
|
||||
.v2-access-protocols header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.v2-access-protocols header strong { font-size: 10px; }
|
||||
.v2-access-protocols header span { color: var(--v2-muted); font-size: 7px; }
|
||||
.v2-access-segments { display: flex; height: 6px; gap: 2px; margin-top: 8px; overflow: hidden; border-radius: 6px; background: #edf1f6; }
|
||||
.v2-access-segments i { display: block; min-width: 2px; height: 100%; }
|
||||
.v2-access-legends { display: flex; gap: 20px; margin-top: 8px; overflow-x: auto; color: #657286; font-size: 7px; white-space: nowrap; }
|
||||
.v2-access-legends span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.v2-access-legends span > i { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.v2-access-legends b { color: #455268; font-weight: 700; }
|
||||
.v2-access-legends em { color: #8a97a9; font-style: normal; }
|
||||
.v2-access-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(680px, 1fr) 300px; gap: 9px; }
|
||||
.v2-access-identity-queue { flex: 0 0 auto; border: 1px solid #efd7b5; border-radius: var(--v2-radius); background: #fffaf3; color: var(--v2-text); }
|
||||
.v2-access-identity-queue summary { display: flex; min-height: 35px; align-items: center; justify-content: space-between; gap: 12px; padding: 7px 12px; cursor: pointer; list-style-position: inside; }
|
||||
.v2-access-identity-queue summary span { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.v2-access-identity-queue summary strong { min-width: 22px; border-radius: 12px; background: #a65a12; padding: 2px 7px; color: #fff; font-size: 10px; text-align: center; }
|
||||
.v2-access-identity-queue summary em { color: #8a6440; font-size: 10px; font-style: normal; }
|
||||
.v2-access-identity-queue > div { display: grid; max-height: 112px; grid-template-columns: repeat(5, minmax(180px, 1fr)); gap: 7px; overflow: auto; border-top: 1px solid #efd7b5; padding: 7px; }
|
||||
.v2-access-identity-queue article { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 8px; border: 1px solid #f0dfc7; border-radius: 7px; background: #fff; padding: 7px 8px; }
|
||||
.v2-access-identity-code { overflow: hidden; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-access-identity-queue article dl { display: grid; min-width: 0; grid-column: 1 / -1; gap: 2px; margin: 0; }
|
||||
.v2-access-identity-queue article dl div { display: grid; min-width: 0; grid-template-columns: 45px minmax(0, 1fr); gap: 4px; }
|
||||
.v2-access-identity-queue article dt, .v2-access-identity-queue article dd { overflow: hidden; margin: 0; color: var(--v2-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-access-identity-queue article button { grid-column: 2; grid-row: 1; border: 0; background: transparent; color: var(--v2-blue); font-size: 9px; cursor: pointer; }
|
||||
.v2-access-table-card { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-access-table-card > header { display: flex; min-height: 38px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 9px 0 12px; }
|
||||
.v2-access-table-card > header strong { font-size: 10px; }
|
||||
.v2-access-table-card > header div { display: flex; align-items: center; gap: 5px; }
|
||||
.v2-access-table-card > header span { margin-right: 5px; color: var(--v2-muted); font-size: 7px; }
|
||||
.v2-access-table-card > header button { display: inline-flex; height: 26px; align-items: center; gap: 4px; border: 1px solid #dfe6ef; border-radius: 5px; background: #fff; padding: 0 7px; color: #657286; cursor: pointer; font-size: 7px; }
|
||||
.v2-access-table-card > header button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.v2-access-table-scroll { position: relative; min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
|
||||
.v2-access-table-scroll table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; color: #4e5b6f; font-size: 7px; }
|
||||
.v2-access-table-scroll th { position: sticky; z-index: 3; top: 0; height: 32px; border-bottom: 1px solid var(--v2-border); background: #f8fafc; color: #607086; text-align: left; font-weight: 700; }
|
||||
.v2-access-table-scroll th, .v2-access-table-scroll td { max-width: 150px; border-right: 1px solid #eef2f7; padding: 0 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-access-table-scroll td { height: 31px; border-bottom: 1px solid #eef2f7; content-visibility: auto; font-variant-numeric: tabular-nums; }
|
||||
.v2-access-table-scroll tr:hover td, .v2-access-table-scroll tr.is-selected td { background: #f2f7ff; }
|
||||
.v2-access-table-scroll input { width: 12px; height: 12px; accent-color: var(--v2-blue); }
|
||||
.v2-access-table-scroll td > button { border: 0; background: transparent; color: var(--v2-blue); cursor: pointer; font-size: 7px; }
|
||||
.v2-access-table-scroll td.is-good { color: var(--v2-green); }
|
||||
.v2-access-table-scroll td.is-danger { color: var(--v2-red); }
|
||||
.v2-access-status { display: inline-flex; align-items: center; gap: 5px; color: #64748b; white-space: nowrap; }
|
||||
.v2-access-status i { width: 6px; height: 6px; border-radius: 50%; background: #94a3b8; }
|
||||
.v2-access-status.is-online { color: var(--v2-green); }
|
||||
.v2-access-status.is-online i { background: var(--v2-green); }
|
||||
.v2-access-status.is-offline i { background: #7b8798; }
|
||||
.v2-access-status.is-never_reported i { background: #aab3c0; }
|
||||
.v2-access-status.is-unknown i { background: var(--v2-orange); }
|
||||
.v2-access-loading, .v2-access-empty { position: sticky; left: 0; display: flex; min-height: 72px; align-items: center; justify-content: center; gap: 7px; color: var(--v2-muted); font-size: 8px; }
|
||||
.v2-access-loading { position: absolute; inset: 32px 0 auto; min-height: 34px; background: rgba(255,255,255,.88); backdrop-filter: blur(2px); }
|
||||
.v2-access-loading i { width: 12px; height: 12px; border: 2px solid #cfe0fb; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; }
|
||||
.v2-access-table-card > footer { display: flex; min-height: 38px; align-items: center; justify-content: space-between; border-top: 1px solid var(--v2-border); padding: 0 9px; color: #657286; font-size: 7px; }
|
||||
.v2-access-table-card > footer div { display: flex; gap: 5px; }
|
||||
.v2-access-table-card > footer button, .v2-access-table-card > footer select { height: 25px; border: 1px solid #dfe6ef; border-radius: 5px; background: #fff; padding: 0 7px; color: #5f6e83; font-size: 7px; }
|
||||
.v2-access-table-card > footer button:disabled { opacity: .35; }
|
||||
.v2-access-side { min-width: 0; min-height: 0; overflow: auto; overscroll-behavior: contain; }
|
||||
.v2-access-side > section { overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-access-side > section + section { margin-top: 9px; }
|
||||
.v2-access-side section > header { display: flex; height: 38px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 11px; }
|
||||
.v2-access-side section > header strong { font-size: 10px; }
|
||||
.v2-access-side section > header span { font-size: 7px; }
|
||||
.v2-access-identity { margin: 0; padding: 7px 11px 3px; }
|
||||
.v2-access-identity > div, .v2-access-inspector section dl > div { display: grid; grid-template-columns: 77px minmax(0, 1fr); gap: 8px; padding: 4px 0; font-size: 7px; }
|
||||
.v2-access-inspector dt { color: #7e8b9e; }
|
||||
.v2-access-inspector dd { margin: 0; overflow-wrap: anywhere; color: #46546a; text-align: right; }
|
||||
.v2-access-inspector dd.is-good { color: var(--v2-green); }
|
||||
.v2-access-inspector dd.is-danger { color: var(--v2-red); }
|
||||
.v2-access-vehicle-link { display: block; margin: 3px 11px 9px; color: var(--v2-blue); text-decoration: none; font-size: 7px; }
|
||||
.v2-access-inspector > section { border-top: 1px solid var(--v2-border); padding: 9px 11px; }
|
||||
.v2-access-inspector h3 { margin: 0 0 5px; font-size: 8px; }
|
||||
.v2-access-inspector section dl { margin: 0; }
|
||||
.v2-access-proof p { display: grid; grid-template-columns: 64px 1fr; gap: 8px; margin: 0; padding: 4px 0; color: #778599; font-size: 7px; line-height: 1.5; }
|
||||
.v2-access-proof p b { color: #4c596d; }
|
||||
.v2-access-side-empty { display: flex; min-height: 100px; align-items: center; justify-content: center; padding: 16px; color: var(--v2-muted); text-align: center; font-size: 8px; line-height: 1.6; }
|
||||
.v2-threshold-form { min-width: 0; margin: 0; border: 0; padding: 7px 11px 10px; }
|
||||
.v2-threshold-form:disabled { opacity: .82; }
|
||||
.v2-threshold-form label { position: relative; display: grid; min-height: 30px; grid-template-columns: 84px minmax(0, 1fr); align-items: center; gap: 7px; border-bottom: 1px solid #eef2f7; color: #657286; font-size: 7px; }
|
||||
.v2-threshold-form input, .v2-threshold-form select { min-width: 0; height: 24px; border: 1px solid #dce4ef; border-radius: 5px; background: #fff; padding: 0 22px 0 7px; color: #46546a; outline: 0; font-size: 7px; }
|
||||
.v2-threshold-form em { position: absolute; right: 7px; color: #96a1b1; font-size: 6px; font-style: normal; }
|
||||
.v2-threshold-form > button { display: inline-flex; width: 100%; height: 29px; align-items: center; justify-content: center; gap: 5px; margin-top: 8px; border: 1px solid var(--v2-blue); border-radius: 6px; background: var(--v2-blue); color: #fff; cursor: pointer; font-size: 8px; font-weight: 700; }
|
||||
.v2-threshold-form > button:disabled { opacity: .5; }
|
||||
.v2-threshold-error { margin: 6px 0 0; color: var(--v2-red); font-size: 7px; line-height: 1.4; }
|
||||
.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; }
|
||||
|
||||
.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; }
|
||||
.v2-alert-heading p { display: inline; margin: 0 0 0 12px; color: var(--v2-muted); font-size: 8px; }
|
||||
.v2-alert-heading > div { display: flex; align-items: baseline; }
|
||||
.v2-alert-tabs { display: flex; min-height: 35px; flex: 0 0 auto; gap: 18px; border-bottom: 1px solid var(--v2-border); background: #fff; margin: 0 -12px 8px; padding: 0 16px; }
|
||||
.v2-alert-tabs button { position: relative; display: inline-flex; align-items: center; gap: 5px; border: 0; background: transparent; padding: 0 4px; color: #68768a; cursor: pointer; font-size: 9px; font-weight: 600; }
|
||||
.v2-alert-tabs button.is-active { color: var(--v2-blue); }
|
||||
.v2-alert-tabs button.is-active::after { position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; border-radius: 2px 2px 0 0; background: var(--v2-blue); content: ''; }
|
||||
.v2-alert-tabs b { display: grid; min-width: 15px; height: 15px; place-items: center; border-radius: 8px; background: var(--v2-red); color: #fff; font-size: 7px; }
|
||||
.v2-alert-filter { display: grid; flex: 0 0 auto; grid-template-columns: minmax(180px,1.15fr) repeat(4,minmax(92px,.65fr)) repeat(2,minmax(135px,.8fr)) auto auto; align-items: end; gap: 6px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 7px 9px; box-shadow: var(--v2-shadow); }
|
||||
.v2-alert-filter label { display: flex; min-width: 0; flex-direction: column; gap: 4px; color: var(--v2-muted); font-size: 7px; }
|
||||
.v2-alert-filter label > div { display: flex; height: 30px; align-items: center; gap: 5px; border: 1px solid #dce4ef; border-radius: 5px; padding: 0 7px; color: #8996a8; }
|
||||
.v2-alert-filter input, .v2-alert-filter select { min-width: 0; height: 30px; border: 1px solid #dce4ef; border-radius: 5px; background: #fff; padding: 0 7px; color: #435168; outline: 0; font-size: 8px; }
|
||||
.v2-alert-filter label > div input { height: auto; flex: 1; border: 0; padding: 0; }
|
||||
.v2-alert-filter label > div:focus-within, .v2-alert-filter input:focus, .v2-alert-filter select:focus { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
|
||||
.v2-alert-filter button { height: 30px; padding: 0 10px; white-space: nowrap; font-size: 8px; }
|
||||
.v2-alert-kpis { display: grid; min-height: 62px; flex: 0 0 auto; grid-template-columns: repeat(7,minmax(74px,1fr)); overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; margin-top: 8px; box-shadow: var(--v2-shadow); }
|
||||
.v2-alert-kpis button { position: relative; display: flex; min-width: 0; align-items: center; justify-content: center; flex-direction: column; border: 0; background: #fff; cursor: pointer; }
|
||||
.v2-alert-kpis button + button::before { position: absolute; inset: 11px auto 11px 0; width: 1px; background: var(--v2-border); content: ''; }
|
||||
.v2-alert-kpis button:hover, .v2-alert-kpis .is-unprocessed { background: #fff9f2; }
|
||||
.v2-alert-kpis small { color: var(--v2-muted); font-size: 8px; }
|
||||
.v2-alert-kpis strong { margin-top: 5px; font-size: 18px; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.v2-alert-kpis .is-unprocessed strong, .v2-alert-kpis button:first-child strong { color: var(--v2-red); }
|
||||
.v2-alert-kpis .is-processing strong { color: var(--v2-blue); }.v2-alert-kpis .is-recovered strong { color: var(--v2-green); }.v2-alert-kpis .is-notice strong { color: #7c3aed; }
|
||||
.v2-alert-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(700px,1fr) 320px; gap: 8px; margin-top: 8px; }
|
||||
.v2-alert-table-card { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-alert-table-card > header { display: flex; min-height: 36px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 9px 0 11px; }
|
||||
.v2-alert-table-card > header strong { font-size: 9px; }.v2-alert-table-card > header div { display: flex; align-items: center; gap: 7px; }.v2-alert-table-card > header span { color: var(--v2-muted); font-size: 7px; }
|
||||
.v2-alert-table-card > header button { display: inline-flex; height: 25px; align-items: center; gap: 4px; border: 1px solid #dfe6ef; border-radius: 5px; background: #fff; padding: 0 7px; color: #657286; cursor: pointer; font-size: 7px; }
|
||||
.v2-alert-table-scroll { position: relative; min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
|
||||
.v2-alert-table-scroll table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; color: #4e5b6f; font-size: 7px; }
|
||||
.v2-alert-table-scroll th { position: sticky; z-index: 3; top: 0; height: 31px; border-bottom: 1px solid var(--v2-border); background: #f8fafc; color: #607086; text-align: left; }
|
||||
.v2-alert-table-scroll th, .v2-alert-table-scroll td { max-width: 130px; border-right: 1px solid #eef2f7; padding: 0 7px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-alert-table-scroll td { height: 34px; border-bottom: 1px solid #eef2f7; content-visibility: auto; font-variant-numeric: tabular-nums; cursor: pointer; }
|
||||
.v2-alert-table-scroll td strong, .v2-alert-table-scroll td small { display: block; }.v2-alert-table-scroll td small { margin-top: 2px; color: #8b97a8; font-size: 6px; }
|
||||
.v2-alert-table-scroll tr:hover td, .v2-alert-table-scroll tr.is-selected td { background: #f2f7ff; }.v2-alert-table-scroll tr.is-selected td:first-child { box-shadow: inset 2px 0 var(--v2-blue); }
|
||||
.v2-alert-table-scroll input { width: 12px; height: 12px; accent-color: var(--v2-blue); }
|
||||
.v2-alert-severity { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }.v2-alert-severity i { width: 6px; height: 6px; border-radius: 50%; background: #94a3b8; }.v2-alert-severity.is-critical { color: var(--v2-red); }.v2-alert-severity.is-critical i { background: var(--v2-red); }.v2-alert-severity.is-major { color: var(--v2-orange); }.v2-alert-severity.is-major i { background: var(--v2-orange); }
|
||||
.v2-alert-status { display: inline-flex; border: 1px solid #dce4ef; border-radius: 4px; background: #f8fafc; padding: 2px 5px; color: #657286; white-space: nowrap; }.v2-alert-status.is-unprocessed { border-color: #fecaca; background: #fff1f2; color: #dc2626; }.v2-alert-status.is-processing { border-color: #bfdbfe; background: #eff6ff; color: var(--v2-blue); }.v2-alert-status.is-recovered { border-color: #bbf7d0; background: #f0fdf4; color: var(--v2-green); }
|
||||
.v2-alert-loading, .v2-alert-empty { position: sticky; left: 0; display: flex; min-height: 70px; align-items: center; justify-content: center; gap: 6px; color: var(--v2-muted); font-size: 8px; }.v2-alert-loading { position: absolute; inset: 31px 0 auto; min-height: 32px; background: rgba(255,255,255,.88); }.v2-alert-loading i { width: 11px; height: 11px; border: 2px solid #cfe0fb; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; }
|
||||
.v2-alert-table-card > footer { display: flex; min-height: 36px; align-items: center; justify-content: space-between; border-top: 1px solid var(--v2-border); padding: 0 9px; color: #657286; font-size: 7px; }.v2-alert-table-card > footer div { display: flex; gap: 5px; }.v2-alert-table-card > footer button, .v2-alert-table-card > footer select { height: 24px; border: 1px solid #dfe6ef; border-radius: 5px; background: #fff; padding: 0 7px; color: #5f6e83; font-size: 7px; }.v2-alert-table-card > footer button:disabled { opacity: .35; }
|
||||
.v2-alert-inspector { min-width: 0; min-height: 0; overflow: auto; overscroll-behavior: contain; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-alert-inspector > header { display: flex; min-height: 52px; align-items: center; border-bottom: 1px solid var(--v2-border); padding: 8px 11px; }.v2-alert-inspector > header > div { display: flex; min-width: 0; flex: 1; justify-content: space-between; flex-direction: column; gap: 6px; }.v2-alert-inspector > header strong { font-size: 12px; }.v2-alert-inspector > header span { display: flex; gap: 5px; font-size: 7px; }
|
||||
.v2-alert-inspector > section { border-bottom: 1px solid var(--v2-border); padding: 9px 11px; }.v2-alert-inspector h3 { margin: 0 0 6px; border-left: 2px solid var(--v2-blue); padding-left: 6px; font-size: 8px; }.v2-alert-inspector dl { margin: 0; }.v2-alert-inspector dl > div { display: grid; grid-template-columns: 74px minmax(0,1fr); gap: 7px; padding: 3px 0; font-size: 7px; }.v2-alert-inspector dt { color: #7f8c9f; }.v2-alert-inspector dd { margin: 0; overflow-wrap: anywhere; text-align: right; }
|
||||
.v2-alert-evidence { display: grid; grid-template-columns: 1fr 24px 1fr; align-items: center; gap: 4px; }.v2-alert-evidence > div { display: flex; min-height: 57px; align-items: center; justify-content: center; flex-direction: column; border: 1px solid #e0e7f0; border-radius: 6px; background: #fafcff; text-align: center; }.v2-alert-evidence small { color: #7e8b9e; font-size: 7px; }.v2-alert-evidence strong { margin-top: 6px; color: #39475c; font-size: 11px; }.v2-alert-evidence > div:first-child strong { color: var(--v2-red); }.v2-alert-evidence > b { color: #7e8b9e; text-align: center; font-size: 8px; }
|
||||
.v2-alert-timeline { padding-left: 3px; }.v2-alert-timeline article { position: relative; display: grid; min-height: 35px; grid-template-columns: 12px 1fr; gap: 7px; }.v2-alert-timeline article:not(:last-child)::before { position: absolute; top: 10px; bottom: -3px; left: 3px; width: 1px; background: #cdd7e4; content: ''; }.v2-alert-timeline i { position: relative; z-index: 1; width: 7px; height: 7px; margin-top: 3px; border: 2px solid var(--v2-blue); border-radius: 50%; background: #fff; }.v2-alert-timeline article:first-child i { border-color: var(--v2-red); }.v2-alert-timeline strong { display: block; font-size: 7px; }.v2-alert-timeline span { display: block; margin-top: 3px; color: #8793a5; font-size: 6px; }.v2-alert-timeline p { margin: 3px 0 0; color: #68768a; font-size: 6px; }
|
||||
.v2-alert-inspector textarea { width: 100%; height: 46px; resize: vertical; border: 1px solid #dce4ef; border-radius: 5px; padding: 6px; color: #46546a; outline: 0; font: inherit; font-size: 7px; }.v2-alert-note-count { display: block; margin-top: -13px; padding-right: 5px; color: #9aa5b4; text-align: right; font-size: 6px; }.v2-alert-action-error { margin: 6px 0 0; color: var(--v2-red); font-size: 7px; }
|
||||
.v2-alert-actions { display: grid; grid-template-columns: 1.5fr 1fr 1fr; gap: 5px; margin-top: 8px; }.v2-alert-actions button { height: 28px; border: 1px solid #dce4ef; border-radius: 5px; background: #fff; color: #526176; cursor: pointer; font-size: 7px; }.v2-alert-actions button.is-primary { border-color: var(--v2-blue); background: var(--v2-blue); color: #fff; }.v2-alert-actions button:disabled { opacity: .35; cursor: not-allowed; }
|
||||
.v2-alert-links { display: grid; grid-template-columns: repeat(3,1fr); gap: 4px; padding: 8px 11px; }.v2-alert-links a { display: flex; height: 26px; align-items: center; justify-content: center; color: var(--v2-blue); text-decoration: none; font-size: 7px; }
|
||||
.v2-alert-side-empty { display: flex; min-height: 220px; align-items: center; justify-content: center; flex-direction: column; gap: 8px; padding: 20px; color: #8290a3; text-align: center; }.v2-alert-side-empty strong { color: #4b596d; font-size: 11px; }.v2-alert-side-empty span { font-size: 8px; }
|
||||
.v2-alert-rules { display: grid; min-height: 0; flex: 1; grid-template-columns: 330px minmax(560px,1fr); gap: 8px; }.v2-alert-rule-list, .v2-alert-rule-editor, .v2-alert-notifications { min-height: 0; overflow: auto; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-alert-rule-list > header, .v2-alert-rule-editor > header, .v2-alert-notifications > header { display: flex; min-height: 45px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 12px; }.v2-alert-rule-list > header strong, .v2-alert-rule-editor > header strong, .v2-alert-notifications > header strong { font-size: 11px; }.v2-alert-rule-list > header button, .v2-alert-rule-editor > header button, .v2-alert-notifications > header button { height: 27px; border: 1px solid #dce4ef; border-radius: 5px; background: #fff; padding: 0 8px; color: var(--v2-blue); cursor: pointer; font-size: 8px; }
|
||||
.v2-alert-rule-list > button { display: grid; width: 100%; min-height: 58px; grid-template-columns: 7px minmax(0,1fr) auto; align-items: center; gap: 9px; border: 0; border-bottom: 1px solid #eef2f7; background: #fff; padding: 8px 11px; text-align: left; cursor: pointer; }.v2-alert-rule-list > button.is-selected { background: #f2f7ff; box-shadow: inset 2px 0 var(--v2-blue); }.v2-alert-rule-list > button > i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }.v2-alert-rule-list > button > i.is-critical { background: var(--v2-red); }.v2-alert-rule-list > button > i.is-major { background: var(--v2-orange); }.v2-alert-rule-list > button span { min-width: 0; }.v2-alert-rule-list > button strong, .v2-alert-rule-list > button small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-alert-rule-list > button strong { font-size: 9px; }.v2-alert-rule-list > button small { margin-top: 5px; color: #8390a2; font-size: 7px; }.v2-alert-rule-list em { color: #94a3b8; font-size: 7px; font-style: normal; }.v2-alert-rule-list em.is-enabled { color: var(--v2-green); }
|
||||
.v2-alert-rule-editor > header div, .v2-alert-notifications > header div { display: flex; flex-direction: column; gap: 3px; }.v2-alert-rule-editor > header span, .v2-alert-notifications > header span { color: var(--v2-muted); font-size: 7px; }.v2-rule-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; padding: 15px; }.v2-rule-form-grid label { display: flex; min-width: 0; flex-direction: column; gap: 5px; color: #68768a; font-size: 8px; }.v2-rule-form-grid label.is-wide { grid-column: 1/-1; }.v2-rule-form-grid input, .v2-rule-form-grid select, .v2-rule-form-grid textarea { min-width: 0; height: 34px; border: 1px solid #dce4ef; border-radius: 6px; background: #fff; padding: 0 9px; color: #435168; outline: 0; font: inherit; font-size: 9px; }.v2-rule-form-grid textarea { height: 60px; padding: 8px; resize: vertical; }.v2-alert-rule-editor > footer { display: flex; align-items: center; gap: 12px; border-top: 1px solid var(--v2-border); padding: 10px 15px; }.v2-alert-rule-editor > footer > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }.v2-alert-rule-editor > footer b { font-size: 8px; }.v2-alert-rule-editor > footer span { color: var(--v2-muted); font-size: 7px; }.v2-alert-rule-editor > footer em { color: var(--v2-red); font-size: 7px; font-style: normal; }.v2-alert-rule-editor > footer button { height: 32px; }
|
||||
.v2-alert-notifications { flex: 1; }.v2-alert-notifications > div { min-height: 0; max-height: calc(100% - 125px); overflow: auto; }.v2-alert-notifications article { display: grid; min-height: 64px; grid-template-columns: 8px minmax(0,1fr) auto; align-items: center; gap: 10px; border-bottom: 1px solid #eef2f7; padding: 9px 13px; }.v2-alert-notifications article.is-read { opacity: .58; }.v2-alert-notifications article > i { width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; }.v2-alert-notifications article > i.is-critical { background: var(--v2-red); }.v2-alert-notifications article > i.is-major { background: var(--v2-orange); }.v2-alert-notifications article strong { font-size: 9px; }.v2-alert-notifications article p { margin: 4px 0; color: #68768a; font-size: 8px; }.v2-alert-notifications article span { color: #94a3b8; font-size: 7px; }.v2-alert-notifications article button { border: 0; background: transparent; color: var(--v2-blue); cursor: pointer; font-size: 8px; }.v2-alert-notifications > footer { display: flex; gap: 16px; border-top: 1px solid var(--v2-border); padding: 12px 13px; color: #7f8c9f; font-size: 7px; }.v2-alert-notifications > footer b { color: #4f5d72; }
|
||||
@keyframes v2-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes v2-map-ripple { 0% { opacity: .8; transform: scale(.35); } 80%, 100% { opacity: 0; transform: scale(1.25); } }
|
||||
@keyframes v2-panel-enter { from { opacity: .72; transform: translate3d(8px, 0, 0); } to { opacity: 1; transform: translate3d(0, 0, 0); } }
|
||||
|
||||
@media (min-width: 1600px) and (min-height: 900px) {
|
||||
.v2-filterbar { gap: 12px; padding: 11px 14px; }
|
||||
.v2-search-field, .v2-filterbar select, .v2-primary-button, .v2-secondary-button { height: 40px; }
|
||||
.v2-kpi { min-height: 70px; padding: 12px 17px; }
|
||||
.v2-kpi small { font-size: 11px; }
|
||||
.v2-kpi strong { margin-top: 7px; font-size: clamp(20px, 1.2vw, 27px); }
|
||||
.v2-vehicle-rail > header { height: 50px; padding: 0 14px; }
|
||||
.v2-rail-search { height: 36px; margin: 0 11px 9px; }
|
||||
.v2-vehicle-row { min-height: 66px; padding: 9px 12px; }
|
||||
.v2-vehicle-identity strong { font-size: 13px; }
|
||||
.v2-vehicle-detail { padding: 16px; }
|
||||
.v2-detail-title strong { font-size: 17px; }
|
||||
.v2-detail-actions a { height: 32px; font-size: 9px; }
|
||||
.v2-vehicle-detail h3 { font-size: 12px; }
|
||||
.v2-detail-list > div { grid-template-columns: 84px minmax(0, 1fr); padding: 6px 0; font-size: 10px; }
|
||||
.v2-metric-grid > div { padding: 11px; }
|
||||
.v2-metric-grid small { font-size: 9px; }
|
||||
.v2-metric-grid strong { font-size: 17px; }
|
||||
.v2-event-strip { min-height: 52px; font-size: 10px; }
|
||||
}
|
||||
|
||||
@media (min-width: 2200px) and (min-height: 1200px) {
|
||||
.v2-monitor-page { gap: 14px; padding: 18px 22px 20px; }
|
||||
.v2-filterbar { padding: 13px 16px; }
|
||||
.v2-search-field, .v2-filterbar select, .v2-primary-button, .v2-secondary-button { height: 42px; font-size: 13px; }
|
||||
.v2-kpi { min-height: 82px; padding: 15px 20px; }
|
||||
.v2-kpi small { font-size: 12px; }
|
||||
.v2-kpi strong { font-size: 28px; }
|
||||
.v2-vehicle-rail > header { height: 54px; }
|
||||
.v2-vehicle-rail > header strong { font-size: 14px; }
|
||||
.v2-rail-search { height: 38px; font-size: 11px; }
|
||||
.v2-vehicle-row { min-height: 72px; }
|
||||
.v2-vehicle-identity strong { font-size: 14px; }
|
||||
.v2-vehicle-motion strong { font-size: 12px; }
|
||||
.v2-map-controls { top: 16px; right: 16px; }
|
||||
.v2-map-layer-control, .v2-map-follow-control { height: 48px; padding: 0 12px; }
|
||||
.v2-map-layer-control strong { font-size: 11px; }
|
||||
.v2-map-layer-control small { font-size: 9px; }
|
||||
.v2-map-follow-control strong { font-size: 11px; }
|
||||
.v2-map-follow-control small { font-size: 9px; }
|
||||
.v2-map-legend { bottom: 16px; height: 40px; gap: 22px; padding: 0 20px; font-size: 10px; }
|
||||
.v2-vehicle-detail { padding: 19px; }
|
||||
.v2-detail-title strong { font-size: 19px; }
|
||||
.v2-detail-list > div { grid-template-columns: 92px minmax(0, 1fr); font-size: 11px; }
|
||||
.v2-vehicle-detail h3 { font-size: 13px; }
|
||||
.v2-metric-grid small { font-size: 10px; }
|
||||
.v2-metric-grid strong { font-size: 19px; }
|
||||
.v2-event-strip { min-height: 56px; padding: 0 20px; font-size: 11px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.v2-monitor-workspace { grid-template-columns: 220px minmax(420px, 1fr); }
|
||||
.v2-monitor-workspace.is-detail-open { grid-template-columns: 220px minmax(420px, 1fr) 280px; }
|
||||
.v2-monitor-workspace.is-detail-collapsed { grid-template-columns: 220px minmax(420px, 1fr) 42px; }
|
||||
.v2-kpis { grid-template-columns: repeat(4, 1fr); }
|
||||
.v2-kpi:nth-child(5)::before { display: none; }
|
||||
.v2-identity-band { grid-template-columns: minmax(240px, 1fr) minmax(300px, 1fr); }
|
||||
.v2-identity-actions { grid-column: 1 / -1; justify-content: flex-end; border-top: 1px solid var(--v2-border); }
|
||||
.v2-record-grid { grid-template-columns: minmax(460px, 1.5fr) minmax(280px, 1fr); }
|
||||
.v2-track-toolbar { grid-template-columns: minmax(210px, 1fr) repeat(3, minmax(140px, .7fr)); }
|
||||
.v2-track-toolbar > button { min-width: 100px; }
|
||||
.v2-track-workspace { grid-template-columns: minmax(500px, 1fr) 290px; }
|
||||
.v2-track-playback { grid-template-columns: 180px minmax(210px, 1fr); }
|
||||
.v2-current-metrics { grid-column: 1 / -1; border-top: 1px solid var(--v2-border); border-left: 0; padding-top: 6px; }
|
||||
.v2-history-toolbar { grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(120px, .7fr)); }
|
||||
.v2-history-toolbar button { min-width: 90px; }
|
||||
.v2-history-workspace { grid-template-columns: minmax(600px, 1fr) 280px; }
|
||||
.v2-access-filter { grid-template-columns: minmax(210px, 1fr) repeat(4, minmax(110px, .7fr)); }
|
||||
.v2-access-filter button { min-width: 86px; }
|
||||
.v2-access-advanced > div { grid-template-columns: repeat(3, minmax(150px, 1fr)); }
|
||||
.v2-access-workspace { grid-template-columns: minmax(620px, 1fr) 280px; }
|
||||
.v2-access-identity-queue > div { grid-template-columns: repeat(3, minmax(180px, 1fr)); }
|
||||
.v2-alert-filter { grid-template-columns: minmax(180px,1fr) repeat(4,minmax(88px,.65fr)); }
|
||||
.v2-alert-filter label:nth-of-type(6), .v2-alert-filter label:nth-of-type(7) { display: none; }
|
||||
.v2-alert-workspace { grid-template-columns: minmax(620px,1fr) 290px; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.v2-sidebar { width: 68px; }
|
||||
.v2-main { margin-left: 68px; }
|
||||
.v2-brand strong, .v2-nav-label, .v2-collapse span { display: none; }
|
||||
.v2-brand { padding: 0 17px; }
|
||||
.v2-nav-item { justify-content: center; padding: 0; }
|
||||
.v2-filterbar { grid-template-columns: 1fr 1fr; }
|
||||
.v2-search-field { grid-column: 1 / -1; }
|
||||
.v2-monitor-workspace, .v2-monitor-workspace.is-detail-open, .v2-monitor-workspace.is-detail-collapsed { position: relative; grid-template-columns: 220px minmax(0, 1fr); }
|
||||
.v2-vehicle-detail { position: absolute; inset: 10px 10px 10px auto; z-index: 8; display: block; width: min(340px, calc(100% - 240px)); border: 1px solid #d8e2ed; border-radius: 10px; box-shadow: 0 12px 36px rgba(21,32,51,.16); }
|
||||
.v2-detail-peek { position: absolute; right: 10px; bottom: 10px; z-index: 8; display: block; min-width: 0; min-height: 0; border: 1px solid #cfe0f8; border-radius: 22px; box-shadow: 0 7px 24px rgba(18,104,243,.16); }
|
||||
.v2-detail-peek button { width: auto; height: 44px; flex-direction: row; gap: 7px; border-radius: 22px; padding: 0 14px; }
|
||||
.v2-detail-peek button > span { max-width: 130px; writing-mode: horizontal-tb; letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-identity-band { grid-template-columns: 1fr; }
|
||||
.v2-identity-meta { border: 0; border-top: 1px solid var(--v2-border); border-bottom: 1px solid var(--v2-border); }
|
||||
.v2-identity-actions { grid-column: auto; justify-content: stretch; border: 0; }
|
||||
.v2-identity-actions a { flex: 1; justify-content: center; }
|
||||
.v2-record-grid { display: flex; flex-direction: column; }
|
||||
.v2-single-map-card { min-height: 360px; }
|
||||
.v2-archive-card, .v2-live-card, .v2-telemetry-card, .v2-events-card { grid-area: auto; }
|
||||
.v2-track-page { height: auto; min-height: 100%; overflow: auto; }
|
||||
.v2-track-toolbar { grid-template-columns: 1fr 1fr; }
|
||||
.v2-track-vehicle-input { grid-column: 1 / -1; }
|
||||
.v2-track-workspace { display: flex; flex-direction: column; }
|
||||
.v2-track-main { min-height: 700px; flex: none; grid-template-rows: 34px 420px 58px 145px; }
|
||||
.v2-track-inspector { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; overflow: visible; }
|
||||
.v2-track-inspector > section + section { margin-top: 0; }
|
||||
.v2-track-events { grid-column: 1 / -1; }
|
||||
.v2-history-page { height: auto; min-height: 100%; overflow: auto; }
|
||||
.v2-history-toolbar { grid-template-columns: 1fr 1fr; }
|
||||
.v2-history-vehicles { grid-column: 1 / -1; }
|
||||
.v2-history-workspace { display: flex; flex-direction: column; }
|
||||
.v2-history-main { height: 760px; min-height: 0; flex: none; grid-template-rows: 58px 220px minmax(0, 1fr); }
|
||||
.v2-history-side { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; overflow: visible; }
|
||||
.v2-history-side > section + section { margin-top: 0; }
|
||||
.v2-access-page { height: auto; min-height: 100%; overflow: auto; }
|
||||
.v2-access-filter { grid-template-columns: 1fr 1fr; }
|
||||
.v2-access-filter label:first-child { grid-column: 1 / -1; }
|
||||
.v2-access-advanced > div { grid-template-columns: 1fr 1fr; }
|
||||
.v2-access-kpis { grid-template-columns: repeat(3, 1fr); }
|
||||
.v2-access-kpis button:nth-child(4)::before { display: none; }
|
||||
.v2-access-workspace { display: flex; flex-direction: column; }
|
||||
.v2-access-identity-queue > div { grid-template-columns: repeat(2, minmax(180px, 1fr)); }
|
||||
.v2-access-table-card { height: 620px; flex: none; }
|
||||
.v2-access-side { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; overflow: visible; }
|
||||
.v2-access-side > section + section { margin-top: 0; }
|
||||
.v2-alert-page { height: auto; min-height: 100%; overflow: auto; }
|
||||
.v2-alert-filter { grid-template-columns: repeat(3,1fr); }
|
||||
.v2-alert-filter label:first-child { grid-column: 1/-1; }
|
||||
.v2-alert-kpis { grid-template-columns: repeat(4,1fr); }
|
||||
.v2-alert-kpis button:nth-child(5)::before { display: none; }
|
||||
.v2-alert-workspace { display: flex; flex-direction: column; }
|
||||
.v2-alert-table-card { height: 610px; flex: none; }
|
||||
.v2-alert-inspector { max-height: none; overflow: visible; }
|
||||
.v2-alert-rules { display: flex; flex-direction: column; }
|
||||
.v2-alert-rule-list { max-height: 300px; flex: none; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
html, body, #root { min-width: 320px; min-height: 100%; }
|
||||
body { overscroll-behavior-y: none; }
|
||||
.v2-auth-screen { min-height: 100dvh; padding: 16px; }
|
||||
.v2-auth-card { padding: 26px 20px; }
|
||||
.v2-auth-card input { height: 46px; font-size: 16px; }
|
||||
.v2-auth-card button { height: 46px; }
|
||||
.v2-sidebar { inset: auto 0 0 0; width: auto; height: calc(64px + env(safe-area-inset-bottom)); flex-direction: row; border: 0; border-top: 1px solid var(--v2-border); padding-bottom: env(safe-area-inset-bottom); box-shadow: 0 -8px 24px rgba(21,32,51,.07); }
|
||||
.v2-brand, .v2-collapse, .v2-nav-operations { display: none; }
|
||||
.v2-navigation { display: grid; width: 100%; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 0; padding: 4px 3px; }
|
||||
.v2-nav-item { height: 56px; justify-content: center; flex-direction: column; gap: 2px; padding: 3px 0 2px; border-radius: 8px; font-weight: 600; }
|
||||
.v2-nav-item > svg { flex: 0 0 auto; font-size: 18px; }
|
||||
.v2-nav-label, .v2-sidebar.is-collapsed .v2-nav-label { display: block; max-width: 100%; overflow: hidden; font-size: 9px; line-height: 1.2; text-overflow: ellipsis; }
|
||||
.v2-nav-item.is-active::before { inset: auto auto 0; width: 20px; height: 2px; }
|
||||
.v2-main, .v2-sidebar.is-collapsed + .v2-main { margin-left: 0; padding-bottom: calc(64px + env(safe-area-inset-bottom)); }
|
||||
.v2-topbar { height: 52px; flex: 0 0 52px; padding: 0 10px 0 14px; }
|
||||
.v2-topbar h1 { font-size: 17px; }
|
||||
.v2-topbar-actions { gap: 0; }
|
||||
.v2-topbar-actions button { width: 40px; height: 40px; }
|
||||
.v2-current-user { display: none; }
|
||||
.v2-content { overscroll-behavior: contain; }
|
||||
.v2-monitor-page { height: auto; min-height: 100%; gap: 8px; overflow: visible; padding: 8px; }
|
||||
.v2-filterbar { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; padding: 9px; box-shadow: 0 4px 16px rgba(21,32,51,.04); }
|
||||
.v2-search-field { grid-column: 1 / -1; height: 44px; }
|
||||
.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-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; }
|
||||
.v2-kpi + .v2-kpi::before, .v2-kpi:nth-child(odd)::before { display: block; }
|
||||
.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-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; }
|
||||
.v2-rail-search { height: 40px; }
|
||||
.v2-vehicle-row { min-height: 64px; padding: 9px 11px; }
|
||||
.v2-vehicle-identity strong { font-size: 13px; }
|
||||
.v2-map-controls { top: 8px; right: 8px; gap: 6px; }
|
||||
.v2-map-layer-control, .v2-map-follow-control { width: 44px; height: 44px; grid-template-columns: 1fr; justify-items: center; padding: 0; }
|
||||
.v2-map-layer-control span:not(.semi-icon), .v2-map-layer-control > i, .v2-map-follow-control span:not(.semi-icon) { display: none; }
|
||||
.v2-map-layer-control .semi-icon, .v2-map-follow-control .semi-icon { display: inline-flex; font-size: 18px; }
|
||||
.v2-map-legend { right: 8px; bottom: 8px; left: 8px; width: auto; max-width: none; height: 36px; justify-content: flex-start; gap: 13px; overflow-x: auto; padding: 0 12px; transform: none; white-space: nowrap; scrollbar-width: none; }
|
||||
.v2-map-legend::-webkit-scrollbar { display: none; }
|
||||
.v2-map-legend b { margin-left: auto; }
|
||||
.v2-vehicle-detail { position: fixed; inset: auto 8px calc(72px + env(safe-area-inset-bottom)) 8px; z-index: 70; display: block; width: auto; height: min(68dvh, 620px); min-height: 300px; contain: layout paint; overscroll-behavior: contain; border: 1px solid #d8e2ed; border-radius: 14px; background: #fff; padding: 18px 15px 20px; box-shadow: 0 -10px 44px rgba(21,32,51,.2); animation: v2-mobile-sheet-enter .18s ease-out; }
|
||||
.v2-vehicle-detail::before { display: block; width: 38px; height: 4px; margin: -8px auto 10px; border-radius: 2px; background: #d7e0eb; content: ""; }
|
||||
.v2-detail-controls { top: 13px; right: 12px; }
|
||||
.v2-detail-controls button { width: 36px; height: 36px; }
|
||||
.v2-detail-title { padding: 0 82px 14px 0; }
|
||||
.v2-detail-title strong { font-size: 18px; }
|
||||
.v2-detail-actions { position: sticky; top: -18px; z-index: 2; background: #fff; padding: 10px 0; }
|
||||
.v2-detail-actions a { min-height: 38px; font-size: 10px; }
|
||||
.v2-detail-list > div { grid-template-columns: 82px minmax(0, 1fr); font-size: 11px; }
|
||||
.v2-metric-grid small { font-size: 10px; }
|
||||
.v2-metric-grid strong { font-size: 17px; }
|
||||
.v2-detail-peek { position: fixed; right: 10px; bottom: calc(74px + env(safe-area-inset-bottom)); z-index: 65; display: block; min-width: 0; min-height: 0; border: 1px solid #cfe0f8; border-radius: 22px; box-shadow: 0 7px 24px rgba(18,104,243,.18); }
|
||||
.v2-detail-peek button { width: auto; height: 44px; flex-direction: row; gap: 7px; border-radius: 22px; padding: 0 14px; background: #fff; }
|
||||
.v2-detail-peek button > span { max-width: 120px; writing-mode: horizontal-tb; font-size: 11px; letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-event-strip { flex-wrap: wrap; gap: 7px 12px; padding: 10px 12px; line-height: 1.4; }
|
||||
.v2-event-strip > strong { width: 100%; }
|
||||
.v2-refresh-cadence { border-left: 0; padding-left: 0; }
|
||||
.v2-event-strip time { width: 100%; margin: 0; }
|
||||
.v2-vehicle-record-page { overflow: auto; padding: 8px; }
|
||||
.v2-vehicle-search-page { padding: 12px; }
|
||||
.v2-vehicle-search-card { padding: 30px 18px; }
|
||||
.v2-vehicle-search-card form { height: auto; flex-wrap: wrap; padding: 8px; }
|
||||
.v2-vehicle-search-card form button { width: 100%; justify-content: center; }
|
||||
.v2-vehicle-search-page.has-sync-panel { align-content: start; }
|
||||
.v2-profile-sync-panel { padding: 13px; }
|
||||
.v2-profile-sync-fields { grid-template-columns: 1fr; }
|
||||
.v2-profile-sync-result > div { grid-template-columns: repeat(3,1fr); gap: 8px; }
|
||||
.v2-profile-sync-panel > footer button { flex: 1; }
|
||||
.v2-identity-actions { flex-wrap: wrap; }
|
||||
.v2-identity-actions a { flex-basis: calc(50% - 4px); }
|
||||
.v2-identity-meta { grid-template-columns: 1fr; }
|
||||
.v2-identity-meta > div + div { margin-top: 12px; border: 0; border-top: 1px solid var(--v2-border); padding: 12px 0 0; }
|
||||
.v2-single-map-card { min-height: 300px; }
|
||||
.v2-single-map-card footer { align-items: flex-start; flex-direction: column; gap: 4px; padding: 8px 12px; }
|
||||
.v2-telemetry-list { grid-template-columns: 1fr; }
|
||||
.v2-telemetry-list > div:nth-child(odd) { border-right: 0; }
|
||||
.v2-live-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.v2-live-grid > div:nth-child(3), .v2-live-grid > div:nth-child(5) { border-left: 0; }
|
||||
.v2-live-grid > div:nth-child(n+3) { border-top: 1px solid var(--v2-border); }
|
||||
.v2-track-page { padding: 8px; }
|
||||
.v2-track-toolbar { grid-template-columns: 1fr; }
|
||||
.v2-track-vehicle-input { grid-column: auto; }
|
||||
.v2-track-toolbar > button { width: 100%; }
|
||||
.v2-track-main { min-height: 770px; grid-template-rows: auto 400px auto 205px; }
|
||||
.v2-track-map-legend { right: 8px; bottom: 8px; left: 8px; justify-content: center; }
|
||||
.v2-track-map-legend b { display: none; }
|
||||
.v2-track-coverage { min-height: 52px; flex-wrap: wrap; gap: 4px 8px; padding: 8px 10px; }
|
||||
.v2-track-coverage span { width: calc(100% - 20px); white-space: normal; }
|
||||
.v2-track-coverage em { width: 100%; margin-left: 14px; }
|
||||
.v2-track-timeline { min-height: 74px; grid-template-columns: 1fr; gap: 6px; padding: 8px 10px; }
|
||||
.v2-track-timeline header { flex-direction: row; justify-content: space-between; }
|
||||
.v2-track-timeline > em { grid-column: 1; }
|
||||
.v2-track-playback { display: flex; align-items: stretch; flex-direction: column; gap: 12px; padding: 10px; }
|
||||
.v2-play-controls { display: flex; align-items: center; justify-content: space-between; }
|
||||
.v2-play-controls small { margin: 0; }
|
||||
.v2-play-progress { border: 0; padding: 0; }
|
||||
.v2-track-inspector { display: flex; flex-direction: column; }
|
||||
.v2-history-page { padding: 8px; }
|
||||
.v2-history-toolbar { grid-template-columns: 1fr; }
|
||||
.v2-history-vehicles { grid-column: auto; }
|
||||
.v2-history-toolbar button { width: 100%; }
|
||||
.v2-history-summary { grid-template-columns: 1fr 1fr; min-height: 106px; }
|
||||
.v2-history-summary > div:nth-child(3)::before { display: none; }
|
||||
.v2-history-main { height: 1111px; grid-template-rows: 106px 385px minmax(0, 600px); }
|
||||
.v2-history-trend > header { align-items: flex-start; flex-direction: column; gap: 6px; padding: 7px 10px; }
|
||||
.v2-history-trend > header div { flex-wrap: wrap; gap: 5px 10px; }
|
||||
.v2-history-trend-panels { grid-template-columns: 1fr; grid-auto-rows: 152px; }
|
||||
.v2-history-side { display: flex; flex-direction: column; }
|
||||
.v2-access-page { padding: 8px; }
|
||||
.v2-access-filter { grid-template-columns: 1fr; }
|
||||
.v2-access-filter label:first-child { grid-column: auto; }
|
||||
.v2-access-filter button { width: 100%; }
|
||||
.v2-access-advanced > div { grid-template-columns: 1fr; }
|
||||
.v2-access-kpis { grid-template-columns: repeat(2, 1fr); }
|
||||
.v2-access-identity-queue summary { align-items: flex-start; flex-direction: column; gap: 3px; }
|
||||
.v2-access-identity-queue > div { grid-template-columns: 1fr; }
|
||||
.v2-access-kpis button:nth-child(4)::before { display: block; }
|
||||
.v2-access-kpis button:nth-child(odd)::before { display: none; }
|
||||
.v2-access-protocols header { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||
.v2-access-table-card { height: 600px; }
|
||||
.v2-access-table-card > header { align-items: flex-start; height: auto; flex-direction: column; gap: 7px; padding: 8px 9px; }
|
||||
.v2-access-table-card > header div { width: 100%; overflow-x: auto; }
|
||||
.v2-access-table-card > footer { align-items: flex-start; height: auto; flex-direction: column; gap: 7px; padding: 8px 9px; }
|
||||
.v2-access-side { display: flex; flex-direction: column; }
|
||||
.v2-alert-page { padding: 0 8px 8px; }
|
||||
.v2-alert-heading { margin: 0 -8px; padding: 0 10px; }
|
||||
.v2-alert-heading > div { align-items: flex-start; flex-direction: column; gap: 3px; }
|
||||
.v2-alert-heading p { margin: 0; }
|
||||
.v2-alert-tabs { margin: 0 -8px 8px; padding: 0 9px; }
|
||||
.v2-alert-filter { grid-template-columns: 1fr; }
|
||||
.v2-alert-filter label:first-child { grid-column: auto; }
|
||||
.v2-alert-filter label:nth-of-type(6), .v2-alert-filter label:nth-of-type(7) { display: flex; }
|
||||
.v2-alert-filter button { width: 100%; }
|
||||
.v2-alert-kpis { grid-template-columns: repeat(2,1fr); }
|
||||
.v2-alert-kpis button:nth-child(5)::before { display: block; }
|
||||
.v2-alert-kpis button:nth-child(odd)::before { display: none; }
|
||||
.v2-alert-table-card { height: 590px; }
|
||||
.v2-alert-table-card > header, .v2-alert-table-card > footer { align-items: flex-start; height: auto; flex-direction: column; gap: 6px; padding: 7px 9px; }
|
||||
.v2-alert-inspector { max-height: none; }
|
||||
.v2-rule-form-grid { grid-template-columns: 1fr; padding: 10px; }
|
||||
.v2-rule-form-grid label.is-wide { grid-column: auto; }
|
||||
.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); }
|
||||
}
|
||||
|
||||
@keyframes v2-mobile-sheet-enter {
|
||||
from { opacity: 0; transform: translateY(24px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 374px) {
|
||||
.v2-nav-label, .v2-sidebar.is-collapsed .v2-nav-label { font-size: 8px; }
|
||||
.v2-monitor-workspace, .v2-monitor-workspace.is-detail-open, .v2-monitor-workspace.is-detail-collapsed { grid-template-rows: minmax(330px, 50dvh) 340px; min-height: calc(50dvh + 340px); }
|
||||
.v2-map-legend span:nth-of-type(3), .v2-map-legend span:nth-of-type(4) { display: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
Reference in New Issue
Block a user