feat: build vehicle data platform and production pipeline
This commit is contained in:
@@ -10,10 +10,12 @@
|
||||
"@douyinfe/semi-icons": "^2.71.0",
|
||||
"@douyinfe/semi-theme-default": "^2.71.0",
|
||||
"@douyinfe/semi-ui": "^2.71.0",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"echarts": "^5.6.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "6.30.1",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
52
vehicle-data-platform/apps/web/pnpm-lock.yaml
generated
52
vehicle-data-platform/apps/web/pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
||||
'@douyinfe/semi-ui':
|
||||
specifier: ^2.71.0
|
||||
version: 2.101.0(@floating-ui/dom@1.7.6)(@tiptap/suggestion@3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@tanstack/react-query':
|
||||
specifier: 5.101.2
|
||||
version: 5.101.2(react@18.3.1)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.3.4
|
||||
version: 4.7.0(vite@6.4.3(sass@1.101.0))
|
||||
@@ -29,6 +32,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1(react@18.3.1)
|
||||
react-router-dom:
|
||||
specifier: 6.30.1
|
||||
version: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
vite:
|
||||
specifier: ^6.0.0
|
||||
version: 6.4.3(sass@1.101.0)
|
||||
@@ -647,6 +653,10 @@ packages:
|
||||
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
'@remix-run/router@1.23.0':
|
||||
resolution: {integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||
|
||||
@@ -788,6 +798,14 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tanstack/query-core@5.101.2':
|
||||
resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==}
|
||||
|
||||
'@tanstack/react-query@5.101.2':
|
||||
resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1842,6 +1860,19 @@ packages:
|
||||
react: '>= 16.3'
|
||||
react-dom: '>= 16.3'
|
||||
|
||||
react-router-dom@6.30.1:
|
||||
resolution: {integrity: sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
react-router@6.30.1:
|
||||
resolution: {integrity: sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
|
||||
react-window@1.8.11:
|
||||
resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==}
|
||||
engines: {node: '>8.0.0'}
|
||||
@@ -2751,6 +2782,8 @@ snapshots:
|
||||
'@parcel/watcher-win32-x64': 2.5.6
|
||||
optional: true
|
||||
|
||||
'@remix-run/router@1.23.0': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.62.2':
|
||||
@@ -2828,6 +2861,13 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@tanstack/query-core@5.101.2': {}
|
||||
|
||||
'@tanstack/react-query@5.101.2(react@18.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.101.2
|
||||
react: 18.3.1
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
@@ -4267,6 +4307,18 @@ snapshots:
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-draggable: 4.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
react-router-dom@6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@remix-run/router': 1.23.0
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-router: 6.30.1(react@18.3.1)
|
||||
|
||||
react-router@6.30.1(react@18.3.1):
|
||||
dependencies:
|
||||
'@remix-run/router': 1.23.0
|
||||
react: 18.3.1
|
||||
|
||||
react-window@1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
window.__LINGNIU_APP_CONFIG__ = {
|
||||
amapWebJsKey: '',
|
||||
// Production should prefer amapSecurityServiceHost and keep amapSecurityJsCode on the API service.
|
||||
amapSecurityJsCode: '',
|
||||
amapSecurityServiceHost: '',
|
||||
apiBaseUrl: '',
|
||||
prototypeUseRealData: true
|
||||
};
|
||||
7819
vehicle-data-platform/apps/web/src/PrototypeApp.tsx
Normal file
7819
vehicle-data-platform/apps/web/src/PrototypeApp.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,85 @@ import { api } from './client';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
test('authenticated requests use the session-only bearer token', async () => {
|
||||
window.sessionStorage.setItem('vehicle-platform.access-token', 'operator-secret-token');
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { name: 'operator-a', role: 'operator', authMode: 'enforce' } }) } as Response);
|
||||
await api.session();
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer operator-secret-token');
|
||||
expect(window.localStorage.getItem('vehicle-platform.access-token')).toBeNull();
|
||||
});
|
||||
|
||||
test('durable alert APIs keep versioned actions, rules and notification reads explicit', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {}, traceId: 'trace-alert', timestamp: 1 }) } as Response);
|
||||
await api.alertEventsV2({ status: 'unprocessed', limit: 20, offset: 0 });
|
||||
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'unprocessed', limit: 20, offset: 0 }) });
|
||||
await api.actOnAlertV2('alert 1', { version: 2, action: 'acknowledge', note: '已确认' });
|
||||
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/events/alert%201/actions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 2, action: 'acknowledge', note: '已确认' }) });
|
||||
await api.readAlertNotificationsV2([7, 8]);
|
||||
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/notifications/read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: [7, 8] }) });
|
||||
});
|
||||
|
||||
test('access APIs post one shared filter contract and version threshold updates', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [], total: 0, limit: 50, offset: 0 }, traceId: 'trace-access', timestamp: 1783094400000 })
|
||||
} as Response);
|
||||
const query = { protocol: 'JT808', onlineState: 'offline', limit: 50, offset: 0 };
|
||||
await api.accessVehicles(query);
|
||||
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/vehicles', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
});
|
||||
|
||||
const unresolved = { protocol: 'JT808', limit: 20, offset: 0 };
|
||||
await api.accessUnresolvedIdentities(unresolved);
|
||||
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/unresolved-identities', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(unresolved)
|
||||
});
|
||||
|
||||
await api.updateAccessThresholds({ version: 3, defaultThresholdSec: 300, delayThresholdSec: 30, longOfflineSec: 1800, protocols: [{ protocol: 'JT808', thresholdSec: 60 }] });
|
||||
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/thresholds', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 3, defaultThresholdSec: 300, delayThresholdSec: 30, longOfflineSec: 1800, protocols: [{ protocol: 'JT808', thresholdSec: 60 }] })
|
||||
});
|
||||
});
|
||||
|
||||
test('vehicle profile API uses encoded VIN and optimistic version updates', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {}, traceId: 'trace-profile', timestamp: 1 }) } as Response);
|
||||
const input = { modelName: '氢燃料重卡', vehicleType: '重卡', companyName: '示范物流', operationStatus: 'active' as const, accessProvider: '车厂平台', firstAccessAt: '2026-07-01T08:30', runtimeSeconds: 3600, version: 2 };
|
||||
await api.updateVehicleProfile('VIN 001', input);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicles/VIN%20001/profile', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
});
|
||||
});
|
||||
|
||||
test('latest telemetry uses the encoded vehicle identity path', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { categories: [], values: [] }, traceId: 'trace-telemetry', timestamp: 1 }) } as Response);
|
||||
await api.latestTelemetry('粤A 001');
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicles/%E7%B2%A4A%20001/telemetry/latest', undefined);
|
||||
});
|
||||
|
||||
test('vehicle profile sync posts an explicit dry-run and conflict policy contract', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {}, traceId: 'trace-profile-sync', timestamp: 1 }) } as Response);
|
||||
const input = {
|
||||
sourceSystem: 'oem-tsp', sourceVersion: 'snapshot-1', conflictPolicy: 'preserve' as const, dryRun: true,
|
||||
items: [{ vin: 'VIN001', modelName: '车型一', vehicleType: '重卡', companyName: '示范物流', operationStatus: 'active' as const, accessProvider: '车厂平台', firstAccessAt: '', runtimeSeconds: null }]
|
||||
};
|
||||
await api.syncVehicleProfiles(input);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicle-profiles/sync', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
});
|
||||
});
|
||||
|
||||
test('trackPlayback preserves the bounded V2 query contract', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ data: { vin: 'VIN001', points: [], events: [], sources: [], summary: { pointCount: 0 } }, traceId: 'trace-test', timestamp: 1783094400000 })
|
||||
} as Response);
|
||||
await api.trackPlayback(new URLSearchParams({ keyword: '粤AG18312', maxPoints: '1200' }));
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v2/tracks?keyword=%E7%B2%A4AG18312&maxPoints=1200', undefined);
|
||||
});
|
||||
|
||||
test('rawFramesQuery posts structured JSON instead of URL query strings', async () => {
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import type {
|
||||
ApiEnvelope,
|
||||
AccessQuery,
|
||||
AccessSummary,
|
||||
AccessThresholdConfig,
|
||||
AccessThresholdUpdate,
|
||||
AccessUnresolvedIdentity,
|
||||
AccessUnresolvedIdentityQuery,
|
||||
AccessVehicleRow,
|
||||
AlertAction,
|
||||
AlertEvent,
|
||||
AlertNotification,
|
||||
AlertQuery,
|
||||
AlertRule,
|
||||
AlertRuleInput,
|
||||
AlertSummary,
|
||||
DailyMileageRow,
|
||||
DashboardSummary,
|
||||
HistoryLocationRow,
|
||||
HistoryDataResponse,
|
||||
HistorySeriesResponse,
|
||||
HistoryExportJob,
|
||||
HistoryExportRequest,
|
||||
HistoryMetricCatalog,
|
||||
MetricCatalog,
|
||||
LatestTelemetryResponse,
|
||||
MileageSummary,
|
||||
MapReverseGeocode,
|
||||
MonitorMapResponse,
|
||||
MonitorSummary,
|
||||
OnlineStatisticsSummary,
|
||||
OnlineVehicleStatusRow,
|
||||
OpsHealth,
|
||||
@@ -15,15 +38,22 @@ import type {
|
||||
RawFrameRow,
|
||||
RealtimeLocationRow,
|
||||
SourceReadinessPlan,
|
||||
SessionInfo,
|
||||
TrackPlaybackResponse,
|
||||
VehicleRealtimeRow,
|
||||
VehicleCoverageRow,
|
||||
VehicleCoverageSummary,
|
||||
VehicleDetail,
|
||||
VehicleProfile,
|
||||
VehicleProfileInput,
|
||||
VehicleProfileSyncRequest,
|
||||
VehicleProfileSyncResult,
|
||||
VehicleIdentityResolution,
|
||||
VehicleServiceOverview,
|
||||
VehicleServiceSummary,
|
||||
VehicleRow
|
||||
} from './types';
|
||||
import { getAccessToken } from '../v2/auth/session';
|
||||
|
||||
export type RawFrameQuery = {
|
||||
keyword?: string;
|
||||
@@ -54,7 +84,9 @@ type ApiErrorEnvelope = {
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, init);
|
||||
const token = getAccessToken();
|
||||
const requestInit = token ? { ...init, headers: withAuthorization(init?.headers, token) } : init;
|
||||
const response = await fetch(path, requestInit);
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseErrorMessage(response));
|
||||
}
|
||||
@@ -62,6 +94,12 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return envelope.data;
|
||||
}
|
||||
|
||||
function withAuthorization(headers: HeadersInit | undefined, token: string) {
|
||||
const authorized = new Headers(headers);
|
||||
authorized.set('Authorization', `Bearer ${token}`);
|
||||
return authorized;
|
||||
}
|
||||
|
||||
async function responseErrorMessage(response: Response) {
|
||||
try {
|
||||
const envelope = (await response.json()) as ApiErrorEnvelope;
|
||||
@@ -85,12 +123,66 @@ function withTraceID(message: string, traceID?: string) {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
session: () => request<SessionInfo>('/api/v2/session'),
|
||||
monitorSummary: (params = new URLSearchParams()) => request<MonitorSummary>(`/api/v2/monitor/summary?${params.toString()}`),
|
||||
monitorMap: (params = new URLSearchParams()) => request<MonitorMapResponse>(`/api/v2/monitor/map?${params.toString()}`),
|
||||
trackPlayback: (params = new URLSearchParams()) => request<TrackPlaybackResponse>(`/api/v2/tracks?${params.toString()}`),
|
||||
metricCatalog: () => request<MetricCatalog>('/api/v2/metrics'),
|
||||
historyMetricCatalog: () => request<HistoryMetricCatalog>('/api/v2/history/metrics'),
|
||||
historyData: (params = new URLSearchParams()) => request<HistoryDataResponse>(`/api/v2/history/query?${params.toString()}`),
|
||||
historySeries: (params = new URLSearchParams()) => request<HistorySeriesResponse>(`/api/v2/history/series?${params.toString()}`),
|
||||
historyExports: () => request<HistoryExportJob[]>('/api/v2/exports'),
|
||||
createHistoryExport: (query: HistoryExportRequest) => request<HistoryExportJob>('/api/v2/exports', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}),
|
||||
accessSummary: (query: AccessQuery) => request<AccessSummary>('/api/v2/access/summary', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}),
|
||||
accessVehicles: (query: AccessQuery) => request<Page<AccessVehicleRow>>('/api/v2/access/vehicles', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}),
|
||||
accessUnresolvedIdentities: (query: AccessUnresolvedIdentityQuery) => request<Page<AccessUnresolvedIdentity>>('/api/v2/access/unresolved-identities', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}),
|
||||
accessThresholds: () => request<AccessThresholdConfig>('/api/v2/access/thresholds'),
|
||||
updateAccessThresholds: (update: AccessThresholdUpdate) => request<AccessThresholdConfig>('/api/v2/access/thresholds', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(update)
|
||||
}),
|
||||
alertSummaryV2: (query: AlertQuery) => request<AlertSummary>('/api/v2/alerts/summary', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}),
|
||||
alertEventsV2: (query: AlertQuery) => request<Page<AlertEvent>>('/api/v2/alerts/events', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}),
|
||||
alertEventV2: (id: string) => request<AlertEvent>(`/api/v2/alerts/events/${encodeURIComponent(id)}`),
|
||||
actOnAlertV2: (id: string, action: Pick<AlertAction, 'action' | 'note'> & { version: number; actor?: string }) => request<AlertEvent>(`/api/v2/alerts/events/${encodeURIComponent(id)}/actions`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(action)
|
||||
}),
|
||||
alertRulesV2: () => request<AlertRule[]>('/api/v2/alerts/rules'),
|
||||
saveAlertRuleV2: (input: AlertRuleInput) => request<AlertRule>(input.version > 0 ? `/api/v2/alerts/rules/${encodeURIComponent(input.id)}` : '/api/v2/alerts/rules', {
|
||||
method: input.version > 0 ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
setAlertRuleEnabledV2: (id: string, update: { version: number; enabled: boolean; actor?: string }) => request<AlertRule>(`/api/v2/alerts/rules/${encodeURIComponent(id)}/enabled`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(update)
|
||||
}),
|
||||
alertNotificationsV2: (params = new URLSearchParams()) => request<Page<AlertNotification>>(`/api/v2/alerts/notifications?${params.toString()}`),
|
||||
readAlertNotificationsV2: (ids: number[]) => request<{ updated: number }>('/api/v2/alerts/notifications/read', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids })
|
||||
}),
|
||||
dashboardSummary: () => request<DashboardSummary>('/api/dashboard/summary'),
|
||||
vehicles: (params = new URLSearchParams()) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`),
|
||||
vehicleResolve: (params = new URLSearchParams()) => request<VehicleIdentityResolution>(`/api/vehicles/resolve?${params.toString()}`),
|
||||
vehicleCoverage: (params = new URLSearchParams()) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`),
|
||||
vehicleCoverageSummary: (params = new URLSearchParams()) => request<VehicleCoverageSummary>(`/api/vehicles/coverage/summary?${params.toString()}`),
|
||||
vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`),
|
||||
vehicleProfile: (vin: string) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`),
|
||||
latestTelemetry: (vin: string) => request<LatestTelemetryResponse>(`/api/v2/vehicles/${encodeURIComponent(vin)}/telemetry/latest`),
|
||||
updateVehicleProfile: (vin: string, input: VehicleProfileInput) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
syncVehicleProfiles: (input: VehicleProfileSyncRequest) => request<VehicleProfileSyncResult>('/api/v2/vehicle-profiles/sync', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
vehicleServiceSummary: () => request<VehicleServiceSummary>('/api/vehicle-service/summary'),
|
||||
vehicleServiceOverview: (params = new URLSearchParams()) => request<VehicleServiceOverview>(`/api/vehicle-service/overview?${params.toString()}`),
|
||||
vehicleServiceOverviews: (query: VehicleOverviewBatchQuery) => request<Page<VehicleServiceOverview>>('/api/vehicle-service/overviews', {
|
||||
|
||||
@@ -4,6 +4,247 @@ export interface ApiEnvelope<T> {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
name: string;
|
||||
role: 'viewer' | 'operator' | 'admin';
|
||||
authMode: 'disabled' | 'enforce';
|
||||
}
|
||||
|
||||
export interface MonitorSummary {
|
||||
totalVehicles: number;
|
||||
onlineVehicles: number;
|
||||
offlineVehicles: number;
|
||||
drivingVehicles: number;
|
||||
idleVehicles: number;
|
||||
alertVehicles: number;
|
||||
unknownVehicles: number;
|
||||
activeToday: number;
|
||||
frameToday: number;
|
||||
alertDataAvailable: boolean;
|
||||
truncated: boolean;
|
||||
asOf: string;
|
||||
}
|
||||
|
||||
export interface MonitorMapPoint {
|
||||
vin: string;
|
||||
plate: string;
|
||||
protocol: string;
|
||||
protocols: string[];
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
speedKmh: number;
|
||||
socPercent: number;
|
||||
totalMileageKm: number;
|
||||
lastSeen: string;
|
||||
status: 'driving' | 'idle' | 'offline' | 'unknown';
|
||||
}
|
||||
|
||||
export interface MonitorMapCluster {
|
||||
id: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
count: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
driving: number;
|
||||
idle: number;
|
||||
unknown: number;
|
||||
}
|
||||
|
||||
export interface MonitorMapResponse {
|
||||
mode: 'clusters' | 'mixed' | 'points';
|
||||
zoom: number;
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
points: MonitorMapPoint[];
|
||||
clusters: MonitorMapCluster[];
|
||||
asOf: string;
|
||||
}
|
||||
|
||||
export interface TrackPlaybackResponse {
|
||||
vin: string;
|
||||
plate: string;
|
||||
points: HistoryLocationRow[];
|
||||
events: TrackPlaybackEvent[];
|
||||
sources: TrackPlaybackSource[];
|
||||
segments: TrackSegment[];
|
||||
stops: TrackStop[];
|
||||
summary: TrackPlaybackSummary;
|
||||
coverage: TrackCoverage;
|
||||
quality: TrackQuality;
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
sampled: boolean;
|
||||
asOf: string;
|
||||
}
|
||||
|
||||
export interface TrackPlaybackSummary {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
distanceKm: number;
|
||||
durationSeconds: number;
|
||||
averageSpeedKmh: number;
|
||||
maximumSpeedKmh: number;
|
||||
pointCount: number;
|
||||
movingSeconds: number;
|
||||
stoppedSeconds: number;
|
||||
stopCount: number;
|
||||
segmentCount: number;
|
||||
}
|
||||
|
||||
export interface TrackPlaybackEvent {
|
||||
index: number;
|
||||
sampledIndex: number;
|
||||
type: 'start' | 'end' | 'stop' | 'acceleration' | 'braking' | 'source_switch' | string;
|
||||
title: string;
|
||||
time: string;
|
||||
speedKmh: number;
|
||||
socPercent: number;
|
||||
socAvailable: boolean;
|
||||
directionDeg?: number;
|
||||
alarmFlag?: number;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
}
|
||||
|
||||
export interface TrackPlaybackSource {
|
||||
protocol: string;
|
||||
pointCount: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
export interface TrackCoverage {
|
||||
requestedStart: string;
|
||||
requestedEnd: string;
|
||||
actualStart: string;
|
||||
actualEnd: string;
|
||||
totalPoints: number;
|
||||
fetchedPoints: number;
|
||||
processedPoints: number;
|
||||
returnedPoints: number;
|
||||
complete: boolean;
|
||||
limitReasons: string[];
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
export interface TrackSegment {
|
||||
index: number;
|
||||
type: 'moving' | 'stopped' | 'gap' | 'point' | string;
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
durationSeconds: number;
|
||||
distanceKm: number;
|
||||
pointCount: number;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
sampledStartIndex: number;
|
||||
sampledEndIndex: number;
|
||||
}
|
||||
|
||||
export interface TrackStop {
|
||||
index: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
durationSeconds: number;
|
||||
pointCount: number;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
sampledIndex: number;
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
export interface TrackQuality {
|
||||
status: 'good' | 'warning' | string;
|
||||
selectedProtocol: string;
|
||||
rawPoints: number;
|
||||
validPoints: number;
|
||||
alternateSourcePoints: number;
|
||||
invalidCoordinatePoints: number;
|
||||
duplicatePoints: number;
|
||||
driftPoints: number;
|
||||
sourceSwitches: number;
|
||||
largeGapCount: number;
|
||||
maximumGapSeconds: number;
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
export interface HistoryMetricCatalog {
|
||||
categories: HistoryDataCategory[];
|
||||
metrics: HistoryMetricDefinition[];
|
||||
}
|
||||
|
||||
export interface MetricCatalog { metrics: MetricDefinition[]; asOf: string; }
|
||||
export interface MetricDefinition {
|
||||
key: string; label: string; description: string; unit: string; category: string; valueType: 'numeric' | 'boolean';
|
||||
protocols: string[]; sourceFields: Record<string, string>; searchable: boolean; chartable: boolean; alertable: boolean;
|
||||
}
|
||||
|
||||
export interface HistoryDataCategory { key: 'location' | 'raw' | 'mileage' | string; label: string; }
|
||||
export interface HistoryMetricDefinition { key: string; label: string; unit: string; category: string; valueType: string; defaultVisible: boolean; }
|
||||
export interface HistoryDataRow { id: string; vin: string; plate: string; protocol: string; deviceTime: string; serverTime: string; quality: string; evidenceId?: string; values: Record<string, unknown>; }
|
||||
export interface HistoryDataSummary { resultRows: number; vehicleCount: number; sources: string[]; queryDurationMs: number; }
|
||||
export interface HistoryDataResponse { category: string; columns: HistoryMetricDefinition[]; rows: HistoryDataRow[]; summary: HistoryDataSummary; total: number; limit: number; offset: number; asOf: string; }
|
||||
export interface HistorySeriesPoint { time: string; value: number | null; min: number | null; max: number | null; count: number; }
|
||||
export interface HistorySeries { vin: string; plate: string; protocol: string; metric: string; label: string; unit: string; aggregation: 'avg' | 'last' | string; points: HistorySeriesPoint[]; }
|
||||
export interface HistorySeriesSummary { rawPointCount: number; bucketCount: number; returnedPointCount: number; seriesCount: number; grainSeconds: number; targetPoints: number; expectedBucketCount: number; missingBucketCount: number; queryDurationMs: number; complete: boolean; evidence: string; }
|
||||
export interface HistorySeriesResponse { metrics: HistoryMetricDefinition[]; series: HistorySeries[]; summary: HistorySeriesSummary; dateFrom: string; dateTo: string; asOf: string; }
|
||||
|
||||
export interface HistoryExportRequest { keywords: string[]; category: string; protocol?: string; dateFrom?: string; dateTo?: string; metrics: string[]; format: 'csv'; }
|
||||
export interface HistoryExportJob { id: string; name: string; status: 'queued' | 'running' | 'completed' | 'failed'; progress: number; format: string; category: string; keywords: string[]; rowCount: number; totalRows: number; processedRows: number; fileSizeBytes: number; error?: string; downloadUrl?: string; createdAt: string; updatedAt: string; completedAt?: string; evidence: string; }
|
||||
|
||||
export interface AccessQuery { keyword?: string; protocol?: string; oem?: string; model?: string; provider?: string; firstSeenFrom?: string; firstSeenTo?: string; latestSeenFrom?: string; latestSeenTo?: string; onlineState?: string; delayState?: string; limit?: number; offset?: number; }
|
||||
export interface AccessUnresolvedIdentityQuery { keyword?: string; protocol?: string; limit?: number; offset?: number; }
|
||||
export interface AccessUnresolvedIdentity {
|
||||
id: string; protocol: string; identifierMasked: string; plate: string; manufacturer: string; sourceEndpoint: string;
|
||||
firstRegisteredAt: string; latestRegisteredAt: string; latestAuthenticatedAt: string; latestSeenAt: string;
|
||||
freshnessSec: number; issueCode: string; recommendedAction: string;
|
||||
}
|
||||
export interface AccessVehicleRow {
|
||||
vin: string; plate: string; oem: string; model: string; company: string; protocol: string; provider: string; source: string;
|
||||
firstSeenAt: string; latestEventAt: string; latestReceivedAt: string; reportIntervalSec: number | null;
|
||||
dataDelaySec: number | null; freshnessSec: number | null; onlineState: 'online' | 'offline' | 'never_reported' | 'unknown';
|
||||
thresholdSec: number; latestMessageType: string; latestEventId: string; latestError: string; delayAbnormal: boolean;
|
||||
firstSeenEvidence: string; firstSeenSource: string; reportIntervalEvidence: string; reportSampleCount: number;
|
||||
}
|
||||
export interface AccessDistribution { name: string; total: number; online: number; onlineRate: number; }
|
||||
export interface AccessSummary {
|
||||
totalVehicles: number; onlineVehicles: number; offlineVehicles: number; longOfflineVehicles: number;
|
||||
neverReported: number; unknownVehicles: number; delayAbnormal: number; reportedToday: number; onlineRate: number;
|
||||
protocols: AccessDistribution[]; oems: AccessDistribution[]; asOf: string; thresholdVersion: number;
|
||||
}
|
||||
export interface AccessProtocolThreshold { protocol: string; thresholdSec: number; }
|
||||
export interface AccessThresholdAudit { version: number; actor: string; changedAt: string; summary: string; }
|
||||
export interface AccessThresholdConfig {
|
||||
version: number; defaultThresholdSec: number; delayThresholdSec: number; longOfflineSec: number;
|
||||
protocols: AccessProtocolThreshold[]; updatedBy: string; updatedAt: string; audit: AccessThresholdAudit[];
|
||||
}
|
||||
export interface AccessThresholdUpdate {
|
||||
version: number; defaultThresholdSec: number; delayThresholdSec: number; longOfflineSec: number;
|
||||
protocols: AccessProtocolThreshold[]; actor?: string;
|
||||
}
|
||||
|
||||
export type AlertSeverity = 'critical' | 'major' | 'minor';
|
||||
export type AlertStatus = 'unprocessed' | 'processing' | 'recovered' | 'closed' | 'ignored';
|
||||
export interface AlertQuery { keyword?: string; severity?: string; status?: string; ruleId?: string; protocol?: string; dateFrom?: string; dateTo?: string; limit?: number; offset?: number; }
|
||||
export interface AlertSummary { active: number; unprocessed: number; processing: number; recovered: number; closed: number; ignored: number; unreadNotifications: number; asOf: string; }
|
||||
export interface AlertAction { id: number; action: string; fromStatus: string; toStatus: string; actor: string; note: string; createdAt: string; }
|
||||
export interface AlertEvent {
|
||||
id: string; ruleId: string; ruleName: string; ruleVersion: number; severity: AlertSeverity; status: AlertStatus;
|
||||
vin: string; plate: string; protocol: string; metric: string; operator: string; triggerValue: number; threshold: number; thresholdHigh: number;
|
||||
unit: string; durationSec: number; location: string; longitude?: number; latitude?: number; sourceEventId: string;
|
||||
eventAt: string; receivedAt: string; triggeredAt: string; recoveredAt: string; handler: string; version: number; actions?: AlertAction[];
|
||||
}
|
||||
export interface AlertRule {
|
||||
id: string; name: string; description: string; severity: AlertSeverity; valueType: 'numeric' | 'boolean'; metric: string;
|
||||
operator: string; threshold: number; thresholdHigh: number; booleanThreshold?: boolean; durationSec: number; recoveryOperator: string;
|
||||
recoveryThreshold: number; repeatIntervalSec: number; scopeProtocols: string[]; scopeVins: string[]; scopeOems: string[]; scopeModels: string[]; scopeCompanies: string[];
|
||||
notificationChannels: string[]; enabled: boolean; version: number; createdBy: string; updatedBy: string; createdAt: string; updatedAt: string;
|
||||
}
|
||||
export interface AlertRuleInput extends Omit<AlertRule, 'createdBy' | 'updatedBy' | 'createdAt' | 'updatedAt'> { actor?: string; }
|
||||
export interface AlertNotification { id: number; eventId: string; title: string; content: string; severity: AlertSeverity; channel: string; read: boolean; createdAt: string; readAt: string; }
|
||||
|
||||
export interface ProtocolStat {
|
||||
protocol: string;
|
||||
online: number;
|
||||
@@ -94,6 +335,7 @@ export interface VehicleDetail {
|
||||
lookupResolved: boolean;
|
||||
resolution?: VehicleIdentityResolution;
|
||||
identity?: VehicleRow;
|
||||
profile?: VehicleProfile;
|
||||
realtimeSummary?: VehicleRealtimeRow;
|
||||
serviceStatus?: VehicleServiceStatus;
|
||||
serviceOverview?: VehicleServiceOverview;
|
||||
@@ -107,6 +349,76 @@ export interface VehicleDetail {
|
||||
quality: Page<QualityIssueRow>;
|
||||
}
|
||||
|
||||
export interface VehicleProfile {
|
||||
vin: string;
|
||||
modelName: string;
|
||||
vehicleType: string;
|
||||
companyName: string;
|
||||
operationStatus: 'unknown' | 'active' | 'inactive' | 'maintenance' | 'retired';
|
||||
accessProvider: string;
|
||||
firstAccessAt: string;
|
||||
runtimeSeconds: number | null;
|
||||
sourceSystem: string;
|
||||
sourceVersion: string;
|
||||
syncedAt: string;
|
||||
version: number;
|
||||
updatedBy: string;
|
||||
updatedAt: string;
|
||||
completeness: number;
|
||||
missingFields: string[];
|
||||
}
|
||||
|
||||
export interface VehicleProfileInput {
|
||||
modelName: string;
|
||||
vehicleType: string;
|
||||
companyName: string;
|
||||
operationStatus: VehicleProfile['operationStatus'];
|
||||
accessProvider: string;
|
||||
firstAccessAt: string;
|
||||
runtimeSeconds: number | null;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface VehicleProfileSyncItem {
|
||||
vin: string;
|
||||
modelName: string;
|
||||
vehicleType: string;
|
||||
companyName: string;
|
||||
operationStatus: VehicleProfile['operationStatus'];
|
||||
accessProvider: string;
|
||||
firstAccessAt: string;
|
||||
runtimeSeconds: number | null;
|
||||
}
|
||||
|
||||
export interface VehicleProfileSyncRequest {
|
||||
sourceSystem: string;
|
||||
sourceVersion: string;
|
||||
conflictPolicy: 'preserve' | 'overwrite';
|
||||
dryRun: boolean;
|
||||
items: VehicleProfileSyncItem[];
|
||||
}
|
||||
|
||||
export interface VehicleProfileSyncItemResult {
|
||||
vin: string;
|
||||
status: 'created' | 'updated' | 'unchanged' | 'conflict_source' | 'conflict_source_version' | 'missing_vehicle';
|
||||
previousSource?: string;
|
||||
previousVersion?: string;
|
||||
profileVersion?: number;
|
||||
}
|
||||
|
||||
export interface VehicleProfileSyncResult {
|
||||
sourceSystem: string;
|
||||
sourceVersion: string;
|
||||
dryRun: boolean;
|
||||
received: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
unchanged: number;
|
||||
conflicted: number;
|
||||
missing: number;
|
||||
items: VehicleProfileSyncItemResult[];
|
||||
}
|
||||
|
||||
export interface VehicleSourceConsistency {
|
||||
sourceCount: number;
|
||||
onlineSourceCount: number;
|
||||
@@ -220,6 +532,9 @@ export interface VehicleRealtimeRow {
|
||||
}
|
||||
|
||||
export interface HistoryLocationRow extends RealtimeLocationRow {
|
||||
socAvailable: boolean;
|
||||
directionDeg?: number;
|
||||
alarmFlag?: number;
|
||||
deviceTime: string;
|
||||
serverTime: string;
|
||||
}
|
||||
@@ -233,9 +548,24 @@ export interface RawFrameRow {
|
||||
deviceTime: string;
|
||||
serverTime: string;
|
||||
rawSizeBytes: number;
|
||||
parseStatus?: string;
|
||||
parseError?: string;
|
||||
sourceEndpoint?: string;
|
||||
parsedFields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LatestTelemetryCategory { key: string; label: string; count: number; }
|
||||
export interface LatestTelemetryValue {
|
||||
key: string; sourceField: string; label: string; description?: string; unit: string; category: string;
|
||||
valueType: string; value: unknown; protocol: string; sourceEndpoint?: string; frameId: string;
|
||||
deviceTime: string; serverTime: string; quality: 'good' | 'stale' | 'warning'; qualityReason: string;
|
||||
freshnessSeconds: number; dataDelaySeconds?: number;
|
||||
}
|
||||
export interface LatestTelemetryResponse {
|
||||
vin: string; categories: LatestTelemetryCategory[]; values: LatestTelemetryValue[]; asOf: string;
|
||||
staleAfterSeconds: number; scannedFrames: number; evidence: string;
|
||||
}
|
||||
|
||||
export interface DailyMileageRow {
|
||||
vin: string;
|
||||
plate: string;
|
||||
@@ -401,6 +731,7 @@ export interface CapacityMetrics {
|
||||
}
|
||||
|
||||
export interface RuntimeInfo {
|
||||
dataMode?: 'mock' | 'production' | string;
|
||||
requestTimeoutMs: number;
|
||||
amapWebJsConfigured?: boolean;
|
||||
amapApiConfigured?: boolean;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { VehicleMap } from './VehicleMap';
|
||||
|
||||
class TestMap {
|
||||
add = vi.fn();
|
||||
addControl = vi.fn();
|
||||
setFitView = vi.fn();
|
||||
destroy = vi.fn();
|
||||
|
||||
constructor(
|
||||
public container: HTMLDivElement,
|
||||
public options: Record<string, unknown>
|
||||
) {}
|
||||
}
|
||||
|
||||
class TestOverlay {
|
||||
setMap = vi.fn();
|
||||
on = vi.fn();
|
||||
}
|
||||
|
||||
class TestScale {}
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__LINGNIU_APP_CONFIG__;
|
||||
delete window.AMapLoader;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('loads AMap base map even when realtime vehicle points are empty', async () => {
|
||||
const mapLoad = vi.fn().mockResolvedValue({
|
||||
Map: TestMap,
|
||||
Marker: TestOverlay,
|
||||
Polyline: TestOverlay,
|
||||
Scale: TestScale
|
||||
});
|
||||
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||
window.AMapLoader = { load: mapLoad };
|
||||
|
||||
render(<VehicleMap points={[]} fallbackLabel="等待真实车辆坐标" />);
|
||||
|
||||
expect(await screen.findByText('高德地图已加载')).toBeInTheDocument();
|
||||
expect(screen.queryByText('等待真实车辆坐标')).not.toBeInTheDocument();
|
||||
expect(mapLoad).toHaveBeenCalledWith({
|
||||
key: 'amap-web-key',
|
||||
version: '2.0',
|
||||
plugins: ['AMap.Scale']
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { Tag } from '@douyinfe/semi-ui';
|
||||
import { getAMapConfig, isAMapConfigured } from '../config/appConfig';
|
||||
import { isValidAMapCoordinate, loadAMap, wgs84ToGcj02, type AMapMap, type AMapOverlay } from '../integrations/amap';
|
||||
|
||||
export type VehicleMapPoint = {
|
||||
id: string;
|
||||
@@ -11,72 +12,8 @@ export type VehicleMapPoint = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type AMapLike = {
|
||||
Map: new (container: HTMLDivElement, options: Record<string, unknown>) => AMapMap;
|
||||
Marker: new (options: Record<string, unknown>) => AMapOverlay;
|
||||
Polyline: new (options: Record<string, unknown>) => AMapOverlay;
|
||||
Scale: new () => unknown;
|
||||
};
|
||||
|
||||
type AMapMap = {
|
||||
add: (overlay: AMapOverlay | AMapOverlay[]) => void;
|
||||
addControl: (control: unknown) => void;
|
||||
setFitView: (overlays?: AMapOverlay[] | null, immediate?: boolean, padding?: number[]) => void;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
type AMapOverlay = {
|
||||
setMap?: (map: AMapMap | null) => void;
|
||||
on?: (eventName: string, handler: () => void) => void;
|
||||
};
|
||||
|
||||
let amapLoaderPromise: Promise<AMapLike> | null = null;
|
||||
|
||||
function validPoint(point: VehicleMapPoint) {
|
||||
return Number.isFinite(point.longitude) && Number.isFinite(point.latitude) && point.longitude !== 0 && point.latitude !== 0;
|
||||
}
|
||||
|
||||
function loadScript(src: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve(), { once: true });
|
||||
existing.addEventListener('error', () => reject(new Error('高德地图 Loader 加载失败')), { once: true });
|
||||
if (window.AMapLoader) resolve();
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('高德地图 Loader 加载失败'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function loadAMap() {
|
||||
const config = getAMapConfig();
|
||||
if (!isAMapConfigured(config)) {
|
||||
return Promise.reject(new Error('高德地图未配置'));
|
||||
}
|
||||
if (!amapLoaderPromise) {
|
||||
if (config.securityServiceHost) {
|
||||
window._AMapSecurityConfig = { serviceHost: config.securityServiceHost };
|
||||
} else if (config.securityJsCode) {
|
||||
window._AMapSecurityConfig = { securityJsCode: config.securityJsCode };
|
||||
}
|
||||
amapLoaderPromise = loadScript('https://webapi.amap.com/loader.js').then(() => {
|
||||
if (!window.AMapLoader) {
|
||||
throw new Error('高德地图 Loader 不可用');
|
||||
}
|
||||
return window.AMapLoader.load({
|
||||
key: config.webJsKey,
|
||||
version: '2.0',
|
||||
plugins: ['AMap.Scale']
|
||||
}) as Promise<AMapLike>;
|
||||
});
|
||||
}
|
||||
return amapLoaderPromise;
|
||||
return isValidAMapCoordinate(point.longitude, point.latitude);
|
||||
}
|
||||
|
||||
function pointToPixelStyle(point: VehicleMapPoint, index: number) {
|
||||
@@ -88,6 +25,38 @@ function pointToPixelStyle(point: VehicleMapPoint, index: number) {
|
||||
return { left: `${left}%`, top: `${top}%` };
|
||||
}
|
||||
|
||||
function escapeHTML(value: string) {
|
||||
return value.replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[char] ?? char));
|
||||
}
|
||||
|
||||
function markerContent(point: VehicleMapPoint, index: number, selected: boolean, mode: 'realtime' | 'track') {
|
||||
const statusClass = point.online === false ? 'vp-amap-marker-offline' : 'vp-amap-marker-online';
|
||||
const selectedClass = selected ? 'vp-amap-marker-selected' : '';
|
||||
const markerText = mode === 'track' ? String(index + 1) : '';
|
||||
const label = selected ? `<span class="vp-amap-marker-label">${escapeHTML(point.label)}</span>` : '';
|
||||
return `<button type="button" class="vp-amap-marker-shell ${selected ? 'vp-amap-marker-shell-selected' : ''}" data-map-vehicle-id="${escapeHTML(point.id)}" aria-label="选择地图车辆 ${escapeHTML(point.label)}"><span class="vp-amap-marker ${statusClass} ${selectedClass}">${markerText}</span>${label}</button>`;
|
||||
}
|
||||
|
||||
function initialMapView(points: VehicleMapPoint[], mode: 'realtime' | 'track') {
|
||||
const firstPoint = points[0];
|
||||
if (firstPoint) {
|
||||
return {
|
||||
center: wgs84ToGcj02(firstPoint.longitude, firstPoint.latitude),
|
||||
zoom: mode === 'track' ? 13 : 11
|
||||
};
|
||||
}
|
||||
return {
|
||||
center: wgs84ToGcj02(104.1954, 35.8617),
|
||||
zoom: 4
|
||||
};
|
||||
}
|
||||
|
||||
export function VehicleMap({
|
||||
points,
|
||||
mode = 'realtime',
|
||||
@@ -116,7 +85,7 @@ export function VehicleMap({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!isAMapConfigured(config) || validPoints.length === 0 || !containerRef.current) {
|
||||
if (!isAMapConfigured(config) || !containerRef.current) {
|
||||
setStatus('fallback');
|
||||
return;
|
||||
}
|
||||
@@ -124,10 +93,11 @@ export function VehicleMap({
|
||||
loadAMap()
|
||||
.then((AMap) => {
|
||||
if (cancelled || !containerRef.current) return;
|
||||
const mapView = initialMapView(validPoints, mode);
|
||||
if (!mapRef.current) {
|
||||
mapRef.current = new AMap.Map(containerRef.current, {
|
||||
zoom: 11,
|
||||
center: [validPoints[0].longitude, validPoints[0].latitude],
|
||||
zoom: mapView.zoom,
|
||||
center: mapView.center,
|
||||
viewMode: '2D',
|
||||
mapStyle: 'amap://styles/normal'
|
||||
});
|
||||
@@ -135,11 +105,12 @@ export function VehicleMap({
|
||||
}
|
||||
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
|
||||
const markers = validPoints.slice(0, 500).map((point, index) => {
|
||||
const selected = point.id === selectedId;
|
||||
const marker = new AMap.Marker({
|
||||
position: [point.longitude, point.latitude],
|
||||
position: wgs84ToGcj02(point.longitude, point.latitude),
|
||||
title: point.title || point.label,
|
||||
zIndex: point.id === selectedId ? 300 : mode === 'track' ? 100 + index : 100,
|
||||
content: `<div class="vp-amap-marker ${point.online === false ? 'vp-amap-marker-offline' : 'vp-amap-marker-online'} ${point.id === selectedId ? 'vp-amap-marker-selected' : ''}">${mode === 'track' ? index + 1 : ''}</div>`
|
||||
zIndex: selected ? 300 : mode === 'track' ? 100 + index : 100,
|
||||
content: markerContent(point, index, selected, mode)
|
||||
});
|
||||
marker.on?.('click', () => onPointSelect?.(point));
|
||||
return marker;
|
||||
@@ -147,7 +118,7 @@ export function VehicleMap({
|
||||
const nextOverlays: AMapOverlay[] = [...markers];
|
||||
if (mode === 'track' && validPoints.length > 1) {
|
||||
nextOverlays.push(new AMap.Polyline({
|
||||
path: validPoints.map((point) => [point.longitude, point.latitude]),
|
||||
path: validPoints.map((point) => wgs84ToGcj02(point.longitude, point.latitude)),
|
||||
strokeColor: '#1664ff',
|
||||
strokeOpacity: 0.85,
|
||||
strokeWeight: 5,
|
||||
@@ -155,8 +126,10 @@ export function VehicleMap({
|
||||
}));
|
||||
}
|
||||
overlaysRef.current = nextOverlays;
|
||||
mapRef.current.add(nextOverlays);
|
||||
mapRef.current.setFitView(nextOverlays, false, [56, 56, 56, 56]);
|
||||
if (nextOverlays.length > 0) {
|
||||
mapRef.current.add(nextOverlays);
|
||||
mapRef.current.setFitView(nextOverlays, false, [56, 56, 56, 56]);
|
||||
}
|
||||
setStatus('ready');
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -196,7 +169,11 @@ export function VehicleMap({
|
||||
{status === 'loading' ? '地图加载中' : fallbackLabel || '高德地图未配置,显示坐标预览'}
|
||||
</Tag>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<Tag className="vp-map-provider-status" color="green">
|
||||
高德地图已加载
|
||||
</Tag>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,15 +4,55 @@ export type AMapRuntimeConfig = {
|
||||
securityServiceHost: string;
|
||||
};
|
||||
|
||||
export type AppRuntimeConfig = AMapRuntimeConfig & {
|
||||
apiBaseUrl: string;
|
||||
prototypeUseRealData: boolean;
|
||||
};
|
||||
|
||||
function runtimeConfig() {
|
||||
return window.__LINGNIU_APP_CONFIG__ ?? {};
|
||||
}
|
||||
|
||||
function boolFromConfig(value: unknown, defaultValue: boolean) {
|
||||
if (value == null || value === '') {
|
||||
return defaultValue;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
return !['0', 'false', 'off', 'no'].includes(String(value).trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function getAMapConfig(): AMapRuntimeConfig {
|
||||
const runtime = window.__LINGNIU_APP_CONFIG__ ?? {};
|
||||
const runtime = runtimeConfig();
|
||||
return {
|
||||
webJsKey: String(runtime.amapWebJsKey || import.meta.env.VITE_AMAP_WEB_JS_KEY || '').trim(),
|
||||
securityJsCode: String(runtime.amapSecurityJsCode || '').trim(),
|
||||
securityServiceHost: String(runtime.amapSecurityServiceHost || '').trim()
|
||||
securityJsCode: String(runtime.amapSecurityJsCode || import.meta.env.VITE_AMAP_SECURITY_JS_CODE || '').trim(),
|
||||
securityServiceHost: String(runtime.amapSecurityServiceHost || import.meta.env.VITE_AMAP_SECURITY_SERVICE_HOST || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
export function isAMapConfigured(config = getAMapConfig()) {
|
||||
return Boolean(config.webJsKey);
|
||||
}
|
||||
|
||||
export function getAppConfig(): AppRuntimeConfig {
|
||||
const runtime = runtimeConfig();
|
||||
const amap = getAMapConfig();
|
||||
return {
|
||||
...amap,
|
||||
apiBaseUrl: String(runtime.apiBaseUrl || import.meta.env.VITE_API_BASE_URL || '').trim().replace(/\/$/, ''),
|
||||
prototypeUseRealData: boolFromConfig(
|
||||
runtime.prototypeUseRealData ?? import.meta.env.VITE_PROTOTYPE_REAL_DATA,
|
||||
true
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function getApiBaseUrl(config = getAppConfig()) {
|
||||
return config.apiBaseUrl;
|
||||
}
|
||||
|
||||
export function shouldUsePrototypeRealData(config = getAppConfig()) {
|
||||
return config.prototypeUseRealData;
|
||||
}
|
||||
|
||||
32
vehicle-data-platform/apps/web/src/integrations/amap.test.ts
Normal file
32
vehicle-data-platform/apps/web/src/integrations/amap.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { buildAMapMarkerURL, gcj02ToWgs84, isValidAMapCoordinate, wgs84ToGcj02 } from './amap';
|
||||
|
||||
test('accepts coordinates inside the China vehicle service range', () => {
|
||||
expect(isValidAMapCoordinate(116.397128, 39.916527)).toBe(true);
|
||||
expect(isValidAMapCoordinate(87.6379, 43.98146)).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects placeholder and out-of-range coordinates', () => {
|
||||
expect(isValidAMapCoordinate(0, 0)).toBe(false);
|
||||
expect(isValidAMapCoordinate(-0.999999, -0.999999)).toBe(false);
|
||||
expect(isValidAMapCoordinate(139.6917, 35.6895)).toBe(false);
|
||||
expect(isValidAMapCoordinate(121.4737, 10)).toBe(false);
|
||||
});
|
||||
|
||||
test('converts WGS-84 coordinates to GCJ-02 and supports a precise viewport round trip', () => {
|
||||
const wgs: [number, number] = [116.397128, 39.916527];
|
||||
const gcj = wgs84ToGcj02(...wgs);
|
||||
expect(gcj[0]).toBeCloseTo(116.40337, 5);
|
||||
expect(gcj[1]).toBeCloseTo(39.91793, 5);
|
||||
const roundTrip = gcj02ToWgs84(...gcj);
|
||||
expect(roundTrip[0]).toBeCloseTo(wgs[0], 6);
|
||||
expect(roundTrip[1]).toBeCloseTo(wgs[1], 6);
|
||||
});
|
||||
|
||||
test('keeps coordinates outside China unchanged and converts AMap marker links at the rendering boundary', () => {
|
||||
expect(wgs84ToGcj02(2.3522, 48.8566)).toEqual([2.3522, 48.8566]);
|
||||
const url = new URL(buildAMapMarkerURL({ longitude: 116.397128, latitude: 39.916527, name: '测试车辆' }));
|
||||
const position = url.searchParams.get('position')?.split(',').map(Number) ?? [];
|
||||
expect(position[0]).toBeCloseTo(116.40337, 5);
|
||||
expect(position[1]).toBeCloseTo(39.91793, 5);
|
||||
});
|
||||
267
vehicle-data-platform/apps/web/src/integrations/amap.ts
Normal file
267
vehicle-data-platform/apps/web/src/integrations/amap.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { getAMapConfig, isAMapConfigured } from '../config/appConfig';
|
||||
|
||||
export type AMapPlugin = 'AMap.Scale' | 'AMap.Geocoder' | 'AMap.ToolBar';
|
||||
|
||||
export type AMapMap = {
|
||||
add: (overlay: AMapOverlay | AMapOverlay[]) => void;
|
||||
addControl: (control: unknown) => void;
|
||||
setFitView: (overlays?: AMapOverlay[] | null, immediate?: boolean, padding?: number[]) => void;
|
||||
destroy: () => void;
|
||||
on?: (eventName: string, handler: (event: unknown) => void) => void;
|
||||
getZoom?: () => number;
|
||||
getBounds?: () => {
|
||||
getSouthWest?: () => { getLng?: () => number; getLat?: () => number };
|
||||
getNorthEast?: () => { getLng?: () => number; getLat?: () => number };
|
||||
};
|
||||
setCenter?: (center: [number, number]) => void;
|
||||
setZoomAndCenter?: (zoom: number, center: [number, number]) => void;
|
||||
panTo?: (center: [number, number], duration?: number) => void;
|
||||
resize?: () => void;
|
||||
};
|
||||
|
||||
export type AMapOverlay = {
|
||||
setMap?: (map: AMapMap | null) => void;
|
||||
on?: (eventName: string, handler: () => void) => void;
|
||||
setPosition?: (position: [number, number]) => void;
|
||||
};
|
||||
|
||||
export type AMapMassMarks = {
|
||||
setMap: (map: AMapMap | null) => void;
|
||||
setData: (data: AMapMassPoint[]) => void;
|
||||
setStyle?: (style: Array<{ url: string; anchor: unknown; size: unknown }>) => void;
|
||||
on: (eventName: string, handler: (event: { data: AMapMassPoint }) => void) => void;
|
||||
};
|
||||
|
||||
export type AMapLabelsLayer = {
|
||||
setMap: (map: AMapMap | null) => void;
|
||||
add: (markers: AMapOverlay | AMapOverlay[]) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
export type AMapMassPoint = {
|
||||
lnglat: [number, number];
|
||||
style: number;
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type AMapAddressComponent = {
|
||||
province?: string;
|
||||
city?: string | string[];
|
||||
district?: string;
|
||||
township?: string;
|
||||
adcode?: string;
|
||||
};
|
||||
|
||||
type AMapGeocoderResult = {
|
||||
info?: string;
|
||||
regeocode?: {
|
||||
formattedAddress?: string;
|
||||
addressComponent?: AMapAddressComponent;
|
||||
};
|
||||
};
|
||||
|
||||
export type AMapAddress = {
|
||||
provider: 'AMap Web JS';
|
||||
formattedAddress: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
township?: string;
|
||||
adcode?: string;
|
||||
};
|
||||
|
||||
export type AMapLike = {
|
||||
Map: new (container: HTMLDivElement, options: Record<string, unknown>) => AMapMap;
|
||||
Marker: new (options: Record<string, unknown>) => AMapOverlay;
|
||||
Polyline: new (options: Record<string, unknown>) => AMapOverlay;
|
||||
Scale: new () => unknown;
|
||||
ToolBar?: new (options?: Record<string, unknown>) => unknown;
|
||||
Size: new (width: number, height: number) => unknown;
|
||||
Pixel: new (x: number, y: number) => unknown;
|
||||
MassMarks: new (data: AMapMassPoint[], options: Record<string, unknown>) => AMapMassMarks;
|
||||
LabelsLayer?: new (options?: Record<string, unknown>) => AMapLabelsLayer;
|
||||
LabelMarker?: new (options: Record<string, unknown>) => AMapOverlay;
|
||||
Geocoder?: new (options?: Record<string, unknown>) => {
|
||||
getAddress: (
|
||||
lnglat: [number, number],
|
||||
callback: (status: string, result: AMapGeocoderResult | string) => void
|
||||
) => void;
|
||||
};
|
||||
};
|
||||
|
||||
const amapLoaderPromises = new Map<string, Promise<AMapLike>>();
|
||||
|
||||
function loadScript(src: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (window.AMapLoader) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve(), { once: true });
|
||||
existing.addEventListener('error', () => reject(new Error('高德地图 Loader 加载失败')), { once: true });
|
||||
if (window.AMapLoader) resolve();
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('高德地图 Loader 加载失败'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function pluginKey(plugins: AMapPlugin[]) {
|
||||
return plugins.slice().sort().join('|') || 'base';
|
||||
}
|
||||
|
||||
function normalizeCity(value: AMapAddressComponent['city']) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(Boolean).join('/');
|
||||
}
|
||||
return String(value ?? '').trim() || undefined;
|
||||
}
|
||||
|
||||
const GCJ_A = 6378245;
|
||||
const GCJ_EE = 0.006693421622965943;
|
||||
|
||||
function outsideGcjChina(longitude: number, latitude: number) {
|
||||
return longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271;
|
||||
}
|
||||
|
||||
function transformLatitude(longitude: number, latitude: number) {
|
||||
let value = -100 + 2 * longitude + 3 * latitude + 0.2 * latitude * latitude
|
||||
+ 0.1 * longitude * latitude + 0.2 * Math.sqrt(Math.abs(longitude));
|
||||
value += (20 * Math.sin(6 * longitude * Math.PI) + 20 * Math.sin(2 * longitude * Math.PI)) * 2 / 3;
|
||||
value += (20 * Math.sin(latitude * Math.PI) + 40 * Math.sin(latitude / 3 * Math.PI)) * 2 / 3;
|
||||
value += (160 * Math.sin(latitude / 12 * Math.PI) + 320 * Math.sin(latitude * Math.PI / 30)) * 2 / 3;
|
||||
return value;
|
||||
}
|
||||
|
||||
function transformLongitude(longitude: number, latitude: number) {
|
||||
let value = 300 + longitude + 2 * latitude + 0.1 * longitude * longitude
|
||||
+ 0.1 * longitude * latitude + 0.1 * Math.sqrt(Math.abs(longitude));
|
||||
value += (20 * Math.sin(6 * longitude * Math.PI) + 20 * Math.sin(2 * longitude * Math.PI)) * 2 / 3;
|
||||
value += (20 * Math.sin(longitude * Math.PI) + 40 * Math.sin(longitude / 3 * Math.PI)) * 2 / 3;
|
||||
value += (150 * Math.sin(longitude / 12 * Math.PI) + 300 * Math.sin(longitude / 30 * Math.PI)) * 2 / 3;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function wgs84ToGcj02(longitude: number, latitude: number): [number, number] {
|
||||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude) || outsideGcjChina(longitude, latitude)) {
|
||||
return [longitude, latitude];
|
||||
}
|
||||
const latitudeOffset = transformLatitude(longitude - 105, latitude - 35);
|
||||
const longitudeOffset = transformLongitude(longitude - 105, latitude - 35);
|
||||
const radianLatitude = latitude / 180 * Math.PI;
|
||||
const magic = 1 - GCJ_EE * Math.sin(radianLatitude) ** 2;
|
||||
const sqrtMagic = Math.sqrt(magic);
|
||||
const convertedLatitude = latitude + latitudeOffset * 180 / ((GCJ_A * (1 - GCJ_EE)) / (magic * sqrtMagic) * Math.PI);
|
||||
const convertedLongitude = longitude + longitudeOffset * 180 / (GCJ_A / sqrtMagic * Math.cos(radianLatitude) * Math.PI);
|
||||
return [convertedLongitude, convertedLatitude];
|
||||
}
|
||||
|
||||
export function gcj02ToWgs84(longitude: number, latitude: number): [number, number] {
|
||||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude) || outsideGcjChina(longitude, latitude)) {
|
||||
return [longitude, latitude];
|
||||
}
|
||||
let wgsLongitude = longitude;
|
||||
let wgsLatitude = latitude;
|
||||
for (let iteration = 0; iteration < 3; iteration += 1) {
|
||||
const [convertedLongitude, convertedLatitude] = wgs84ToGcj02(wgsLongitude, wgsLatitude);
|
||||
wgsLongitude -= convertedLongitude - longitude;
|
||||
wgsLatitude -= convertedLatitude - latitude;
|
||||
}
|
||||
return [wgsLongitude, wgsLatitude];
|
||||
}
|
||||
|
||||
export function isValidAMapCoordinate(longitude: number, latitude: number) {
|
||||
return (
|
||||
Number.isFinite(longitude) &&
|
||||
Number.isFinite(latitude) &&
|
||||
longitude >= 73 &&
|
||||
longitude <= 135 &&
|
||||
latitude >= 18 &&
|
||||
latitude <= 54
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAMapMarkerURL({
|
||||
longitude,
|
||||
latitude,
|
||||
name
|
||||
}: {
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
name: string;
|
||||
}) {
|
||||
const [mapLongitude, mapLatitude] = wgs84ToGcj02(longitude, latitude);
|
||||
return `https://uri.amap.com/marker?position=${mapLongitude},${mapLatitude}&name=${encodeURIComponent(name)}&src=lingniu-vehicle-platform`;
|
||||
}
|
||||
|
||||
export function loadAMap(plugins: AMapPlugin[] = ['AMap.Scale']) {
|
||||
const config = getAMapConfig();
|
||||
if (!isAMapConfigured(config)) {
|
||||
return Promise.reject(new Error('高德地图未配置'));
|
||||
}
|
||||
const key = pluginKey(plugins);
|
||||
const cached = amapLoaderPromises.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
if (config.securityServiceHost) {
|
||||
window._AMapSecurityConfig = { serviceHost: config.securityServiceHost };
|
||||
} else if (config.securityJsCode) {
|
||||
window._AMapSecurityConfig = { securityJsCode: config.securityJsCode };
|
||||
}
|
||||
const promise = loadScript('https://webapi.amap.com/loader.js').then(() => {
|
||||
if (!window.AMapLoader) {
|
||||
throw new Error('高德地图 Loader 不可用');
|
||||
}
|
||||
return window.AMapLoader.load({
|
||||
key: config.webJsKey,
|
||||
version: '2.0',
|
||||
plugins
|
||||
}) as Promise<AMapLike>;
|
||||
});
|
||||
amapLoaderPromises.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export async function reverseGeocodeWithAMap(longitude: number, latitude: number): Promise<AMapAddress> {
|
||||
if (!isValidAMapCoordinate(longitude, latitude)) {
|
||||
throw new Error('坐标不可用');
|
||||
}
|
||||
const [mapLongitude, mapLatitude] = wgs84ToGcj02(longitude, latitude);
|
||||
const AMap = await loadAMap(['AMap.Geocoder']);
|
||||
if (!AMap.Geocoder) {
|
||||
throw new Error('高德 Geocoder 插件不可用');
|
||||
}
|
||||
const geocoder = new AMap.Geocoder({ city: '全国' });
|
||||
return new Promise((resolve, reject) => {
|
||||
geocoder.getAddress([mapLongitude, mapLatitude], (status, result) => {
|
||||
if (status !== 'complete' || typeof result === 'string') {
|
||||
reject(new Error(typeof result === 'string' ? result : result?.info || '高德地址解析失败'));
|
||||
return;
|
||||
}
|
||||
const formattedAddress = String(result.regeocode?.formattedAddress ?? '').trim();
|
||||
if (!formattedAddress) {
|
||||
reject(new Error('高德地址解析返回空地址'));
|
||||
return;
|
||||
}
|
||||
const component = result.regeocode?.addressComponent ?? {};
|
||||
resolve({
|
||||
provider: 'AMap Web JS',
|
||||
formattedAddress,
|
||||
province: String(component.province ?? '').trim() || undefined,
|
||||
city: normalizeCity(component.city),
|
||||
district: String(component.district ?? '').trim() || undefined,
|
||||
township: String(component.township ?? '').trim() || undefined,
|
||||
adcode: String(component.adcode ?? '').trim() || undefined
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { AppV2 } from './v2/AppV2';
|
||||
import './styles/global.css';
|
||||
import './v2/styles/v2.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<AppV2 />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
564
vehicle-data-platform/apps/web/src/prototype/mockData.ts
Normal file
564
vehicle-data-platform/apps/web/src/prototype/mockData.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
import type {
|
||||
AlertEvent,
|
||||
DataSourceRecord,
|
||||
FieldOption,
|
||||
HistoryRecord,
|
||||
Protocol,
|
||||
TrackPoint,
|
||||
VehicleRecord,
|
||||
VehicleSourceSnapshot,
|
||||
VehicleStatus
|
||||
} from './types';
|
||||
|
||||
export const protocolMeta: Record<Protocol, { label: string; short: string; color: 'blue' | 'green' | 'orange' }> = {
|
||||
GB32960: { label: 'GB/T 32960', short: '32960', color: 'blue' },
|
||||
JT808: { label: 'JT/T 808', short: '808', color: 'green' },
|
||||
YUTONG_MQTT: { label: '宇通 MQTT', short: 'MQTT', color: 'orange' }
|
||||
};
|
||||
|
||||
export const statusLabel: Record<VehicleStatus, string> = {
|
||||
online: '在线',
|
||||
warning: '关注',
|
||||
offline: '离线'
|
||||
};
|
||||
|
||||
export const sourceHealthLabel = {
|
||||
healthy: '健康',
|
||||
warning: '波动',
|
||||
down: '中断'
|
||||
} as const;
|
||||
|
||||
export const dataSources: DataSourceRecord[] = [
|
||||
{
|
||||
id: 'src-hyundai',
|
||||
name: '现代 HTWO 平台',
|
||||
protocol: 'GB32960',
|
||||
endpoint: '47.99.166.38:32960',
|
||||
platformAccount: 'Hyundai',
|
||||
health: 'warning',
|
||||
vehicleCount: 184,
|
||||
onlineCount: 41,
|
||||
latencyP95Ms: 312,
|
||||
lastFrameAt: '2026-07-08 12:05:33',
|
||||
owner: '数智中心'
|
||||
},
|
||||
{
|
||||
id: 'src-yuejin',
|
||||
name: '跃进 32960 平台',
|
||||
protocol: 'GB32960',
|
||||
endpoint: '115.29.187.205:32960',
|
||||
platformAccount: 'YueJin',
|
||||
health: 'healthy',
|
||||
vehicleCount: 96,
|
||||
onlineCount: 83,
|
||||
latencyP95Ms: 188,
|
||||
lastFrameAt: '2026-07-08 12:06:08',
|
||||
owner: '接入组'
|
||||
},
|
||||
{
|
||||
id: 'src-g7s',
|
||||
name: 'G7s 转发',
|
||||
protocol: 'JT808',
|
||||
endpoint: '115.29.187.205:808',
|
||||
platformAccount: 'g7gps',
|
||||
health: 'healthy',
|
||||
vehicleCount: 312,
|
||||
onlineCount: 229,
|
||||
latencyP95Ms: 246,
|
||||
lastFrameAt: '2026-07-08 12:06:11',
|
||||
owner: '接入组'
|
||||
},
|
||||
{
|
||||
id: 'src-ga',
|
||||
name: '广安车联',
|
||||
protocol: 'JT808',
|
||||
endpoint: '115.231.168.135:43625',
|
||||
platformAccount: 'phone-binding',
|
||||
health: 'healthy',
|
||||
vehicleCount: 128,
|
||||
onlineCount: 107,
|
||||
latencyP95Ms: 271,
|
||||
lastFrameAt: '2026-07-08 12:05:59',
|
||||
owner: '运营'
|
||||
},
|
||||
{
|
||||
id: 'src-yutong',
|
||||
name: '宇通 MQTT',
|
||||
protocol: 'YUTONG_MQTT',
|
||||
endpoint: '/ytforward/shln/3',
|
||||
platformAccount: 'yutong-forward',
|
||||
health: 'warning',
|
||||
vehicleCount: 58,
|
||||
onlineCount: 50,
|
||||
latencyP95Ms: 438,
|
||||
lastFrameAt: '2026-07-08 12:04:48',
|
||||
owner: '接入组'
|
||||
}
|
||||
];
|
||||
|
||||
function source(
|
||||
id: string,
|
||||
status: VehicleStatus,
|
||||
latencyMs: number,
|
||||
lastSeen: string,
|
||||
fieldCount: number,
|
||||
overrides: Partial<VehicleSourceSnapshot> = {}
|
||||
): VehicleSourceSnapshot {
|
||||
const sourceRecord = dataSources.find((item) => item.id === id);
|
||||
if (!sourceRecord) {
|
||||
throw new Error(`Unknown source ${id}`);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
protocol: sourceRecord.protocol,
|
||||
sourceName: sourceRecord.name,
|
||||
endpoint: sourceRecord.endpoint,
|
||||
platformAccount: sourceRecord.platformAccount,
|
||||
status,
|
||||
latencyMs,
|
||||
lastSeen,
|
||||
fieldCount,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
export const vehicles: VehicleRecord[] = [
|
||||
{
|
||||
id: 'veh-001',
|
||||
plate: '粤AG18312',
|
||||
vin: 'LB9A32A22P0LS1230',
|
||||
phone: '13307795425',
|
||||
oem: 'G7s',
|
||||
status: 'online',
|
||||
city: '上海',
|
||||
address: '上海市奉贤区海湾镇海兴路',
|
||||
longitude: 121.075044,
|
||||
latitude: 30.590921,
|
||||
mapX: 58,
|
||||
mapY: 44,
|
||||
heading: 126,
|
||||
soc: 78.4,
|
||||
hydrogenKg: 12.23,
|
||||
primarySourceId: 'src-hyundai',
|
||||
sources: [
|
||||
source('src-hyundai', 'online', 226, '12 秒前', 86, {
|
||||
totalMileageKm: 119925,
|
||||
todayMileageKm: 86.4,
|
||||
speedKph: 42
|
||||
}),
|
||||
source('src-g7s', 'online', 241, '19 秒前', 34, {
|
||||
totalMileageKm: 119924.7,
|
||||
todayMileageKm: 86.1,
|
||||
speedKph: 41
|
||||
})
|
||||
],
|
||||
flatFields: {
|
||||
GB32960: {
|
||||
'gb32960.vehicle.speed_kph': 42,
|
||||
'gb32960.vehicle.total_mileage_km': 119925,
|
||||
'gb32960.vehicle.soc_pct': 78.4,
|
||||
'gb32960.vehicle.dc_voltage_v': 556.1,
|
||||
'gb32960.vendor.fc_stack.stack_1.avg_cell_voltage_v': 0.79,
|
||||
'gb32960.vendor.fc_stack.stack_1.hydrogen_inlet_pressure_kpa': 130,
|
||||
'gb32960.vendor.fc_auxiliary.stack_1.air_compressor_power_kw': 0.8
|
||||
},
|
||||
JT808: {
|
||||
'jt808.location.speed_kph': 41,
|
||||
'jt808.location.total_mileage_km': 119924.7,
|
||||
'jt808.location.latitude': 30.590901,
|
||||
'jt808.location.longitude': 121.075011,
|
||||
'jt808.location.alarm_flag': 0
|
||||
},
|
||||
YUTONG_MQTT: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'veh-002',
|
||||
plate: '粤AFF7936',
|
||||
vin: 'LB9A32A22P0LS1261',
|
||||
phone: '13426091261',
|
||||
oem: '广安车联',
|
||||
status: 'online',
|
||||
city: '杭州',
|
||||
address: '杭州市萧山区机场高速',
|
||||
longitude: 120.435311,
|
||||
latitude: 30.227898,
|
||||
mapX: 43,
|
||||
mapY: 53,
|
||||
heading: 84,
|
||||
hydrogenKg: 9.4,
|
||||
primarySourceId: 'src-ga',
|
||||
sources: [
|
||||
source('src-ga', 'online', 209, '21 秒前', 31, {
|
||||
totalMileageKm: 48798.9,
|
||||
todayMileageKm: 54.1,
|
||||
speedKph: 27
|
||||
})
|
||||
],
|
||||
flatFields: {
|
||||
GB32960: {},
|
||||
JT808: {
|
||||
'jt808.location.speed_kph': 27,
|
||||
'jt808.location.total_mileage_km': 48798.9,
|
||||
'jt808.location.direction_deg': 84,
|
||||
'jt808.additional.total_mileage_km': 48798.9,
|
||||
'jt808.additional.fuel_level_pct': 61
|
||||
},
|
||||
YUTONG_MQTT: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'veh-003',
|
||||
plate: '粤AG15568',
|
||||
vin: 'LB9A32A23P0LS1222',
|
||||
oem: '宇通',
|
||||
status: 'warning',
|
||||
city: '郑州',
|
||||
address: '郑州市管城回族区经开第八大街',
|
||||
longitude: 113.746221,
|
||||
latitude: 34.716884,
|
||||
mapX: 69,
|
||||
mapY: 34,
|
||||
heading: 0,
|
||||
soc: 65.1,
|
||||
hydrogenKg: 8.7,
|
||||
primarySourceId: 'src-yutong',
|
||||
sources: [
|
||||
source('src-yutong', 'warning', 524, '4 分钟前', 24, {
|
||||
totalMileageKm: 88310,
|
||||
todayMileageKm: 19.8,
|
||||
speedKph: 0
|
||||
})
|
||||
],
|
||||
flatFields: {
|
||||
GB32960: {},
|
||||
JT808: {},
|
||||
YUTONG_MQTT: {
|
||||
'mqtt.vehicle.total_mileage_m': 88310000,
|
||||
'mqtt.vehicle.speed_kph': 0,
|
||||
'mqtt.vehicle.soc_pct': 65.1,
|
||||
'mqtt.vehicle.left_hydrogen_kg': 8.7,
|
||||
'mqtt.fuel_cell.voltage_v': 318,
|
||||
'mqtt.fuel_cell.current_a': 54.9,
|
||||
'mqtt.fuel_cell.air_in_pressure_kpa': 18
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'veh-004',
|
||||
plate: '粤AG18285',
|
||||
vin: 'LB9A32A23P0LS1236',
|
||||
phone: '13426091236',
|
||||
oem: '现代四川',
|
||||
status: 'offline',
|
||||
city: '成都',
|
||||
address: '成都市龙泉驿区车城大道',
|
||||
longitude: 104.269552,
|
||||
latitude: 30.574117,
|
||||
mapX: 31,
|
||||
mapY: 37,
|
||||
heading: 0,
|
||||
soc: 41.2,
|
||||
hydrogenKg: 5.1,
|
||||
primarySourceId: 'src-hyundai',
|
||||
sources: [
|
||||
source('src-hyundai', 'offline', 0, '38 分钟前', 77, {
|
||||
totalMileageKm: 72104,
|
||||
todayMileageKm: 0,
|
||||
speedKph: 0
|
||||
})
|
||||
],
|
||||
flatFields: {
|
||||
GB32960: {
|
||||
'gb32960.vehicle.total_mileage_km': 72104,
|
||||
'gb32960.vehicle.soc_pct': 41.2,
|
||||
'gb32960.vehicle.status': 'offline'
|
||||
},
|
||||
JT808: {},
|
||||
YUTONG_MQTT: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'veh-005',
|
||||
plate: '粤AFN0065',
|
||||
vin: 'LB9A32A23P0LS1270',
|
||||
phone: '13307791270',
|
||||
oem: '广安车联',
|
||||
status: 'online',
|
||||
city: '广安',
|
||||
address: '广安市前锋区物流大道',
|
||||
longitude: 106.884219,
|
||||
latitude: 30.504891,
|
||||
mapX: 51,
|
||||
mapY: 68,
|
||||
heading: 213,
|
||||
hydrogenKg: 11.9,
|
||||
primarySourceId: 'src-ga',
|
||||
sources: [
|
||||
source('src-ga', 'online', 218, '31 秒前', 33, {
|
||||
totalMileageKm: 65177.2,
|
||||
todayMileageKm: 102.6,
|
||||
speedKph: 63
|
||||
})
|
||||
],
|
||||
flatFields: {
|
||||
GB32960: {},
|
||||
JT808: {
|
||||
'jt808.location.speed_kph': 63,
|
||||
'jt808.location.total_mileage_km': 65177.2,
|
||||
'jt808.location.direction_deg': 213,
|
||||
'jt808.additional.total_mileage_km': 65177.2
|
||||
},
|
||||
YUTONG_MQTT: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'veh-006',
|
||||
plate: '粤AG39007',
|
||||
vin: 'LB9A32A25P0LS1223',
|
||||
oem: '跃进',
|
||||
status: 'online',
|
||||
city: '上海',
|
||||
address: '上海市浦东新区申江路',
|
||||
longitude: 121.6431,
|
||||
latitude: 31.2249,
|
||||
mapX: 73,
|
||||
mapY: 57,
|
||||
heading: 36,
|
||||
soc: 82,
|
||||
hydrogenKg: 13.6,
|
||||
primarySourceId: 'src-yuejin',
|
||||
sources: [
|
||||
source('src-yuejin', 'online', 164, '8 秒前', 92, {
|
||||
totalMileageKm: 90441.5,
|
||||
todayMileageKm: 73.2,
|
||||
speedKph: 51
|
||||
})
|
||||
],
|
||||
flatFields: {
|
||||
GB32960: {
|
||||
'gb32960.vehicle.speed_kph': 51,
|
||||
'gb32960.vehicle.total_mileage_km': 90441.5,
|
||||
'gb32960.vehicle.soc_pct': 82,
|
||||
'gb32960.vendor.fc_stack.stack_1.stack_water_outlet_temp_c': 63
|
||||
},
|
||||
JT808: {},
|
||||
YUTONG_MQTT: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'veh-007',
|
||||
plate: '粤AFQ8013',
|
||||
vin: 'LB9A32A25P0LS1254',
|
||||
oem: 'G7s',
|
||||
phone: '13307791254',
|
||||
status: 'warning',
|
||||
city: '苏州',
|
||||
address: '苏州市吴江区同津大道',
|
||||
longitude: 120.6415,
|
||||
latitude: 31.1608,
|
||||
mapX: 63,
|
||||
mapY: 61,
|
||||
heading: 175,
|
||||
hydrogenKg: 7.3,
|
||||
primarySourceId: 'src-g7s',
|
||||
sources: [
|
||||
source('src-g7s', 'warning', 612, '6 分钟前', 34, {
|
||||
totalMileageKm: 40031.8,
|
||||
todayMileageKm: 12.4,
|
||||
speedKph: 18
|
||||
})
|
||||
],
|
||||
flatFields: {
|
||||
GB32960: {},
|
||||
JT808: {
|
||||
'jt808.location.speed_kph': 18,
|
||||
'jt808.location.total_mileage_km': 40031.8,
|
||||
'jt808.location.direction_deg': 175,
|
||||
'jt808.quality.delay_ms': 612
|
||||
},
|
||||
YUTONG_MQTT: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'veh-008',
|
||||
plate: '粤AG37790',
|
||||
vin: 'LB9A32A26P0LS1232',
|
||||
oem: '宇通',
|
||||
status: 'online',
|
||||
city: '上海',
|
||||
address: '上海市金山区亭卫公路',
|
||||
longitude: 121.3309,
|
||||
latitude: 30.7478,
|
||||
mapX: 47,
|
||||
mapY: 72,
|
||||
heading: 302,
|
||||
soc: 74.6,
|
||||
hydrogenKg: 10.5,
|
||||
primarySourceId: 'src-yutong',
|
||||
sources: [
|
||||
source('src-yutong', 'online', 352, '44 秒前', 22, {
|
||||
totalMileageKm: 119925,
|
||||
todayMileageKm: 41.6,
|
||||
speedKph: 29
|
||||
}),
|
||||
source('src-yuejin', 'online', 191, '37 秒前', 84, {
|
||||
totalMileageKm: 119924.9,
|
||||
todayMileageKm: 41.4,
|
||||
speedKph: 30
|
||||
})
|
||||
],
|
||||
flatFields: {
|
||||
GB32960: {
|
||||
'gb32960.vehicle.speed_kph': 30,
|
||||
'gb32960.vehicle.total_mileage_km': 119924.9,
|
||||
'gb32960.vehicle.soc_pct': 74.6
|
||||
},
|
||||
JT808: {},
|
||||
YUTONG_MQTT: {
|
||||
'mqtt.vehicle.total_mileage_m': 119925000,
|
||||
'mqtt.vehicle.speed_kph': 29,
|
||||
'mqtt.vehicle.soc_pct': 74.6,
|
||||
'mqtt.vehicle.left_hydrogen_kg': 10.5
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export const trackPoints: TrackPoint[] = [
|
||||
{ id: 'trk-1', vehicleId: 'veh-001', time: '08:12', state: 'running', address: '奉贤区海湾镇', longitude: 121.0192, latitude: 30.5803, speedKph: 36, mileageKm: 119842.1, note: '早高峰开始运行' },
|
||||
{ id: 'trk-2', vehicleId: 'veh-001', time: '09:48', state: 'running', address: '金山区亭卫公路', longitude: 121.2451, latitude: 30.7359, speedKph: 58, mileageKm: 119887.4, note: '高速路段速度稳定' },
|
||||
{ id: 'trk-3', vehicleId: 'veh-001', time: '10:26', state: 'alert', address: '浦东新区申江路', longitude: 121.6145, latitude: 31.2012, speedKph: 0, mileageKm: 119901.2, note: '出现 92 秒数据断链' },
|
||||
{ id: 'trk-4', vehicleId: 'veh-001', time: '11:36', state: 'stop', address: '奉贤区海兴路', longitude: 121.075044, latitude: 30.590921, speedKph: 0, mileageKm: 119925, note: '车辆停靠,今日里程闭合' },
|
||||
{ id: 'trk-5', vehicleId: 'veh-005', time: '07:52', state: 'running', address: '前锋区物流大道', longitude: 106.8951, latitude: 30.5002, speedKph: 44, mileageKm: 65074.6, note: 'JT808 位置上报正常' },
|
||||
{ id: 'trk-6', vehicleId: 'veh-005', time: '10:18', state: 'running', address: '沪蓉高速广安段', longitude: 106.7289, latitude: 30.4567, speedKph: 63, mileageKm: 65177.2, note: '总里程来自 0x01 附加信息' }
|
||||
];
|
||||
|
||||
export const alertEvents: AlertEvent[] = [
|
||||
{
|
||||
id: 'alt-1',
|
||||
vehicleId: 'veh-001',
|
||||
level: 'P1',
|
||||
type: '断链',
|
||||
title: 'GB32960 心跳延迟',
|
||||
detail: '现代 HTWO 平台 3 台车超过 90 秒未上报,已触发断链预警。',
|
||||
sourceId: 'src-hyundai',
|
||||
time: '2026-07-08 11:58:21',
|
||||
status: 'open'
|
||||
},
|
||||
{
|
||||
id: 'alt-2',
|
||||
vehicleId: 'veh-007',
|
||||
level: 'P2',
|
||||
type: '里程跳变',
|
||||
title: 'JT808 总里程回退',
|
||||
detail: 'G7s 来源出现 1 条总里程小幅回退,统计服务已跳过异常区间。',
|
||||
sourceId: 'src-g7s',
|
||||
time: '2026-07-08 11:41:06',
|
||||
status: 'processing'
|
||||
},
|
||||
{
|
||||
id: 'alt-3',
|
||||
vehicleId: 'veh-003',
|
||||
level: 'P2',
|
||||
type: '字段缺失',
|
||||
title: 'MQTT 字段缺失',
|
||||
detail: '宇通 MQTT 本次只上报经纬度,实时快照使用上一帧补全业务字段。',
|
||||
sourceId: 'src-yutong',
|
||||
time: '2026-07-08 10:36:44',
|
||||
status: 'open'
|
||||
},
|
||||
{
|
||||
id: 'alt-4',
|
||||
vehicleId: 'veh-004',
|
||||
level: 'P1',
|
||||
type: '定位异常',
|
||||
title: '车辆长时间离线',
|
||||
detail: '现代四川来源 38 分钟无实时数据,当前位置仅作为最后有效位置展示。',
|
||||
sourceId: 'src-hyundai',
|
||||
time: '2026-07-08 10:14:09',
|
||||
status: 'open'
|
||||
}
|
||||
];
|
||||
|
||||
export const historyRecords: HistoryRecord[] = [
|
||||
{
|
||||
id: 'raw-gb-20260708-000923',
|
||||
vehicleId: 'veh-001',
|
||||
protocol: 'GB32960',
|
||||
frameType: '实时信息上报 0x02',
|
||||
receiveTime: '2026-07-08 12:05:33.226',
|
||||
deviceTime: '2026-07-08 12:05:32',
|
||||
sourceName: '现代 HTWO 平台',
|
||||
latencyMs: 226,
|
||||
fieldCount: 86,
|
||||
exportable: true
|
||||
},
|
||||
{
|
||||
id: 'raw-808-20260708-002841',
|
||||
vehicleId: 'veh-001',
|
||||
protocol: 'JT808',
|
||||
frameType: '位置信息汇报 0x0200',
|
||||
receiveTime: '2026-07-08 12:05:34.118',
|
||||
deviceTime: '2026-07-08 12:05:33',
|
||||
sourceName: 'G7s 转发',
|
||||
latencyMs: 241,
|
||||
fieldCount: 34,
|
||||
exportable: true
|
||||
},
|
||||
{
|
||||
id: 'raw-mqtt-20260708-000114',
|
||||
vehicleId: 'veh-003',
|
||||
protocol: 'YUTONG_MQTT',
|
||||
frameType: '遥测转发 code=0F80',
|
||||
receiveTime: '2026-07-08 12:04:48.524',
|
||||
deviceTime: '2026-07-08 12:04:47',
|
||||
sourceName: '宇通 MQTT',
|
||||
latencyMs: 524,
|
||||
fieldCount: 24,
|
||||
exportable: true
|
||||
},
|
||||
{
|
||||
id: 'raw-gb-20260708-000927',
|
||||
vehicleId: 'veh-006',
|
||||
protocol: 'GB32960',
|
||||
frameType: '补发信息上报 0x03',
|
||||
receiveTime: '2026-07-08 12:06:08.164',
|
||||
deviceTime: '2026-07-08 12:05:58',
|
||||
sourceName: '跃进 32960 平台',
|
||||
latencyMs: 164,
|
||||
fieldCount: 92,
|
||||
exportable: true
|
||||
},
|
||||
{
|
||||
id: 'raw-808-20260708-002846',
|
||||
vehicleId: 'veh-005',
|
||||
protocol: 'JT808',
|
||||
frameType: '位置信息汇报 0x0200',
|
||||
receiveTime: '2026-07-08 12:05:59.218',
|
||||
deviceTime: '2026-07-08 12:05:58',
|
||||
sourceName: '广安车联',
|
||||
latencyMs: 218,
|
||||
fieldCount: 33,
|
||||
exportable: true
|
||||
}
|
||||
];
|
||||
|
||||
export const fieldOptions: FieldOption[] = [
|
||||
{ key: 'speed_kph', label: '车速', protocol: 'ALL' },
|
||||
{ key: 'total_mileage_km', label: '总里程', protocol: 'ALL' },
|
||||
{ key: 'soc_pct', label: 'SOC', protocol: 'GB32960' },
|
||||
{ key: 'left_hydrogen_kg', label: '剩余氢量', protocol: 'YUTONG_MQTT' },
|
||||
{ key: 'direction_deg', label: '方向角', protocol: 'JT808' },
|
||||
{ key: 'lat_lon', label: '经纬度', protocol: 'ALL' },
|
||||
{ key: 'fc_stack', label: '燃料电池堆', protocol: 'GB32960' },
|
||||
{ key: 'quality_delay', label: '链路延迟', protocol: 'ALL' }
|
||||
];
|
||||
|
||||
export function getVehiclePrimarySource(vehicle: VehicleRecord) {
|
||||
return vehicle.sources.find((item) => item.id === vehicle.primarySourceId) ?? vehicle.sources[0];
|
||||
}
|
||||
|
||||
export function getVehicleProtocols(vehicle: VehicleRecord) {
|
||||
return vehicle.sources.map((item) => item.protocol);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { rawCoordinateAddressEvidence, rawCoordinateEvidence } from './rawEvidence';
|
||||
|
||||
describe('rawCoordinateEvidence', () => {
|
||||
test('converts Yutong MQTT total_mileage meters to kilometers for RAW map evidence', () => {
|
||||
const evidence = rawCoordinateEvidence('YUTONG_MQTT', {
|
||||
'yutong_mqtt.data.longitude': '121.075044',
|
||||
'yutong_mqtt.data.latitude': '30.590921',
|
||||
'yutong_mqtt.data.meter_speed': '27',
|
||||
'yutong_mqtt.data.total_mileage': '119925000'
|
||||
});
|
||||
|
||||
expect(evidence).toMatchObject({
|
||||
longitude: 121.075044,
|
||||
latitude: 30.590921,
|
||||
speedKmh: 27,
|
||||
totalMileageKm: 119925,
|
||||
totalMileageKey: 'yutong_mqtt.data.total_mileage',
|
||||
totalMileageUnitEvidence: 'm->km'
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps GB32960 kilometer mileage unchanged', () => {
|
||||
const evidence = rawCoordinateEvidence('GB32960', {
|
||||
'gb32960.position.longitude': '114.12552',
|
||||
'gb32960.position.latitude': '30.452935',
|
||||
'gb32960.vehicle.speed_kmh': '91.5',
|
||||
'gb32960.vehicle.total_mileage_km': '35370.2'
|
||||
});
|
||||
|
||||
expect(evidence).toMatchObject({
|
||||
longitude: 114.12552,
|
||||
latitude: 30.452935,
|
||||
speedKmh: 91.5,
|
||||
totalMileageKm: 35370.2,
|
||||
totalMileageKey: 'gb32960.vehicle.total_mileage_km',
|
||||
totalMileageUnitEvidence: 'km'
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects out-of-business-range coordinates', () => {
|
||||
expect(rawCoordinateEvidence('JT808', {
|
||||
'jt808.location.longitude': '0',
|
||||
'jt808.location.latitude': '0',
|
||||
'jt808.location.total_mileage_km': '10'
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
test('summarizes reverse geocode state for RAW coordinate evidence', () => {
|
||||
const coordinate = rawCoordinateEvidence('JT808', {
|
||||
'jt808.location.longitude': '121.4737',
|
||||
'jt808.location.latitude': '31.2304',
|
||||
'jt808.location.speed_kmh': '32'
|
||||
});
|
||||
|
||||
expect(rawCoordinateAddressEvidence(coordinate, {
|
||||
status: 'ready',
|
||||
provider: 'AMap Web JS',
|
||||
address: '上海市黄浦区人民大道',
|
||||
region: '上海市/黄浦区'
|
||||
})).toEqual({
|
||||
status: 'ready',
|
||||
label: 'AMap Web JS 已解析',
|
||||
detail: '上海市黄浦区人民大道',
|
||||
evidence: '上海市/黄浦区',
|
||||
tone: 'ready'
|
||||
});
|
||||
|
||||
expect(rawCoordinateAddressEvidence(undefined, { status: 'disabled' })).toMatchObject({
|
||||
status: 'unavailable',
|
||||
label: '无坐标',
|
||||
tone: 'waiting'
|
||||
});
|
||||
});
|
||||
});
|
||||
167
vehicle-data-platform/apps/web/src/prototype/rawEvidence.ts
Normal file
167
vehicle-data-platform/apps/web/src/prototype/rawEvidence.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { isValidAMapCoordinate } from '../integrations/amap';
|
||||
import type { Protocol } from './types';
|
||||
|
||||
export type RawCoordinateEvidence = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
latitudeKey: string;
|
||||
longitudeKey: string;
|
||||
speedKmh?: number;
|
||||
totalMileageKm?: number;
|
||||
totalMileageKey?: string;
|
||||
totalMileageUnitEvidence?: 'km' | 'm->km';
|
||||
directionDeg?: number;
|
||||
altitudeM?: number;
|
||||
};
|
||||
|
||||
export type RawCoordinateAddressInput = {
|
||||
status: 'disabled' | 'loading' | 'ready' | 'unavailable' | 'error';
|
||||
provider?: string;
|
||||
address?: string;
|
||||
region?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type RawCoordinateAddressEvidence = {
|
||||
status: 'ready' | 'loading' | 'unavailable' | 'error';
|
||||
label: string;
|
||||
detail: string;
|
||||
evidence: string;
|
||||
tone: 'ready' | 'warning' | 'waiting' | 'error';
|
||||
};
|
||||
|
||||
function rawNumberValue(value: unknown) {
|
||||
if (value == null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const number = Number(String(value).trim());
|
||||
return Number.isFinite(number) ? number : undefined;
|
||||
}
|
||||
|
||||
function rawFieldScore(key: string) {
|
||||
const normalized = key.toLowerCase();
|
||||
if (normalized.includes('.location.')) return 0;
|
||||
if (normalized.includes('.data.') && !normalized.includes('.root.data.')) return 1;
|
||||
if (normalized.includes('.root.data.')) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function pickRawNumberField(fields: Record<string, unknown>, matcher: (key: string) => boolean) {
|
||||
return Object.entries(fields)
|
||||
.map(([key, value]) => ({ key, value: rawNumberValue(value) }))
|
||||
.filter((item): item is { key: string; value: number } => item.value != null && matcher(item.key.toLowerCase()))
|
||||
.sort((left, right) => rawFieldScore(left.key) - rawFieldScore(right.key))[0];
|
||||
}
|
||||
|
||||
function rawMileageProjection(protocol: Protocol, mileage?: { key: string; value: number }) {
|
||||
if (!mileage) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedKey = mileage.key.toLowerCase();
|
||||
if (normalizedKey.endsWith('.total_mileage_m')) {
|
||||
return { value: mileage.value / 1000, unitEvidence: 'm->km' as const };
|
||||
}
|
||||
if (
|
||||
protocol === 'YUTONG_MQTT' &&
|
||||
normalizedKey.endsWith('.total_mileage') &&
|
||||
!normalizedKey.endsWith('.total_mileage_km')
|
||||
) {
|
||||
return { value: mileage.value / 1000, unitEvidence: 'm->km' as const };
|
||||
}
|
||||
return { value: mileage.value, unitEvidence: 'km' as const };
|
||||
}
|
||||
|
||||
export function rawCoordinateEvidence(
|
||||
protocol: Protocol,
|
||||
fields: Record<string, unknown>
|
||||
): RawCoordinateEvidence | undefined {
|
||||
const latitude = pickRawNumberField(fields, (key) =>
|
||||
key === 'latitude' ||
|
||||
key.endsWith('.latitude') ||
|
||||
key.endsWith('.lat')
|
||||
);
|
||||
const longitude = pickRawNumberField(fields, (key) =>
|
||||
key === 'longitude' ||
|
||||
key.endsWith('.longitude') ||
|
||||
key.endsWith('.lng') ||
|
||||
key.endsWith('.lon')
|
||||
);
|
||||
if (!latitude || !longitude || !isValidAMapCoordinate(longitude.value, latitude.value)) {
|
||||
return undefined;
|
||||
}
|
||||
const speed = pickRawNumberField(fields, (key) =>
|
||||
key.endsWith('.speed_kmh') ||
|
||||
key.endsWith('.speed_kph') ||
|
||||
key.endsWith('.meter_speed')
|
||||
);
|
||||
const mileage = pickRawNumberField(fields, (key) =>
|
||||
key.endsWith('.total_mileage_km') ||
|
||||
key.endsWith('.total_mileage_m') ||
|
||||
key.endsWith('.total_mileage')
|
||||
);
|
||||
const mileageProjection = rawMileageProjection(protocol, mileage);
|
||||
const direction = pickRawNumberField(fields, (key) => key.endsWith('.direction_deg'));
|
||||
const altitude = pickRawNumberField(fields, (key) => key.endsWith('.altitude_m'));
|
||||
return {
|
||||
latitude: latitude.value,
|
||||
longitude: longitude.value,
|
||||
latitudeKey: latitude.key,
|
||||
longitudeKey: longitude.key,
|
||||
speedKmh: speed?.value,
|
||||
totalMileageKm: mileageProjection?.value,
|
||||
totalMileageKey: mileage?.key,
|
||||
totalMileageUnitEvidence: mileageProjection?.unitEvidence,
|
||||
directionDeg: direction?.value,
|
||||
altitudeM: altitude?.value
|
||||
};
|
||||
}
|
||||
|
||||
export function rawCoordinateAddressEvidence(
|
||||
coordinate: RawCoordinateEvidence | undefined,
|
||||
address: RawCoordinateAddressInput
|
||||
): RawCoordinateAddressEvidence {
|
||||
if (!coordinate) {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
label: '无坐标',
|
||||
detail: '当前 RAW 没有可用于高德解析的有效经纬度。',
|
||||
evidence: 'raw parsed_fields',
|
||||
tone: 'waiting'
|
||||
};
|
||||
}
|
||||
if (address.status === 'ready') {
|
||||
const provider = address.provider || 'AMap';
|
||||
return {
|
||||
status: 'ready',
|
||||
label: `${provider} 已解析`,
|
||||
detail: address.address || '已返回地址',
|
||||
evidence: address.region || `${coordinate.latitude.toFixed(6)}, ${coordinate.longitude.toFixed(6)}`,
|
||||
tone: address.error ? 'warning' : 'ready'
|
||||
};
|
||||
}
|
||||
if (address.status === 'loading') {
|
||||
return {
|
||||
status: 'loading',
|
||||
label: '解析中',
|
||||
detail: '正在根据 RAW 经纬度请求逆地理解析。',
|
||||
evidence: `${coordinate.latitude.toFixed(6)}, ${coordinate.longitude.toFixed(6)}`,
|
||||
tone: 'waiting'
|
||||
};
|
||||
}
|
||||
if (address.status === 'error') {
|
||||
return {
|
||||
status: 'error',
|
||||
label: '解析失败',
|
||||
detail: address.error || '地址解析接口或 AMap Web JS 不可用。',
|
||||
evidence: `${coordinate.latitude.toFixed(6)}, ${coordinate.longitude.toFixed(6)}`,
|
||||
tone: 'error'
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'unavailable',
|
||||
label: '待解析',
|
||||
detail: address.error || '地址解析能力未启用,仍可打开高德坐标。',
|
||||
evidence: `${coordinate.latitude.toFixed(6)}, ${coordinate.longitude.toFixed(6)}`,
|
||||
tone: 'waiting'
|
||||
};
|
||||
}
|
||||
50
vehicle-data-platform/apps/web/src/prototype/rawFieldMap.ts
Normal file
50
vehicle-data-platform/apps/web/src/prototype/rawFieldMap.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
const rawFrameFieldAliases: Record<string, string[]> = {
|
||||
speed_kph: [
|
||||
'gb32960.vehicle.speed_kmh',
|
||||
'jt808.location.speed_kmh',
|
||||
'yutong_mqtt.data.meter_speed'
|
||||
],
|
||||
total_mileage_km: [
|
||||
'gb32960.vehicle.total_mileage_km',
|
||||
'jt808.location.total_mileage_km',
|
||||
'yutong_mqtt.data.total_mileage'
|
||||
],
|
||||
soc_pct: [
|
||||
'gb32960.vehicle.soc_percent',
|
||||
'yutong_mqtt.data.battery_capacity_soc'
|
||||
],
|
||||
left_hydrogen_kg: [
|
||||
'gb32960.gd_fc_vehicle_info.hydrogen_mass_kg',
|
||||
'yutong_mqtt.data.left_hydrogen'
|
||||
],
|
||||
direction_deg: [
|
||||
'jt808.location.direction_deg'
|
||||
],
|
||||
lat_lon: [
|
||||
'gb32960.position.latitude',
|
||||
'gb32960.position.longitude',
|
||||
'jt808.location.latitude',
|
||||
'jt808.location.longitude',
|
||||
'yutong_mqtt.data.latitude',
|
||||
'yutong_mqtt.data.longitude'
|
||||
],
|
||||
fc_stack: [
|
||||
'gb32960.gd_fc_stack.stack_count',
|
||||
'gb32960.gd_fc_stack.engine_work_state',
|
||||
'gb32960.gd_fc_stack.avg_cell_voltage_v',
|
||||
'gb32960.gd_fc_stack.stack_water_outlet_temp_c',
|
||||
'gb32960.gd_fc_stack.hydrogen_inlet_pressure_kpa',
|
||||
'gb32960.fuel_cell.fuel_cell_voltage_v',
|
||||
'gb32960.fuel_cell.fuel_cell_current_a'
|
||||
],
|
||||
quality_delay: [
|
||||
'gb32960.device_time.device_time',
|
||||
'jt808.location.device_time',
|
||||
'yutong_mqtt.root.time'
|
||||
]
|
||||
};
|
||||
|
||||
export function expandRawFrameFields(fields: string[]) {
|
||||
const expanded = fields.flatMap((field) => rawFrameFieldAliases[field] ?? [field]);
|
||||
return Array.from(new Set(expanded));
|
||||
}
|
||||
126
vehicle-data-platform/apps/web/src/prototype/realData.test.ts
Normal file
126
vehicle-data-platform/apps/web/src/prototype/realData.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { httpErrorMessage, loadPrototypeRealtimeVehicles } from './realData';
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__LINGNIU_APP_CONFIG__;
|
||||
window.sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('httpErrorMessage', () => {
|
||||
test('preserves HTTP status and compact response body', async () => {
|
||||
const response = new Response(' database connection failed ', { status: 500 });
|
||||
|
||||
await expect(httpErrorMessage(response)).resolves.toBe('HTTP 500: database connection failed');
|
||||
});
|
||||
|
||||
test('marks an empty HTTP error body explicitly', async () => {
|
||||
const response = new Response('', { status: 500 });
|
||||
|
||||
await expect(httpErrorMessage(response)).resolves.toBe('HTTP 500: empty response body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadPrototypeRealtimeVehicles', () => {
|
||||
test('merges realtime snapshot parsed_json fields into the same vehicle as realtime locations', async () => {
|
||||
window.__LINGNIU_APP_CONFIG__ = { prototypeUseRealData: true };
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/realtime/locations')) {
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
items: [{
|
||||
protocol: 'GB32960',
|
||||
vin: 'LB9A32A22R0LS1456',
|
||||
plate: '粤AGP6637',
|
||||
event_time: '2026-07-09 10:38:21.000',
|
||||
latitude: 23.093449,
|
||||
longitude: 113.159489,
|
||||
speed_kmh: 26,
|
||||
total_mileage_km: 22734.4,
|
||||
soc_percent: 68,
|
||||
received_at: '2026-07-09 10:38:24.185',
|
||||
updated_at: '2026-07-09 10:38:25.000'
|
||||
}],
|
||||
total: 1
|
||||
})));
|
||||
}
|
||||
if (url.includes('/api/realtime/snapshots')) {
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
items: [{
|
||||
protocol: 'GB32960',
|
||||
vin: 'LB9A32A22R0LS1456',
|
||||
plate: '粤AGP6637',
|
||||
platform_name: 'Hyundai',
|
||||
peer: '8.134.95.166:35062',
|
||||
parsed_json: JSON.stringify({
|
||||
'gb32960.vehicle.speed_kmh': '32',
|
||||
'gb32960.vehicle.total_mileage_km': '22734.5',
|
||||
'gb32960.gd_fc_stack.stack_water_outlet_temp_c': '65',
|
||||
'gb32960.gd_fc_auxiliary.subsystems.subsystem_1.water_pump_voltage_v': '340'
|
||||
}),
|
||||
event_time: '2026-07-09 10:38:31.000',
|
||||
received_at: '2026-07-09 10:38:34.000',
|
||||
updated_at: '2026-07-09 10:38:35.000'
|
||||
}],
|
||||
total: 1
|
||||
})));
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected fetch ${url}`));
|
||||
});
|
||||
|
||||
const state = await loadPrototypeRealtimeVehicles(10);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/realtime/locations?limit=10&offset=0', expect.any(Object));
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/realtime/snapshots?limit=10&offset=0', expect.any(Object));
|
||||
expect(state.status).toBe('ready');
|
||||
expect(state.vehicles).toHaveLength(1);
|
||||
const vehicle = state.vehicles[0];
|
||||
expect(vehicle.flatFields.GB32960['gb32960.gd_fc_stack.stack_water_outlet_temp_c']).toBe('65');
|
||||
expect(vehicle.flatFields.GB32960['gb32960.gd_fc_auxiliary.subsystems.subsystem_1.water_pump_voltage_v']).toBe('340');
|
||||
expect(vehicle.sources[0].endpoint).toBe('8.134.95.166:35062');
|
||||
expect(vehicle.sources[0].fieldCount).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('converts Yutong MQTT total_mileage meters to source totalMileageKm while preserving raw flat field', async () => {
|
||||
window.__LINGNIU_APP_CONFIG__ = { prototypeUseRealData: true };
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/realtime/locations')) {
|
||||
return Promise.resolve(new Response(JSON.stringify({ items: [], total: 0 })));
|
||||
}
|
||||
if (url.includes('/api/realtime/snapshots')) {
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
items: [{
|
||||
protocol: 'YUTONG_MQTT',
|
||||
vin: 'LMRKH9AC2R1004087',
|
||||
plate: '沪A65516F',
|
||||
peer: 'mqtt://yutong/ytforward/shln/3',
|
||||
parsed_json: JSON.stringify({
|
||||
'yutong_mqtt.data.longitude': '121.075044',
|
||||
'yutong_mqtt.data.latitude': '30.590921',
|
||||
'yutong_mqtt.data.meter_speed': '27',
|
||||
'yutong_mqtt.data.total_mileage': '119925000',
|
||||
'yutong_mqtt.data.battery_capacity_soc': '78.4'
|
||||
}),
|
||||
event_time: '2026-07-09 10:50:37.945',
|
||||
received_at: '2026-07-09 10:50:37.967'
|
||||
}],
|
||||
total: 1
|
||||
})));
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected fetch ${url}`));
|
||||
});
|
||||
|
||||
const state = await loadPrototypeRealtimeVehicles(10);
|
||||
|
||||
expect(state.status).toBe('ready');
|
||||
expect(state.vehicles).toHaveLength(1);
|
||||
const vehicle = state.vehicles[0];
|
||||
expect(vehicle.longitude).toBe(121.075044);
|
||||
expect(vehicle.latitude).toBe(30.590921);
|
||||
expect(vehicle.soc).toBe(78.4);
|
||||
expect(vehicle.sources[0].speedKph).toBe(27);
|
||||
expect(vehicle.sources[0].totalMileageKm).toBe(119925);
|
||||
expect(vehicle.flatFields.YUTONG_MQTT['yutong_mqtt.data.total_mileage']).toBe('119925000');
|
||||
});
|
||||
});
|
||||
1556
vehicle-data-platform/apps/web/src/prototype/realData.ts
Normal file
1556
vehicle-data-platform/apps/web/src/prototype/realData.ts
Normal file
File diff suppressed because it is too large
Load Diff
101
vehicle-data-platform/apps/web/src/prototype/types.ts
Normal file
101
vehicle-data-platform/apps/web/src/prototype/types.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
export type Protocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
|
||||
|
||||
export type VehicleStatus = 'online' | 'warning' | 'offline';
|
||||
|
||||
export type SourceHealth = 'healthy' | 'warning' | 'down';
|
||||
|
||||
export type VehicleSourceSnapshot = {
|
||||
id: string;
|
||||
protocol: Protocol;
|
||||
sourceName: string;
|
||||
endpoint: string;
|
||||
platformAccount: string;
|
||||
status: VehicleStatus;
|
||||
latencyMs: number;
|
||||
lastSeen: string;
|
||||
lastSeenAt?: string;
|
||||
totalMileageKm?: number;
|
||||
todayMileageKm?: number;
|
||||
speedKph?: number;
|
||||
fieldCount: number;
|
||||
};
|
||||
|
||||
export type VehicleRecord = {
|
||||
id: string;
|
||||
plate: string;
|
||||
vin: string;
|
||||
phone?: string;
|
||||
oem: string;
|
||||
status: VehicleStatus;
|
||||
city: string;
|
||||
address: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
mapX: number;
|
||||
mapY: number;
|
||||
heading: number;
|
||||
soc?: number;
|
||||
hydrogenKg?: number;
|
||||
primarySourceId: string;
|
||||
sources: VehicleSourceSnapshot[];
|
||||
flatFields: Record<Protocol, Record<string, string | number | boolean | null>>;
|
||||
};
|
||||
|
||||
export type DataSourceRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
protocol: Protocol;
|
||||
endpoint: string;
|
||||
platformAccount: string;
|
||||
health: SourceHealth;
|
||||
vehicleCount: number;
|
||||
onlineCount: number;
|
||||
latencyP95Ms: number;
|
||||
lastFrameAt: string;
|
||||
owner: string;
|
||||
};
|
||||
|
||||
export type TrackPoint = {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
time: string;
|
||||
timestampMs?: number;
|
||||
state: 'running' | 'stop' | 'alert';
|
||||
address: string;
|
||||
longitude?: number;
|
||||
latitude?: number;
|
||||
speedKph: number;
|
||||
mileageKm: number;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type AlertEvent = {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
level: 'P0' | 'P1' | 'P2';
|
||||
type: '断链' | '里程跳变' | '字段缺失' | '定位异常';
|
||||
title: string;
|
||||
detail: string;
|
||||
sourceId: string;
|
||||
time: string;
|
||||
status: 'open' | 'processing' | 'closed';
|
||||
};
|
||||
|
||||
export type HistoryRecord = {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
protocol: Protocol;
|
||||
frameType: string;
|
||||
receiveTime: string;
|
||||
deviceTime: string;
|
||||
sourceName: string;
|
||||
latencyMs: number;
|
||||
fieldCount: number;
|
||||
exportable: boolean;
|
||||
};
|
||||
|
||||
export type FieldOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
protocol: Protocol | 'ALL';
|
||||
};
|
||||
450
vehicle-data-platform/apps/web/src/prototype/viewModel.test.ts
Normal file
450
vehicle-data-platform/apps/web/src/prototype/viewModel.test.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildDataCapabilityRadar,
|
||||
buildReplayRawLookupWindow,
|
||||
getCommandPriorityItems,
|
||||
getMileageSourceDecisions,
|
||||
getSampleBoundaryCopy,
|
||||
getStatsInterpretationItems,
|
||||
getVehicleDiagnosticSteps,
|
||||
getVehicleDetailDigest,
|
||||
getVehicleSourceResponsibilityRows
|
||||
} from './viewModel';
|
||||
import type { VehicleRecord } from './types';
|
||||
|
||||
describe('buildReplayRawLookupWindow', () => {
|
||||
test('builds a focused China-time RAW lookup window around a replay node', () => {
|
||||
const timestamp = Date.parse('2026-07-09T09:53:20+08:00');
|
||||
|
||||
expect(buildReplayRawLookupWindow(timestamp)).toEqual({
|
||||
dateFrom: '2026-07-09T09:51:20+08:00',
|
||||
dateTo: '2026-07-09T09:55:20+08:00'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMileageSourceDecisions', () => {
|
||||
test('selects one trusted mileage source without summing multiple protocols', () => {
|
||||
const vehicle = testVehicle({ id: '004', protocol: 'GB32960', totalMileageKm: 1000 });
|
||||
vehicle.sources[0].todayMileageKm = 12;
|
||||
vehicle.sources[0].lastSeen = '40 秒前';
|
||||
vehicle.sources.push({
|
||||
id: '004-JT808',
|
||||
protocol: 'JT808',
|
||||
sourceName: 'JT808 来源',
|
||||
endpoint: '/api/realtime/locations',
|
||||
platformAccount: '测试平台',
|
||||
status: 'online',
|
||||
latencyMs: 60,
|
||||
lastSeen: '5 秒前',
|
||||
totalMileageKm: 1000.8,
|
||||
todayMileageKm: 14,
|
||||
fieldCount: 15
|
||||
});
|
||||
|
||||
const [decision] = getMileageSourceDecisions([vehicle]);
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
selectedProtocol: 'JT808',
|
||||
selectedDailyMileageKm: 14,
|
||||
selectedTotalMileageKm: 1000.8,
|
||||
candidateCount: 2,
|
||||
status: 'ready'
|
||||
});
|
||||
expect(decision.sources.map((source) => source.todayMileageKm)).toEqual([12, 14]);
|
||||
expect(decision.selectedDailyMileageKm).not.toBe(26);
|
||||
});
|
||||
|
||||
test('marks vehicles without mileage as missing instead of inventing values', () => {
|
||||
const vehicle = testVehicle({ id: '005', protocol: 'YUTONG_MQTT' });
|
||||
vehicle.sources[0].totalMileageKm = undefined;
|
||||
vehicle.sources[0].todayMileageKm = undefined;
|
||||
|
||||
const [decision] = getMileageSourceDecisions([vehicle]);
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
selectedProtocol: undefined,
|
||||
selectedDailyMileageKm: 0,
|
||||
selectedTotalMileageKm: 0,
|
||||
candidateCount: 0,
|
||||
status: 'missing'
|
||||
});
|
||||
expect(decision.detail).toContain('不展示里程');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSampleBoundaryCopy', () => {
|
||||
test('keeps fallback samples clearly separated from production facts', () => {
|
||||
expect(getSampleBoundaryCopy('history-raw', false)).toMatchObject({
|
||||
title: 'Raw 样例记录已收起',
|
||||
actionLabel: '展开样例记录'
|
||||
});
|
||||
expect(getSampleBoundaryCopy('alert-events', true)).toMatchObject({
|
||||
title: '正在展示样例事件',
|
||||
actionLabel: '收起样例事件'
|
||||
});
|
||||
expect(getSampleBoundaryCopy('source-monitor', false)).toMatchObject({
|
||||
title: '样例来源已收起',
|
||||
actionLabel: '展开样例来源'
|
||||
});
|
||||
|
||||
const allCopy = [
|
||||
getSampleBoundaryCopy('history-raw', true),
|
||||
getSampleBoundaryCopy('alert-events', true),
|
||||
getSampleBoundaryCopy('source-monitor', true)
|
||||
];
|
||||
|
||||
allCopy.forEach((copy) => {
|
||||
expect(copy.detail).toContain('不代表');
|
||||
expect(copy.detail).not.toContain('生产已接入');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function testVehicle({
|
||||
id,
|
||||
protocol,
|
||||
longitude = 121.4,
|
||||
latitude = 31.2,
|
||||
totalMileageKm
|
||||
}: {
|
||||
id: string;
|
||||
protocol: VehicleRecord['sources'][number]['protocol'];
|
||||
longitude?: number;
|
||||
latitude?: number;
|
||||
totalMileageKm?: number;
|
||||
}): VehicleRecord {
|
||||
return {
|
||||
id,
|
||||
plate: `沪A${id}`,
|
||||
vin: `VIN-${id}`,
|
||||
oem: '测试厂商',
|
||||
status: 'online',
|
||||
city: '上海',
|
||||
address: '测试地址',
|
||||
longitude,
|
||||
latitude,
|
||||
mapX: 50,
|
||||
mapY: 50,
|
||||
heading: 0,
|
||||
primarySourceId: `${id}-${protocol}`,
|
||||
sources: [{
|
||||
id: `${id}-${protocol}`,
|
||||
protocol,
|
||||
sourceName: `${protocol} 来源`,
|
||||
endpoint: '/api/realtime/locations',
|
||||
platformAccount: '测试平台',
|
||||
status: 'online',
|
||||
latencyMs: 80,
|
||||
lastSeen: '12 秒前',
|
||||
totalMileageKm,
|
||||
fieldCount: 18
|
||||
}],
|
||||
flatFields: {
|
||||
GB32960: {},
|
||||
JT808: {},
|
||||
YUTONG_MQTT: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildDataCapabilityRadar', () => {
|
||||
test('summarizes only source-backed capabilities for the realtime command surface', () => {
|
||||
const radar = buildDataCapabilityRadar({
|
||||
vehicles: [
|
||||
testVehicle({ id: '001', protocol: 'GB32960', totalMileageKm: 1024.5 }),
|
||||
testVehicle({ id: '002', protocol: 'JT808' })
|
||||
],
|
||||
realtimeStatus: 'ready',
|
||||
realtimeTotal: 2,
|
||||
amapStatus: 'ready',
|
||||
rawQueryEnabled: true,
|
||||
trackQueryEnabled: true
|
||||
});
|
||||
|
||||
expect(radar.map((item) => item.key)).toEqual([
|
||||
'realtime-location',
|
||||
'protocol-coverage',
|
||||
'amap-map',
|
||||
'raw-evidence',
|
||||
'mileage-stat'
|
||||
]);
|
||||
expect(radar.find((item) => item.key === 'realtime-location')).toMatchObject({
|
||||
state: 'ready',
|
||||
metric: '2/2 台'
|
||||
});
|
||||
expect(radar.find((item) => item.key === 'protocol-coverage')).toMatchObject({
|
||||
state: 'partial',
|
||||
metric: '32960 1 / 808 1 / MQTT 0'
|
||||
});
|
||||
expect(radar.find((item) => item.key === 'mileage-stat')).toMatchObject({
|
||||
state: 'partial',
|
||||
metric: '1/2 来源'
|
||||
});
|
||||
});
|
||||
|
||||
test('does not promote mock or unavailable infrastructure as production capability', () => {
|
||||
const radar = buildDataCapabilityRadar({
|
||||
vehicles: [],
|
||||
realtimeStatus: 'error',
|
||||
realtimeTotal: 0,
|
||||
amapStatus: 'error',
|
||||
rawQueryEnabled: false,
|
||||
trackQueryEnabled: false
|
||||
});
|
||||
|
||||
expect(radar.every((item) => item.state !== 'ready')).toBe(true);
|
||||
expect(radar.find((item) => item.key === 'raw-evidence')).toMatchObject({
|
||||
state: 'unavailable',
|
||||
metric: '未启用'
|
||||
});
|
||||
expect(radar.map((item) => item.title).join(' ')).not.toContain('通知');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVehicleDetailDigest', () => {
|
||||
test('condenses a selected vehicle into realtime, trust, and traceability cards', () => {
|
||||
const vehicle = testVehicle({ id: '003', protocol: 'JT808', totalMileageKm: 2048.6 });
|
||||
vehicle.sources.push({
|
||||
id: '003-GB32960',
|
||||
protocol: 'GB32960',
|
||||
sourceName: 'GB32960 来源',
|
||||
endpoint: '/api/realtime/locations',
|
||||
platformAccount: '测试平台',
|
||||
status: 'warning',
|
||||
latencyMs: 230,
|
||||
lastSeen: '4 分钟前',
|
||||
totalMileageKm: 2049.1,
|
||||
fieldCount: 22
|
||||
});
|
||||
|
||||
const digest = getVehicleDetailDigest(vehicle, vehicle.sources[0]);
|
||||
|
||||
expect(digest.map((item) => item.title)).toEqual(['实时状态', '来源可信度', '可追溯证据']);
|
||||
expect(digest[0]).toMatchObject({
|
||||
tone: 'ok',
|
||||
value: '0 km/h'
|
||||
});
|
||||
expect(digest[1]).toMatchObject({
|
||||
tone: 'warning',
|
||||
metric: '1/2 在线'
|
||||
});
|
||||
expect(digest[2]).toMatchObject({
|
||||
tone: 'ready',
|
||||
value: 'JT808 RAW'
|
||||
});
|
||||
expect(digest.map((item) => item.source).join(' ')).toContain('Redis');
|
||||
expect(digest.map((item) => item.source).join(' ')).toContain('TDengine');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVehicleDiagnosticSteps', () => {
|
||||
test('builds an ordered production troubleshooting path for the selected vehicle', () => {
|
||||
const vehicle = testVehicle({ id: '006', protocol: 'JT808', totalMileageKm: 2048.6 });
|
||||
vehicle.sources[0].fieldCount = 0;
|
||||
vehicle.sources.push({
|
||||
id: '006-GB32960',
|
||||
protocol: 'GB32960',
|
||||
sourceName: 'GB32960 来源',
|
||||
endpoint: '/api/realtime/snapshots',
|
||||
platformAccount: '测试平台',
|
||||
status: 'online',
|
||||
latencyMs: 180,
|
||||
lastSeen: '10 秒前',
|
||||
totalMileageKm: 2102.6,
|
||||
fieldCount: 22
|
||||
});
|
||||
|
||||
const steps = getVehicleDiagnosticSteps(vehicle, vehicle.sources[0]);
|
||||
|
||||
expect(steps.map((item) => item.key)).toEqual(['realtime-state', 'source-consistency', 'evidence-loop']);
|
||||
expect(steps[0]).toMatchObject({
|
||||
title: '确认当前态',
|
||||
status: 'warning',
|
||||
action: 'overview'
|
||||
});
|
||||
expect(steps[0].detail).toContain('字段');
|
||||
expect(steps[1]).toMatchObject({
|
||||
title: '核对来源一致性',
|
||||
status: 'warning',
|
||||
action: 'sources'
|
||||
});
|
||||
expect(steps[1].detail).toContain('里程');
|
||||
expect(steps[2]).toMatchObject({
|
||||
title: '回查证据闭环',
|
||||
status: 'ready',
|
||||
action: 'history'
|
||||
});
|
||||
expect(steps[2].detail).toContain('TDengine');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVehicleSourceResponsibilityRows', () => {
|
||||
test('turns protocol sources into an operations responsibility table', () => {
|
||||
const vehicle = testVehicle({ id: '007', protocol: 'JT808', totalMileageKm: 2048.6 });
|
||||
vehicle.sources[0].fieldCount = 0;
|
||||
vehicle.sources.push({
|
||||
id: '007-GB32960',
|
||||
protocol: 'GB32960',
|
||||
sourceName: 'GB32960 来源',
|
||||
endpoint: '/api/realtime/snapshots',
|
||||
platformAccount: '测试平台',
|
||||
status: 'online',
|
||||
latencyMs: 180,
|
||||
lastSeen: '10 秒前',
|
||||
totalMileageKm: 2102.6,
|
||||
fieldCount: 22
|
||||
}, {
|
||||
id: '007-YUTONG',
|
||||
protocol: 'YUTONG_MQTT',
|
||||
sourceName: '宇通 MQTT 来源',
|
||||
endpoint: 'mqtt://yutong/topic',
|
||||
platformAccount: '测试平台',
|
||||
status: 'offline',
|
||||
latencyMs: 900,
|
||||
lastSeen: '12 分钟前',
|
||||
fieldCount: 8
|
||||
});
|
||||
|
||||
const rows = getVehicleSourceResponsibilityRows(vehicle, vehicle.sources[0]);
|
||||
|
||||
expect(rows.map((row) => row.protocol)).toEqual(['JT808', 'GB32960', 'YUTONG_MQTT']);
|
||||
expect(rows[0]).toMatchObject({
|
||||
selected: true,
|
||||
status: 'warning',
|
||||
issueCount: 1,
|
||||
issueSummary: '字段缺失',
|
||||
mileageDeltaKm: 0
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
status: 'warning',
|
||||
issueSummary: '里程差异',
|
||||
mileageDeltaKm: 54
|
||||
});
|
||||
expect(rows[2]).toMatchObject({
|
||||
status: 'error',
|
||||
issueCount: 2,
|
||||
issueSummary: '来源离线 / 里程缺失'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatsInterpretationItems', () => {
|
||||
test('summarizes trusted mileage, daily API, source consistency, and protocol coverage', () => {
|
||||
const ready = testVehicle({ id: '020', protocol: 'GB32960', totalMileageKm: 1000 });
|
||||
ready.sources[0].todayMileageKm = 12;
|
||||
|
||||
const conflict = testVehicle({ id: '021', protocol: 'GB32960', totalMileageKm: 2000 });
|
||||
conflict.sources[0].todayMileageKm = 20;
|
||||
conflict.sources.push({
|
||||
id: '021-JT808',
|
||||
protocol: 'JT808',
|
||||
sourceName: 'JT808 来源',
|
||||
endpoint: '/api/realtime/locations',
|
||||
platformAccount: '测试平台',
|
||||
status: 'online',
|
||||
latencyMs: 60,
|
||||
lastSeen: '4 秒前',
|
||||
totalMileageKm: 2028,
|
||||
todayMileageKm: 22,
|
||||
fieldCount: 16
|
||||
});
|
||||
|
||||
const missing = testVehicle({ id: '022', protocol: 'YUTONG_MQTT' });
|
||||
missing.sources[0].totalMileageKm = undefined;
|
||||
missing.sources[0].todayMileageKm = undefined;
|
||||
|
||||
const items = getStatsInterpretationItems([ready, conflict, missing], 'ALL', false);
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual([
|
||||
'trusted-mileage',
|
||||
'daily-mileage-api',
|
||||
'source-consistency',
|
||||
'protocol-coverage'
|
||||
]);
|
||||
expect(items[0]).toMatchObject({
|
||||
metric: '1/3',
|
||||
tone: 'error'
|
||||
});
|
||||
expect(items[1]).toMatchObject({
|
||||
metric: '待接入',
|
||||
tone: 'waiting'
|
||||
});
|
||||
expect(items[2]).toMatchObject({
|
||||
metric: '1/1 需关注',
|
||||
tone: 'warning'
|
||||
});
|
||||
expect(items[3]).toMatchObject({
|
||||
metric: '32960 2 / 808 1 / MQTT 1',
|
||||
tone: 'ready'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCommandPriorityItems', () => {
|
||||
test('prioritizes source health, coordinate, mileage, and field coverage issues from realtime vehicles', () => {
|
||||
const healthy = testVehicle({ id: '010', protocol: 'GB32960', totalMileageKm: 1024 });
|
||||
healthy.sources[0].fieldCount = 20;
|
||||
healthy.flatFields.GB32960 = {
|
||||
'gb32960.vehicle.speed_kmh': 20,
|
||||
'gb32960.vehicle.total_mileage_km': 1024
|
||||
};
|
||||
|
||||
const offline = testVehicle({ id: '011', protocol: 'JT808', totalMileageKm: 2048 });
|
||||
offline.status = 'offline';
|
||||
offline.sources[0].status = 'offline';
|
||||
offline.sources[0].fieldCount = 0;
|
||||
|
||||
const missingCoordinate = testVehicle({
|
||||
id: '012',
|
||||
protocol: 'YUTONG_MQTT',
|
||||
longitude: 0,
|
||||
latitude: 0,
|
||||
totalMileageKm: 4096
|
||||
});
|
||||
missingCoordinate.sources[0].fieldCount = 12;
|
||||
missingCoordinate.flatFields.YUTONG_MQTT = {
|
||||
'yutong_mqtt.data.longitude': 0,
|
||||
'yutong_mqtt.data.latitude': 0
|
||||
};
|
||||
|
||||
const missingMileage = testVehicle({ id: '013', protocol: 'GB32960' });
|
||||
missingMileage.sources[0].totalMileageKm = undefined;
|
||||
missingMileage.sources[0].todayMileageKm = undefined;
|
||||
missingMileage.sources[0].fieldCount = 0;
|
||||
|
||||
const priorities = getCommandPriorityItems([
|
||||
healthy,
|
||||
offline,
|
||||
missingCoordinate,
|
||||
missingMileage
|
||||
]);
|
||||
|
||||
expect(priorities.map((item) => item.key)).toEqual([
|
||||
'source-health',
|
||||
'coordinate',
|
||||
'mileage',
|
||||
'field-coverage'
|
||||
]);
|
||||
expect(priorities[0]).toMatchObject({
|
||||
value: '1',
|
||||
tone: 'error',
|
||||
vehicleIds: ['011']
|
||||
});
|
||||
expect(priorities[1]).toMatchObject({
|
||||
value: '1',
|
||||
tone: 'warning',
|
||||
vehicleIds: ['012']
|
||||
});
|
||||
expect(priorities[2]).toMatchObject({
|
||||
value: '1',
|
||||
tone: 'warning',
|
||||
vehicleIds: ['013']
|
||||
});
|
||||
expect(priorities[3]).toMatchObject({
|
||||
value: '2',
|
||||
tone: 'warning',
|
||||
vehicleIds: ['011', '013']
|
||||
});
|
||||
});
|
||||
});
|
||||
1316
vehicle-data-platform/apps/web/src/prototype/viewModel.ts
Normal file
1316
vehicle-data-platform/apps/web/src/prototype/viewModel.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7001,6 +7001,26 @@ button.vp-realtime-command-item:focus-visible {
|
||||
box-shadow: var(--vp-shadow-sm);
|
||||
}
|
||||
|
||||
.vp-map-provider-status {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 12px;
|
||||
z-index: 10;
|
||||
box-shadow: var(--vp-shadow-sm);
|
||||
}
|
||||
|
||||
.vp-amap-marker-shell {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.vp-amap-marker {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
@@ -7030,6 +7050,22 @@ button.vp-realtime-command-item:focus-visible {
|
||||
0 10px 22px rgba(16, 24, 40, 0.24);
|
||||
}
|
||||
|
||||
.vp-amap-marker-label {
|
||||
max-width: 116px;
|
||||
padding: 5px 7px;
|
||||
border: 1px solid #b8cdfd;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
color: #164194;
|
||||
box-shadow: 0 10px 22px rgba(16, 24, 40, 0.16);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vp-monitor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 260px;
|
||||
|
||||
8380
vehicle-data-platform/apps/web/src/styles/prototype.css
Normal file
8380
vehicle-data-platform/apps/web/src/styles/prototype.css
Normal file
File diff suppressed because it is too large
Load Diff
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; }
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_AMAP_WEB_JS_KEY?: string;
|
||||
readonly VITE_AMAP_SECURITY_JS_CODE?: string;
|
||||
readonly VITE_AMAP_SECURITY_SERVICE_HOST?: string;
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
readonly VITE_PROTOTYPE_REAL_DATA?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
@@ -13,6 +17,8 @@ interface Window {
|
||||
amapWebJsKey?: string;
|
||||
amapSecurityJsCode?: string;
|
||||
amapSecurityServiceHost?: string;
|
||||
apiBaseUrl?: string;
|
||||
prototypeUseRealData?: boolean | string;
|
||||
};
|
||||
_AMapSecurityConfig?: {
|
||||
securityJsCode?: string;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || 'http://115.29.187.205:20200';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:20300'
|
||||
'/api': apiProxyTarget,
|
||||
'/_AMapService': apiProxyTarget
|
||||
}
|
||||
},
|
||||
test: {
|
||||
|
||||
Reference in New Issue
Block a user