From ae42a4092bbe3abd9a39c054213e1d38b5bf555d Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 21:59:41 +0800 Subject: [PATCH] feat(platform): add dashboard map operations panel --- .../apps/web/src/pages/Dashboard.tsx | 97 ++++++++++++++++++- .../apps/web/src/styles/global.css | 65 +++++++++++++ .../apps/web/src/test/App.test.tsx | 58 ++++++++++- 3 files changed, 212 insertions(+), 8 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx index 98cd0192..9ed1409d 100644 --- a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx @@ -2,7 +2,7 @@ import { Button, Card, Col, Form, Row, Select, Space, Spin, Table, Tag, Toast, T import { IconSearch } from '@douyinfe/semi-icons'; import { useEffect, useMemo, useState } from 'react'; import { api } from '../api/client'; -import type { DashboardSummary, LinkHealth, ProtocolStat, QualityIssueRow, ServiceStatusStat, VehicleCoverageRow, VehicleRealtimeRow, VehicleServiceSummary } from '../api/types'; +import type { DashboardSummary, LinkHealth, OpsHealth, ProtocolStat, QualityIssueRow, ServiceStatusStat, VehicleCoverageRow, VehicleRealtimeRow, VehicleServiceSummary } from '../api/types'; import { PageHeader } from '../components/PageHeader'; import { SourceStatusTags } from '../components/SourceStatusTags'; import { StatusTag } from '../components/StatusTag'; @@ -258,11 +258,16 @@ export function Dashboard({ const [coverage, setCoverage] = useState([]); const [locations, setLocations] = useState([]); const [qualityIssues, setQualityIssues] = useState([]); + const [opsHealth, setOpsHealth] = useState(null); const [loading, setLoading] = useState(true); const [coverageLoading, setCoverageLoading] = useState(false); const [coverageServiceStatusTitle, setCoverageServiceStatusTitle] = useState(''); const [coverageFilters, setCoverageFilters] = useState>({}); - const amapConfigured = isAMapConfigured(); + const localAMapConfigured = isAMapConfigured(); + const amapConfigured = opsHealth?.runtime?.amapWebJsConfigured ?? localAMapConfigured; + const amapApiConfigured = opsHealth?.runtime?.amapApiConfigured ?? false; + const amapSecurityProxyEnabled = opsHealth?.runtime?.amapSecurityProxyEnabled ?? false; + const amapSecurityCodeExposed = opsHealth?.runtime?.amapSecurityCodeExposed ?? false; const loadCoverage = (values?: Record) => { const nextValues = values ?? {}; @@ -292,7 +297,8 @@ export function Dashboard({ api.vehicleServiceSummary().then(setServiceSummary), api.vehicleCoverage(new URLSearchParams({ limit: '8' })).then((page) => setCoverage(page.items)), api.vehicleRealtime(new URLSearchParams({ limit: '8' })).then((page) => setLocations(page.items)), - api.alertEvents(new URLSearchParams({ limit: '5' })).then((page) => setQualityIssues(page.items)) + api.alertEvents(new URLSearchParams({ limit: '5' })).then((page) => setQualityIssues(page.items)), + api.opsHealth().then(setOpsHealth) ]; Promise.allSettled(tasks) .then((results) => { @@ -499,6 +505,62 @@ export function Dashboard({ onClick: () => onOpenMileage() } ]; + const mapReadiness = [ + { + title: '高德 Web JS', + value: amapConfigured ? '已配置' : '待配置', + color: amapConfigured ? 'green' as const : 'orange' as const, + detail: amapConfigured ? '实时监控和轨迹回放可加载高德底图。' : '缺少 Web JS Key 时降级为坐标预览。' + }, + { + title: '安全代理', + value: amapSecurityProxyEnabled ? '已启用' : '未启用', + color: amapSecurityProxyEnabled ? 'green' as const : 'orange' as const, + detail: amapSecurityProxyEnabled ? '安全码由服务端代理追加,不下发到浏览器。' : '生产环境建议启用 /_AMapService。' + }, + { + title: '服务端 API', + value: amapApiConfigured ? '已配置' : '待配置', + color: amapApiConfigured ? 'green' as const : 'orange' as const, + detail: amapApiConfigured ? '逆地理、路线、围栏等服务端地图能力可扩展。' : '后端地图服务会降级。' + }, + { + title: '安全码暴露', + value: amapSecurityCodeExposed ? '需处理' : '未暴露', + color: amapSecurityCodeExposed ? 'red' as const : 'green' as const, + detail: amapSecurityCodeExposed ? '浏览器端可见安全码,需要切换到安全代理。' : '符合生产密钥收敛要求。' + } + ]; + const mapWorkItems = [ + { + title: '实时监控', + value: `${commandLocatedCount.toLocaleString()} 个有效坐标`, + detail: '在线车辆进入地图态势,点击车辆后继续查看车辆服务和实时字段。', + action: '实时地图', + onClick: () => onOpenMap({ online: 'online' }) + }, + { + title: '轨迹回放', + value: `${formatCount(summary?.activeToday)} 今日活跃`, + detail: '按车辆和时间窗回放历史位置,支持打开高德线路和 RAW 证据。', + action: '轨迹回放', + onClick: () => onOpenHistory() + }, + { + title: '历史证据', + value: `${formatCount(summary?.frameToday)} 今日帧`, + detail: '地图点位异常时直接回查位置历史、解析字段和原始帧。', + action: '历史查询', + onClick: () => onOpenHistory({ tab: 'raw', includeFields: 'true' }) + }, + { + title: '告警通知', + value: `${formatCount(summary?.issueVehicles)} 告警车辆`, + detail: '断链、无来源、坐标异常进入告警队列并触发通知闭环。', + action: '告警事件', + onClick: () => onOpenQuality() + } + ]; const operationWorkbench = [ { title: '实时监控', @@ -621,7 +683,7 @@ export function Dashboard({ `实时地图有效定位:${commandLocatedCount.toLocaleString()}`, `告警车辆:${formatCount(summary?.issueVehicles)}`, `Kafka Lag:${formatLag(summary?.kafkaLag)}`, - `高德地图:${amapConfigured ? '已配置' : '待配置'}`, + `高德地图:Web JS ${amapConfigured ? '已配置' : '待配置'} / 服务端 API ${amapApiConfigured ? '已配置' : '待配置'} / 安全代理 ${amapSecurityProxyEnabled ? '已启用' : '未启用'}`, `链路健康:${unhealthyLinks.length > 0 ? unhealthyLinks.map((item) => `${item.name}=${item.status}`).join(';') : '正常'}`, `优先动作:${priorityAction ? `${priorityAction.label} ${priorityAction.count.toLocaleString()}辆 - ${priorityAction.detail}` : '暂无待办'}`, highPriorityIssue ? `最高告警:${highPriorityIssue.severity === 'error' ? 'P0' : 'P1'} ${qualityIssueLabel(highPriorityIssue.issueType)} / ${priorityIssueVehicleLabel(highPriorityIssue)} / ${highPriorityIssue.lastSeen || '-'}` : '最高告警:暂无', @@ -640,7 +702,7 @@ export function Dashboard({ const lines = [ '【车辆数据中台功能蓝图】', '定位:32960 / 808 / 宇通 MQTT 都只是车辆服务证据源,最终围绕一辆车提供实时、轨迹、历史、告警、统计能力。', - `高德地图:${amapConfigured ? '已配置,前端只使用运行时配置和安全代理' : '待配置'}`, + `高德地图:Web JS ${amapConfigured ? '已配置' : '待配置'};服务端 API ${amapApiConfigured ? '已配置' : '待配置'};安全代理 ${amapSecurityProxyEnabled ? '已启用' : '未启用'};安全码${amapSecurityCodeExposed ? '已暴露' : '未暴露'}`, `车辆规模:${formatCount(serviceSummary?.totalVehicles)};在线:${formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles)};告警:${formatCount(summary?.issueVehicles)}`, '', ...scenarioNavigation.map((item, index) => [ @@ -814,6 +876,31 @@ export function Dashboard({ + +
+
+ {mapReadiness.map((item) => ( +
+ {item.title} +
{item.value}
+ {item.detail} +
+ ))} +
+
+ {mapWorkItems.map((item) => ( + + ))} +
+
+
{focusVehicle ? ( { return { ok: true, json: async () => ({ - data: { linkHealth: [], kafkaLag: 0, activeConnections: 0, capacityFindings: [], redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }, + data: { + linkHealth: [], + kafkaLag: 0, + activeConnections: 0, + capacityFindings: [], + redisOnlineKeys: 0, + tdengineWritable: true, + mysqlWritable: true, + runtime: { + requestTimeoutMs: 5000, + amapWebJsConfigured: true, + amapApiConfigured: true, + amapSecurityProxyEnabled: true, + amapSecurityCodeExposed: false, + amapSecurityServiceHost: '/_AMapService' + } + }, traceId: 'trace-test', timestamp: 1783094400000 }) @@ -419,7 +435,23 @@ test('dashboard exposes vehicle data center capability matrix', async () => { return { ok: true, json: async () => ({ - data: { linkHealth: [], kafkaLag: 0, activeConnections: 0, capacityFindings: [], redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }, + data: { + linkHealth: [], + kafkaLag: 0, + activeConnections: 0, + capacityFindings: [], + redisOnlineKeys: 0, + tdengineWritable: true, + mysqlWritable: true, + runtime: { + requestTimeoutMs: 5000, + amapWebJsConfigured: true, + amapApiConfigured: true, + amapSecurityProxyEnabled: true, + amapSecurityCodeExposed: false, + amapSecurityServiceHost: '/_AMapService' + } + }, traceId: 'trace-test', timestamp: 1783094400000 }) @@ -513,7 +545,15 @@ test('dashboard exposes vehicle data center capability matrix', async () => { await renderDashboard(); expect(screen.getByText('车辆服务作业台')).toBeInTheDocument(); + expect(screen.getByText('地图运营能力')).toBeInTheDocument(); expect(screen.getByText('车联网场景导航')).toBeInTheDocument(); + expect(screen.getByText('高德 Web JS')).toBeInTheDocument(); + expect(screen.getByText('服务端 API')).toBeInTheDocument(); + expect(screen.getByText('安全代理')).toBeInTheDocument(); + expect(screen.getByText('安全码暴露')).toBeInTheDocument(); + expect(screen.getAllByText('已配置').length).toBeGreaterThanOrEqual(2); + expect(screen.getByText('已启用')).toBeInTheDocument(); + expect(screen.getByText('未暴露')).toBeInTheDocument(); expect(screen.getAllByText('实时地图').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('实时监控').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('轨迹回放').length).toBeGreaterThanOrEqual(1); @@ -538,9 +578,21 @@ test('dashboard exposes vehicle data center capability matrix', async () => { expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【车辆数据中台功能蓝图】')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('32960 / 808 / 宇通 MQTT 都只是车辆服务证据源')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆规模:1,033;在线:208;告警:7')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('高德地图:Web JS 已配置;服务端 API 已配置;安全代理 已启用;安全码未暴露')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('1. 实时监控')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('SLA:目标 0-1 秒内进入实时视图')); + fireEvent.click(screen.getByRole('button', { name: '地图运营 实时监控' })); + expect(window.location.hash).toBe('#/map?online=online'); + cleanup(); + + await renderDashboard(); + fireEvent.click(screen.getByRole('button', { name: '地图运营 历史证据' })); + expect(window.location.hash.startsWith('#/history-query')).toBe(true); + expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('includeFields')).toBe('true'); + cleanup(); + + await renderDashboard(); fireEvent.click(screen.getByRole('button', { name: '作业台 实时监控 打开实时地图' })); expect(window.location.hash).toBe('#/map?online=online'); cleanup(); @@ -1002,7 +1054,7 @@ test('dashboard exposes end-to-end operations workflow entries', async () => { expect(screen.getByText('接入巡检')).toBeInTheDocument(); expect(screen.getByText('实时态势')).toBeInTheDocument(); expect(screen.getByText('轨迹复盘')).toBeInTheDocument(); - expect(screen.getByText('历史证据')).toBeInTheDocument(); + expect(screen.getAllByText('历史证据').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('告警事件').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('统计复核')).toBeInTheDocument();