diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index 20c3fa6b..790af1f2 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -131,6 +131,9 @@ export default function App() { if (route.page === 'history' || route.page === 'history-query' || route.page === 'mileage') { setAnalysisVin(route.keyword); } + if (route.page === 'time-monitor') { + setAnalysisVin(route.keyword); + } } if (route.page === 'vehicles') { setVehicleFilters(vehicleFiltersFromRoute(route)); @@ -185,6 +188,12 @@ export default function App() { replaceMileageHash(mileageFilters); return; } + if (page === 'time-monitor') { + const keyword = (activeVin || analysisVin).trim(); + const protocol = activeProtocol.trim(); + replaceHash('time-monitor', keyword, protocol, customerTimeWindow); + return; + } if (page === 'quality' || page === 'alert-events') { replaceQualityHash(qualityFilters); return; @@ -498,6 +507,11 @@ export default function App() { openMileageWithFilters(scopedFilters); return; } + if (page === 'time-monitor') { + setActivePage('time-monitor'); + replaceHash('time-monitor', keyword, protocol, timeWindow); + return; + } if (page === 'alert-events' || page === 'quality') { openQuality(scopedFilters); return; @@ -601,6 +615,11 @@ export default function App() { const pages: Record = { dashboard: , + 'time-monitor': , vehicles: , map: , realtime: , diff --git a/vehicle-data-platform/apps/web/src/domain/appRoute.ts b/vehicle-data-platform/apps/web/src/domain/appRoute.ts index 42643528..e86de730 100644 --- a/vehicle-data-platform/apps/web/src/domain/appRoute.ts +++ b/vehicle-data-platform/apps/web/src/domain/appRoute.ts @@ -1,6 +1,6 @@ import type { PageKey } from '../layout/AppShell'; -const pageKeys = new Set(['dashboard', 'vehicles', 'map', 'realtime', 'detail', 'history', 'history-query', 'mileage', 'alert-events', 'quality', 'notification-rules', 'ops-quality']); +const pageKeys = new Set(['dashboard', 'vehicles', 'map', 'realtime', 'detail', 'history', 'history-query', 'mileage', 'time-monitor', 'alert-events', 'quality', 'notification-rules', 'ops-quality']); export type AppRoute = { page?: PageKey; diff --git a/vehicle-data-platform/apps/web/src/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/layout/AppShell.tsx index b47b9930..02189d65 100644 --- a/vehicle-data-platform/apps/web/src/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/layout/AppShell.tsx @@ -16,7 +16,7 @@ import { useState } from 'react'; import type { VehicleSourceConsistency, VehicleServiceStatus } from '../api/types'; import { isAMapConfigured } from '../config/appConfig'; -export type PageKey = 'dashboard' | 'vehicles' | 'map' | 'realtime' | 'detail' | 'history' | 'history-query' | 'mileage' | 'alert-events' | 'quality' | 'notification-rules' | 'ops-quality'; +export type PageKey = 'dashboard' | 'vehicles' | 'map' | 'realtime' | 'detail' | 'history' | 'history-query' | 'mileage' | 'time-monitor' | 'alert-events' | 'quality' | 'notification-rules' | 'ops-quality'; export type CustomerView = { id: string; @@ -54,6 +54,7 @@ const navGroups = [ items: [ { itemKey: 'history', text: '轨迹回放', icon: }, { itemKey: 'mileage', text: '统计查询', icon: }, + { itemKey: 'time-monitor', text: '时间窗监控', icon: }, { itemKey: 'history-query', text: '数据导出', icon: } ] }, @@ -82,6 +83,7 @@ const customerPrimaryPath = [ { label: '车辆地图', page: 'map' as const }, { label: '轨迹回放', page: 'history' as const }, { label: '统计查询', page: 'mileage' as const }, + { label: '时间窗监控', page: 'time-monitor' as const }, { label: '数据导出', page: 'history-query' as const }, { label: '告警通知', page: 'alert-events' as const } ]; @@ -90,6 +92,7 @@ const customerTaskPath = [ { label: '地图看车', question: '车辆在哪里', page: 'map' as const }, { label: '轨迹回放', question: '这段时间怎么跑', page: 'history' as const }, { label: '统计查询', question: '里程怎么算', page: 'mileage' as const }, + { label: '时间窗监控', question: '这段时间发生什么', page: 'time-monitor' as const }, { label: '数据导出', question: '证据怎么给客户', page: 'history-query' as const }, { label: '告警通知', question: '异常谁处理', page: 'alert-events' as const } ]; @@ -216,6 +219,7 @@ export function AppShell({ { label: '监控', ariaLabel: '顶部实时监控', page: 'realtime' as const }, { label: '轨迹', ariaLabel: '顶部轨迹回放', page: 'history' as const }, { label: '统计', ariaLabel: '顶部统计查询', page: 'mileage' as const }, + { label: '时间窗', ariaLabel: '顶部时间窗监控', page: 'time-monitor' as const }, { label: '导出', ariaLabel: '顶部数据导出', page: 'history-query' as const }, { label: '告警', ariaLabel: '顶部告警事件', page: 'alert-events' as const } ]; diff --git a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx index 4c6af3b4..9f1cc25e 100644 --- a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx @@ -287,7 +287,9 @@ export function Dashboard({ onOpenVehicles, onOpenHistory, onOpenMileage, - customerTimeWindow + customerTimeWindow, + focusMode = 'overview', + initialTimeMonitorFilters }: { onOpenVehicle: (vin: string, protocol?: string) => void; onOpenQuality: (filters?: Record) => void; @@ -297,6 +299,8 @@ export function Dashboard({ onOpenHistory: (filters?: Record) => void; onOpenMileage: (filters?: Record) => void; customerTimeWindow?: Record; + focusMode?: 'overview' | 'time-window'; + initialTimeMonitorFilters?: Record; }) { const [summary, setSummary] = useState(null); const [serviceSummary, setServiceSummary] = useState(null); @@ -304,7 +308,10 @@ export function Dashboard({ const [locations, setLocations] = useState([]); const [qualityIssues, setQualityIssues] = useState([]); const [opsHealth, setOpsHealth] = useState(null); - const [timeMonitorFilters, setTimeMonitorFilters] = useState>(defaultTimeMonitorFilters); + const [timeMonitorFilters, setTimeMonitorFilters] = useState>({ + ...defaultTimeMonitorFilters(), + ...(initialTimeMonitorFilters ?? {}) + }); const [loading, setLoading] = useState(true); const [coverageLoading, setCoverageLoading] = useState(false); const [coverageServiceStatusTitle, setCoverageServiceStatusTitle] = useState(''); @@ -1174,6 +1181,36 @@ export function Dashboard({ onClick: () => copyTimeMonitorReviewPackage() } ]; + const dedicatedTimeMonitorActions = [ + { + label: '轨迹回放', + value: timeMonitorWindowLabel, + detail: '按同一辆车和时间窗回放位置、速度、停留和里程断点。', + color: timeMonitorHasTrajectory ? 'blue' as const : 'orange' as const, + onClick: openTimeMonitorHistory + }, + { + label: '统计查询', + value: timeMonitorWindowLabel, + detail: '用同一时间窗核对区间里程、日报闭合和异常点。', + color: 'green' as const, + onClick: openTimeMonitorMileage + }, + { + label: '历史导出', + value: `${formatCount(summary?.frameToday)} 帧`, + detail: '导出历史位置、原始记录和字段证据,形成客户可复核数据包。', + color: timeMonitorHasHistory ? 'blue' as const : 'orange' as const, + onClick: openTimeMonitorRaw + }, + { + label: '告警复盘', + value: `${formatCount(summary?.issueVehicles)} 告警`, + detail: '用同一时间窗解释异常车辆、通知策略和恢复验收。', + color: timeMonitorHasAlerts ? 'orange' as const : 'green' as const, + onClick: openTimeMonitorAlerts + } + ]; const dataFlowStages = [ { stage: '01', @@ -2600,8 +2637,45 @@ export function Dashboard({ return (
- + + {focusMode === 'time-window' ? ( +
+
+ + 时间窗监控 + {timeMonitorWindowLabel} + {timeMonitorDeliveryStatus.label} + + + 当前监控范围:{timeMonitorScopeValue.keyword || '全部车辆'} / {timeMonitorScopeValue.protocol || '全部数据通道'} / {timeMonitorScopeValue.dateFrom || '-'} 至 {timeMonitorScopeValue.dateTo || '-'} + + + 用同一组筛选条件贯穿地图定位、轨迹复盘、里程对账、历史证据导出和告警说明,避免客户跨页面重复输入。 + +
+
+ {dedicatedTimeMonitorActions.map((item) => ( + + ))} +
+
+ ) : null}
{ expect(screen.getByRole('button', { name: '时间窗下一步动作 复制复盘 交付包' })).toBeInTheDocument(); }); +test('time monitor route exposes a dedicated customer time window workspace', async () => { + window.history.replaceState(null, '', '/#/time-monitor?keyword=%E7%B2%A4A%E6%97%B6%E9%97%B4%E7%AA%97&protocol=JT808&dateFrom=2026-07-01&dateTo=2026-07-03'); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/ops/health')) { + return { + ok: true, + json: async () => ({ + data: { + linkHealth: [], + kafkaLag: 0, + activeConnections: 0, + capacityFindings: [], + redisOnlineKeys: 0, + tdengineWritable: true, + mysqlWritable: true, + runtime: { requestTimeoutMs: 5000, amapWebJsConfigured: true, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false } + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/dashboard/summary')) { + return { + ok: true, + json: async () => ({ + data: { + onlineVehicles: 6, + activeToday: 8, + frameToday: 9988, + issueVehicles: 2, + kafkaLag: 0, + protocols: [], + serviceStatuses: [], + linkHealth: [] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/vehicle-service/summary')) { + return { + ok: true, + json: async () => ({ + data: { + totalVehicles: 12, + boundVehicles: 12, + onlineVehicles: 6, + singleSourceVehicles: 3, + multiSourceVehicles: 4, + noDataVehicles: 2, + identityRequiredVehicles: 0, + serviceStatuses: [], + protocols: [], + missingSources: [] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 8, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByRole('heading', { name: '自定义时间监控' })).toBeInTheDocument(); + expect(screen.getByText('把客户选择的一辆车和一个时间窗固定下来,同时进入实时地图、轨迹回放、里程统计、历史导出和告警复盘。')).toBeInTheDocument(); + expect(screen.getAllByText('时间窗监控').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('当前监控范围:粤A时间窗 / JT808 / 2026-07-01 至 2026-07-03')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '时间窗监控入口 轨迹回放 3 天窗口' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '时间窗监控入口 统计查询 3 天窗口' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '时间窗监控入口 历史导出 9,988 帧' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '时间窗监控入口 告警复盘 2 告警' })).toBeInTheDocument(); +}); + test('dashboard exposes vehicle data center capability matrix', async () => { window.history.replaceState(null, '', '/#/dashboard'); const writeText = vi.fn(() => Promise.resolve());