diff --git a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx index 755244df..be465cc8 100644 --- a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx @@ -399,6 +399,55 @@ function onlineStatusHandoffText({ ].join('\n'); } +function onlineOperationsReportText({ + filters, + summary, + onlineRows, + total, + statisticsURL, + vehicleURL +}: { + filters: Record; + summary: OnlineStatisticsSummary; + onlineRows: OnlineVehicleStatusRow[]; + total: number; + statisticsURL: string; + vehicleURL?: string; +}) { + const keyword = filters.keyword?.trim() || '全部车辆'; + const protocol = filters.protocol?.trim() || '全部来源'; + const protocolStats = Array.isArray(summary.protocolStats) ? summary.protocolStats : []; + const offlineRows = onlineRows.filter((row) => !row.online); + const longestOffline = offlineRows.reduce((current, row) => { + if (!current) return row; + return Number(row.offlineDurationMinutes ?? 0) > Number(current.offlineDurationMinutes ?? 0) ? row : current; + }, undefined); + return [ + '【在线统计运营态势】', + `车辆范围:${keyword}`, + `数据来源:${protocol}`, + `在线概览:${summary.onlineVehicleCount.toLocaleString()} 在线 / ${summary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(summary.onlineRatePercent)}`, + `Redis 在线 Key:${summary.redisOnlineKeys == null ? '-' : summary.redisOnlineKeys.toLocaleString()}`, + `统计证据:${summary.evidence || '-'}`, + `当前页车辆:${onlineRows.length.toLocaleString()} / 当前筛选 ${total.toLocaleString()}`, + `当前页离线:${offlineRows.length.toLocaleString()}`, + `最长离线:${longestOffline ? `${longestOffline.plate || longestOffline.vin} / ${formatOfflineDuration(longestOffline.offlineDurationMinutes)} / ${longestOffline.lastSeen || '-'}` : '-'}`, + '', + '协议在线:', + ...(protocolStats.length > 0 ? protocolStats.map((item, index) => { + const rate = item.total > 0 ? (item.online / item.total) * 100 : 0; + return `${index + 1}. ${item.protocol}:${item.online.toLocaleString()}/${item.total.toLocaleString()},在线率 ${formatPercent(rate)}`; + }) : ['暂无协议在线统计']), + '', + '处置建议:', + '1. 在线率下降时先检查 Redis online key、协议接入和最后上报时间', + '2. 离线车辆优先打开车辆服务,核对实时、轨迹、RAW 和告警证据', + '3. 统计发布前确认离线车辆是否影响当日里程和区间闭合', + `统计查询:${statisticsURL}`, + ...(vehicleURL ? [`车辆服务:${vehicleURL}`] : []) + ].join('\n'); +} + async function copyText(value: string, label: string) { const text = value.trim(); if (!text) { @@ -474,6 +523,11 @@ export function Mileage({ redisOnlineKeyText, onlineSummary.evidence ].filter(Boolean).join(','); + const onlineProtocolStats = Array.isArray(onlineSummary.protocolStats) ? onlineSummary.protocolStats : []; + const topOfflineRow = onlineRows.filter((row) => !row.online).reduce((current, row) => { + if (!current) return row; + return Number(row.offlineDurationMinutes ?? 0) > Number(current.offlineDurationMinutes ?? 0) ? row : current; + }, undefined); const publishAuditItems: MileagePublishAuditItem[] = [ { key: 'closure', @@ -723,6 +777,22 @@ export function Mileage({ '在线状态交接' ); }; + const copyOnlineOperationsReport = () => { + const vehicleURL = currentVehicleKeyword + ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) + : undefined; + copyText( + onlineOperationsReportText({ + filters, + summary: onlineSummary, + onlineRows, + total: onlinePagination.total, + statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })), + vehicleURL + }), + '在线统计运营态势' + ); + }; useEffect(() => { const nextFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters); @@ -865,6 +935,69 @@ export function Mileage({ ))} + 在线统计运营态势} + style={{ marginTop: 16 }} + > +
+
+ + = 90 ? 'green' : onlineRatePercent >= 60 ? 'orange' : 'red'}> + 在线率 {formatPercent(onlineRatePercent)} + + + Redis {onlineSummary.redisOnlineKeys == null ? '-' : onlineSummary.redisOnlineKeys.toLocaleString()} + + +
{onlineVehicleCount.toLocaleString()} 在线
+ + 在线状态用于解释统计缺口、离线车辆影响和当日里程是否需要延迟发布。 + +
+
+ {[ + { + label: '离线车辆', + value: offlineVehicleCount.toLocaleString(), + detail: '离线车辆会影响实时统计、位置查询和当日里程完整性。', + color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const + }, + { + label: '协议覆盖', + value: onlineProtocolStats.length.toLocaleString(), + detail: onlineProtocolStats.length > 0 ? onlineProtocolStats.map((item) => `${item.protocol} ${item.online}/${item.total}`).join(';') : '暂无协议在线统计。', + color: onlineProtocolStats.length > 0 ? 'blue' as const : 'grey' as const + }, + { + label: '当前页离线', + value: onlineRows.filter((row) => !row.online).length.toLocaleString(), + detail: `${onlineRows.length.toLocaleString()} 条在线状态明细参与当前页复核。`, + color: onlineRows.some((row) => !row.online) ? 'orange' as const : 'green' as const + }, + { + label: '最长离线', + value: topOfflineRow ? formatOfflineDuration(topOfflineRow.offlineDurationMinutes) : '-', + detail: topOfflineRow ? `${topOfflineRow.plate || topOfflineRow.vin} / ${topOfflineRow.lastSeen || '-'}` : '当前页暂无离线车辆。', + color: topOfflineRow ? 'orange' as const : 'green' as const + }, + { + label: '统计证据', + value: onlineSummary.evidence || '-', + detail: '后端在线统计证据,可与 Redis key 和车辆快照互相校验。', + color: onlineSummary.evidence ? 'blue' as const : 'grey' as const + } + ].map((item) => ( +
+ {item.label} + {item.value} + {item.detail} +
+ ))} +
+
+
{[ diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 1093eb3f..5928e120 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -1094,6 +1094,48 @@ button.vp-realtime-command-item:focus-visible { margin-top: 16px; } +.vp-online-ops-board { + display: grid; + grid-template-columns: minmax(260px, 0.72fr) minmax(0, 1.28fr); + gap: 16px; +} + +.vp-online-ops-summary { + min-height: 166px; + padding: 12px; + border: 1px solid rgba(10, 168, 107, 0.28); + border-radius: var(--vp-radius); + background: #f4fbf8; + display: grid; + gap: 10px; + align-content: start; +} + +.vp-online-ops-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.vp-online-ops-item { + min-height: 166px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + display: grid; + gap: 10px; + align-content: start; +} + +.vp-online-ops-item strong { + color: var(--vp-text); + font-size: 20px; + line-height: 26px; + font-weight: 700; + word-break: break-word; +} + .vp-stat-chart { display: grid; gap: 12px; @@ -2227,6 +2269,8 @@ button.vp-realtime-command-item:focus-visible { .vp-ops-action-grid, .vp-alert-ops-grid, .vp-stat-workspace, + .vp-online-ops-board, + .vp-online-ops-grid, .vp-stat-insight-grid, .vp-stat-definition-grid, .vp-stat-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 452cdae0..8ebf3422 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -7443,6 +7443,13 @@ test('shows vehicle-first statistics domains for one vehicle service', async () expect(screen.getByText('闭合影响')).toBeInTheDocument(); expect(screen.getByText('异常影响')).toBeInTheDocument(); expect(screen.getAllByText('发布判断').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('在线统计运营态势')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '复制在线态势' })).toBeInTheDocument(); + expect(screen.getByText('离线车辆')).toBeInTheDocument(); + expect(screen.getByText('协议覆盖')).toBeInTheDocument(); + expect(screen.getByText('当前页离线')).toBeInTheDocument(); + expect(screen.getByText('最长离线')).toBeInTheDocument(); + expect(screen.getByText('统计证据')).toBeInTheDocument(); expect(await screen.findByText('75%')).toBeInTheDocument(); expect(screen.getAllByText((content) => content.includes('离线 1 车')).length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText((content) => content.includes('Redis 在线 92 key')).length).toBeGreaterThanOrEqual(1); @@ -7457,10 +7464,20 @@ test('shows vehicle-first statistics domains for one vehicle service', async () expect(writeText).toHaveBeenCalledWith(expect.stringContaining('在线状态:3 在线 / 1 离线 / 在线率 75%')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布判断:复核后发布')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('业务影响:当前统计范围需要先复核轨迹、RAW和离线车辆影响')); + fireEvent.click(screen.getByRole('button', { name: '复制在线态势' })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【在线统计运营态势】')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆范围:VIN-STATS-SERVICE')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('数据来源:JT808')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('在线概览:3 在线 / 1 离线 / 在线率 75%')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('Redis 在线 Key:92')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('当前页车辆:2 / 当前筛选 2')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('最长离线:粤A离线1 / 2 小时 / 2026-07-03 18:12:10')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('1. JT808:2/3,在线率 66.7%')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('2. 离线车辆优先打开车辆服务,核对实时、轨迹、RAW 和告警证据')); expect(await screen.findByText('在线状态明细')).toBeInTheDocument(); expect(await screen.findByText('VIN-STATS-SERVICE-OFFLINE')).toBeInTheDocument(); expect(screen.getByText('粤A离线1')).toBeInTheDocument(); - expect(screen.getByText('2 小时')).toBeInTheDocument(); + expect(screen.getAllByText('2 小时').length).toBeGreaterThan(0); fireEvent.click(screen.getByRole('button', { name: '复制在线状态清单' })); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【车辆在线状态交接】')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆范围:VIN-STATS-SERVICE'));