diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index 63af7c50..6d36c076 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -287,6 +287,12 @@ export default function App() { if (filters.issueType) { issueFilters.issueType = filters.issueType; } + if (filters.dateFrom) { + issueFilters.dateFrom = filters.dateFrom; + } + if (filters.dateTo) { + issueFilters.dateTo = filters.dateTo; + } replaceHash('alert-events', keyword, protocol, issueFilters); }; diff --git a/vehicle-data-platform/apps/web/src/pages/Realtime.tsx b/vehicle-data-platform/apps/web/src/pages/Realtime.tsx index bdad8a99..31aa09a7 100644 --- a/vehicle-data-platform/apps/web/src/pages/Realtime.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Realtime.tsx @@ -84,6 +84,36 @@ function formatRefreshTime(date: Date) { }); } +function padDatePart(value: number) { + return String(value).padStart(2, '0'); +} + +function formatDateTime(value: Date) { + return [ + value.getFullYear(), + padDatePart(value.getMonth() + 1), + padDatePart(value.getDate()) + ].join('-') + ` ${padDatePart(value.getHours())}:${padDatePart(value.getMinutes())}:${padDatePart(value.getSeconds())}`; +} + +function defaultTimeWindow() { + const end = new Date(); + const start = new Date(end.getTime() - 24 * 60 * 60 * 1000); + return { dateFrom: formatDateTime(start), dateTo: formatDateTime(end) }; +} + +function timeWindowDurationText(dateFrom: string, dateTo: string) { + const start = new Date(dateFrom.replace(' ', 'T')).getTime(); + const end = new Date(dateTo.replace(' ', 'T')).getTime(); + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) { + return '时间窗待确认'; + } + const minutes = Math.round((end - start) / 60000); + if (minutes < 60) return `${minutes} 分钟窗口`; + if (minutes < 1440) return `${Math.round(minutes / 60)} 小时窗口`; + return `${Math.round(minutes / 1440)} 天窗口`; +} + function parseVehicleTime(value?: string) { const normalized = value?.trim(); if (!normalized) { @@ -585,6 +615,7 @@ export function Realtime({ const [selectedMapPointId, setSelectedMapPointId] = useState(''); const [lastRefreshAt, setLastRefreshAt] = useState(''); const [autoRefresh, setAutoRefresh] = useState(true); + const [timeWindow, setTimeWindow] = useState(defaultTimeWindow); const refreshIntervalSeconds = 15; const amapConfig = getAMapConfig(); const runtime = opsHealth?.runtime; @@ -678,6 +709,114 @@ export function Realtime({ const defaultMapRow = mapServiceRows[0] ?? rows.find((row) => isValidCoordinate(row) && canOpenVehicle(row.vin)); const selectedMapRow = rows.find((row, index) => realtimeMapPointId(row, index) === selectedMapPointId) ?? defaultMapRow; const selectedMapPointKey = selectedMapRow ? realtimeMapPointId(selectedMapRow, rows.indexOf(selectedMapRow)) : selectedMapPointId; + const timeWindowRow = selectedMapRow && canOpenVehicle(selectedMapRow.vin) + ? selectedMapRow + : rows.find((row) => canOpenVehicle(row.vin)); + const timeWindowKeyword = timeWindowRow?.vin || filters.keyword || ''; + const timeWindowProtocol = timeWindowRow ? filters.protocol || timeWindowRow.primaryProtocol || '' : filters.protocol || ''; + const timeWindowReady = Boolean(timeWindowKeyword && timeWindow.dateFrom && timeWindow.dateTo); + const timeWindowDuration = timeWindowDurationText(timeWindow.dateFrom, timeWindow.dateTo); + const setTimeWindowPreset = (preset: '15m' | '1h' | 'today' | '24h') => { + const end = new Date(); + const start = new Date(end); + if (preset === '15m') start.setMinutes(end.getMinutes() - 15); + if (preset === '1h') start.setHours(end.getHours() - 1); + if (preset === '24h') start.setDate(end.getDate() - 1); + if (preset === 'today') { + start.setHours(0, 0, 0, 0); + } + setTimeWindow({ dateFrom: formatDateTime(start), dateTo: formatDateTime(end) }); + }; + const timeWindowFilters = (extra: Record = {}) => ({ + keyword: timeWindowKeyword, + protocol: timeWindowProtocol, + dateFrom: timeWindow.dateFrom, + dateTo: timeWindow.dateTo, + ...extra + }); + const openTimeWindowHistory = () => { + if (!timeWindowReady) { + Toast.warning('请先选择车辆和时间窗'); + return; + } + window.location.hash = buildAppHash({ page: 'history', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters() }); + }; + const openTimeWindowRaw = () => { + if (!timeWindowReady) { + Toast.warning('请先选择车辆和时间窗'); + return; + } + window.location.hash = buildAppHash({ + page: 'history-query', + keyword: timeWindowKeyword, + protocol: timeWindowProtocol, + filters: timeWindowFilters({ tab: 'raw', includeFields: 'true' }) + }); + }; + const openTimeWindowMileage = () => { + if (!timeWindowReady) { + Toast.warning('请先选择车辆和时间窗'); + return; + } + window.location.hash = buildAppHash({ page: 'mileage', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters() }); + }; + const openTimeWindowQuality = () => { + if (!timeWindowReady || !onOpenQuality) { + Toast.warning('请先选择车辆和时间窗'); + return; + } + onOpenQuality(timeWindowFilters()); + }; + const copyTimeWindowPackage = () => copyText([ + '【客户时间窗监控包】', + `车辆:${timeWindowRow ? [timeWindowRow.plate, timeWindowRow.vin].filter(Boolean).join(' / ') : timeWindowKeyword || '-'}`, + `数据通道:${timeWindowProtocol || '全部数据通道'}`, + `时间窗:${timeWindow.dateFrom} 至 ${timeWindow.dateTo}`, + `时间窗判定:${timeWindowDuration}`, + `实时状态:${timeWindowRow ? `${vehicleServiceStatus(timeWindowRow).label} / ${dataFreshness(timeWindowRow).detail}` : '未选车辆'}`, + `轨迹回放:${timeWindowReady ? window.location.origin + window.location.pathname + buildAppHash({ page: 'history', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters() }) : '-'}`, + `历史数据:${timeWindowReady ? window.location.origin + window.location.pathname + buildAppHash({ page: 'history-query', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters({ tab: 'raw', includeFields: 'true' }) }) : '-'}`, + `里程统计:${timeWindowReady ? window.location.origin + window.location.pathname + buildAppHash({ page: 'mileage', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters() }) : '-'}`, + `告警通知:${timeWindowReady ? window.location.origin + window.location.pathname + buildAppHash({ page: 'alert-events', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters() }) : '-'}` + ].join('\n'), '客户时间窗监控包'); + const timeWindowWorkItems = [ + { + title: '轨迹回放', + value: timeWindowDuration, + detail: '按时间窗回放位置、速度、里程断点。', + action: '打开轨迹', + color: 'blue' as const, + disabled: !timeWindowReady, + onClick: openTimeWindowHistory + }, + { + title: '历史数据', + value: timeWindowProtocol || '全部通道', + detail: '查看该时间窗内的历史明细和字段。', + action: '查询历史', + color: 'blue' as const, + disabled: !timeWindowReady, + onClick: openTimeWindowRaw + }, + { + title: '里程统计', + value: timeWindowRow?.totalMileageKm != null ? `${timeWindowRow.totalMileageKm} km` : '待核对', + detail: '核对区间里程、日里程和总里程差值。', + action: '核对里程', + color: 'orange' as const, + disabled: !timeWindowReady, + onClick: openTimeWindowMileage + }, + { + title: '告警通知', + value: timeWindowRow ? realtimeIssueLabels(timeWindowRow)[0] : '待选择', + detail: '查看时间窗内断链、离线、定位异常事件。', + action: '查看告警', + color: timeWindowRow && hasSourceIssue(timeWindowRow) ? 'orange' as const : 'green' as const, + disabled: !timeWindowReady || !onOpenQuality, + onClick: openTimeWindowQuality + } + ]; const mapPoints: VehicleMapPoint[] = rows.map((row, index) => ({ id: realtimeMapPointId(row, index), label: row.plate || row.vin || 'unknown', @@ -1075,6 +1214,74 @@ export function Realtime({ disabled: rows.length === 0 } ]; + const timeWindowMonitorBlock = ( + +
+ + 自定义时间监控 + {timeWindowReady ? '时间窗可用' : '先选车辆'} + {timeWindowDuration} + + 按车辆和时间窗复盘发生了什么 + + 客户问某辆车、某段时间发生了什么时,直接串起轨迹回放、历史数据、里程统计和告警通知。 + + + + + + +
+
+
+
+ 监控车辆 + {timeWindowRow ? [timeWindowRow.plate, timeWindowRow.vin].filter(Boolean).join(' / ') : timeWindowKeyword || '未选择车辆'} + {timeWindowProtocol || '全部数据通道'} +
+ + +
+ + + + +
+
+
+ {timeWindowWorkItems.map((item) => ( + + ))} +
+
+
+ ); if (mode === 'map') { return ( @@ -1225,6 +1432,8 @@ export function Realtime({ + {timeWindowMonitorBlock} +
@@ -1397,6 +1606,7 @@ export function Realtime({ ))}
+ {timeWindowMonitorBlock}
{ const nextFilters = values as Record; diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 9dc55478..1f70fdcd 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -1042,6 +1042,144 @@ body { align-self: end; } +.vp-time-window-monitor { + margin-bottom: 16px; + overflow: hidden; +} + +.vp-time-window-monitor .semi-card-body { + display: grid; + grid-template-columns: minmax(280px, 0.44fr) minmax(0, 1fr); + padding: 0; +} + +.vp-time-window-summary { + padding: 18px; + border-right: 1px solid var(--vp-border); + background: #f9fbff; + display: grid; + gap: 12px; + align-content: start; +} + +.vp-time-window-main { + padding: 16px; + display: grid; + gap: 14px; + background: #fff; +} + +.vp-time-window-controls { + display: grid; + grid-template-columns: minmax(180px, 1.1fr) minmax(190px, 1fr) minmax(190px, 1fr) auto; + gap: 10px; + align-items: end; +} + +.vp-time-window-target, +.vp-time-window-controls label { + min-height: 72px; + padding: 10px 12px; + border: 1px solid var(--vp-border); + border-radius: 8px; + background: #fbfcff; + display: grid; + gap: 4px; + align-content: center; +} + +.vp-time-window-target span, +.vp-time-window-controls label span { + color: var(--vp-text-muted); + font-size: 12px; + line-height: 16px; +} + +.vp-time-window-target strong { + color: var(--vp-text); + font-size: 15px; + line-height: 20px; +} + +.vp-time-window-target small { + color: var(--vp-text-tertiary); + font-size: 12px; +} + +.vp-time-window-controls input { + width: 100%; + border: 0; + outline: none; + background: transparent; + color: var(--vp-text); + font: inherit; + font-size: 14px; + line-height: 20px; +} + +.vp-time-window-controls input:focus { + color: #1664ff; +} + +.vp-time-window-presets { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; +} + +.vp-time-window-actions { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.vp-time-window-action { + min-height: 128px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: 8px; + background: #fbfcff; + text-align: left; + cursor: pointer; + font: inherit; + display: grid; + gap: 8px; + align-content: start; +} + +.vp-time-window-action:hover, +.vp-time-window-action:focus-visible { + border-color: rgba(22, 100, 255, 0.45); + box-shadow: var(--vp-shadow-sm); + outline: none; +} + +.vp-time-window-action:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.vp-time-window-action strong { + color: var(--vp-text); + font-size: 17px; + line-height: 22px; +} + +.vp-time-window-action span { + color: var(--vp-text-muted); + font-size: 12px; + line-height: 18px; +} + +.vp-time-window-action em { + color: #1664ff; + font-style: normal; + font-size: 12px; + font-weight: 600; + align-self: end; +} + .vp-realtime-command-board { margin-bottom: 16px; padding: 14px; @@ -4917,6 +5055,9 @@ button.vp-realtime-command-item:focus-visible { .vp-time-audit-grid, .vp-realtime-customer-board .semi-card-body, .vp-realtime-customer-steps, + .vp-time-window-monitor .semi-card-body, + .vp-time-window-controls, + .vp-time-window-actions, .vp-realtime-command-board, .vp-realtime-command-grid, .vp-realtime-impact-board, diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index 209338f2..5c154553 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -9106,6 +9106,22 @@ test('frames realtime page as one vehicle service with source evidence', async ( expect(writeText).toHaveBeenCalledWith(expect.stringContaining('定位影响:1 辆有有效坐标')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('服务影响:0 辆降级 / 1 辆超时')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('地图能力:高德地图未配置,使用坐标预览')); + expect(screen.getByText('自定义时间监控')).toBeInTheDocument(); + expect(screen.getByText('按车辆和时间窗复盘发生了什么')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '时间窗监控 轨迹回放 打开轨迹' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '时间窗监控 历史数据 查询历史' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /复制时间窗包/ })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【客户时间窗监控包】')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('历史数据:http://localhost:3000/#/history-query?keyword=VIN-RT-SERVICE&protocol=GB32960')); + fireEvent.click(screen.getByRole('button', { name: '时间窗监控 历史数据 查询历史' })); + await waitFor(() => { + const params = new URLSearchParams(window.location.hash.split('?')[1] ?? ''); + expect(window.location.hash).toContain('#/history-query?'); + expect(params.get('keyword')).toBe('VIN-RT-SERVICE'); + expect(params.get('protocol')).toBe('GB32960'); + expect(params.get('dateFrom')).toBeTruthy(); + expect(params.get('dateTo')).toBeTruthy(); + }); }); test('refreshes realtime vehicle data from the monitoring workspace', async () => { @@ -9224,6 +9240,36 @@ test('shows realtime freshness status for recently updated and stale vehicles', }) } as Response; } + if (path.includes('/api/alert-events/summary')) { + return { + ok: true, + json: async () => ({ + data: { issueVehicleCount: 0, issueRecordCount: 0, errorCount: 0, warningCount: 0, protocols: [], issueTypes: [] }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/alert-events/notification-plan')) { + return { + ok: true, + json: async () => ({ + data: { rules: [], policies: [], priorityIssues: [], activeRuleCount: 0, p0RuleCount: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/alert-events')) { + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 50, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } return { ok: true, json: async () => ({ @@ -9285,6 +9331,20 @@ test('shows realtime freshness status for recently updated and stale vehicles', expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【地图客户决策说明】')); }); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('定位判断:2 辆有有效坐标 / 0 辆无坐标')); + expect(screen.getByText('自定义时间监控')).toBeInTheDocument(); + expect(screen.getByText('按车辆和时间窗复盘发生了什么')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /复制时间窗包/ })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【客户时间窗监控包】')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('告警通知:http://localhost:3000/#/alert-events?keyword=VIN-STALE-001&protocol=JT808')); + fireEvent.click(screen.getByRole('button', { name: '时间窗监控 告警通知 查看告警' })); + await waitFor(() => { + const params = new URLSearchParams(window.location.hash.split('?')[1] ?? ''); + expect(window.location.hash).toContain('#/alert-events?'); + expect(params.get('keyword')).toBe('VIN-STALE-001'); + expect(params.get('protocol')).toBe('JT808'); + expect(params.get('dateFrom')).toBeTruthy(); + expect(params.get('dateTo')).toBeTruthy(); + }); }); test('exports current realtime vehicles as CSV', async () => {