diff --git a/vehicle-data-platform/apps/web/src/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/layout/AppShell.tsx index f1db992d..cf6d1f24 100644 --- a/vehicle-data-platform/apps/web/src/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/layout/AppShell.tsx @@ -38,10 +38,15 @@ const navGroups = [ ] }, { - title: '告警运维', + title: '告警通知', items: [ { itemKey: 'alert-events', text: '告警事件', icon: }, - { itemKey: 'notification-rules', text: '通知规则', icon: }, + { itemKey: 'notification-rules', text: '通知规则', icon: } + ] + }, + { + title: '系统运维', + items: [ { itemKey: 'ops-quality', text: '运维质量', icon: } ] } @@ -170,17 +175,13 @@ export function AppShell({ 一致性:{currentVehicleConsistency.title || `${currentVehicleConsistency.onlineSourceCount}/${currentVehicleConsistency.sourceCount} 来源`} ) : null} - 生产环境 - {platformRelease ? 版本 {platformRelease} : null} - 多源数据归并 + 车辆运营 {amapConfigured ? '地图就绪' : '地图待配置'} - - + {platformRelease ? 版本 {platformRelease} : null} {children} diff --git a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx index d41dabc3..dbb084c5 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, OpsHealth, ProtocolStat, QualityIssueRow, ServiceStatusStat, SourceReadinessPlan, SourceReadinessRow, 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'; @@ -93,15 +93,6 @@ function formatProtocolRate(row: ProtocolStat) { return `${Math.round((row.online / row.total) * 100)}%`; } -function normalizeSourceReadiness(plan: SourceReadinessPlan | null): SourceReadinessPlan | null { - return plan && Array.isArray(plan.sources) ? plan : null; -} - -function sourceReadinessColor(row: SourceReadinessRow): 'green' | 'orange' | 'red' { - if (row.severity === 'error') return 'red'; - return row.severity === 'warning' ? 'orange' : 'green'; -} - function rowServiceStatus(row: { serviceStatus?: { title: string; severity: string }; onlineSourceCount: number; sourceCount: number }) { if (row.serviceStatus) { return { @@ -268,7 +259,6 @@ export function Dashboard({ const [locations, setLocations] = useState([]); const [qualityIssues, setQualityIssues] = useState([]); const [opsHealth, setOpsHealth] = useState(null); - const [sourceReadiness, setSourceReadiness] = useState(null); const [loading, setLoading] = useState(true); const [coverageLoading, setCoverageLoading] = useState(false); const [coverageServiceStatusTitle, setCoverageServiceStatusTitle] = useState(''); @@ -308,8 +298,7 @@ export function Dashboard({ 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.opsHealth().then(setOpsHealth), - api.sourceReadiness().then((plan) => setSourceReadiness(normalizeSourceReadiness(plan))) + api.opsHealth().then(setOpsHealth) ]; Promise.allSettled(tasks) .then((results) => { @@ -394,6 +383,66 @@ export function Dashboard({ online: row.online, title: `${row.plate || row.vin || '-'} ${row.primaryProtocol || ''} ${row.lastSeen || ''}` })); + const customerHeroActions = [ + { + title: '实时车辆地图', + value: `${commandLocatedCount.toLocaleString()} 辆有定位`, + detail: '按车查看实时位置、在线状态、速度和最新上报时间。', + action: '进入地图', + onClick: () => onOpenMap({ online: 'online' }) + }, + { + title: '轨迹回放', + value: `${formatCount(summary?.activeToday)} 今日活跃`, + detail: '选择车辆和时间范围,回放历史路线并核对断点。', + action: '回放轨迹', + onClick: () => onOpenHistory() + }, + { + title: '里程统计', + value: `${formatCount(serviceSummary?.totalVehicles)} 辆可统计`, + detail: '按自定义时间查询区间里程、日里程和异常记录。', + action: '查看统计', + onClick: () => onOpenMileage({}) + }, + { + title: '历史数据导出', + value: `${formatCount(summary?.frameToday)} 今日数据`, + detail: '按车辆、时间和字段裁剪查询历史位置与 RAW 证据。', + action: '查询导出', + onClick: () => onOpenHistory({ tab: 'raw', includeFields: 'true' }) + } + ]; + const customerHealthItems = [ + { + label: '在线车辆', + value: formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles), + detail: vehicleServiceOnlineText(serviceSummary, summary), + color: 'green' as const, + onClick: () => onOpenVehicles({ online: 'online' }) + }, + { + label: '有效定位', + value: commandLocatedCount.toLocaleString(), + detail: `当前抽样 ${locations.length.toLocaleString()} 辆,定位可用于地图和回放。`, + color: commandLocatedCount > 0 ? 'blue' as const : 'orange' as const, + onClick: () => onOpenMap({ online: 'online' }) + }, + { + label: '需要关注', + value: formatCount(summary?.issueVehicles), + detail: highPriorityIssue ? `${qualityIssueLabel(highPriorityIssue.issueType)} / ${priorityIssueVehicleLabel(highPriorityIssue)}` : '暂无高优先级告警。', + color: (summary?.issueVehicles ?? 0) > 0 ? 'orange' as const : 'green' as const, + onClick: () => onOpenQuality() + }, + { + label: '时间范围监控', + value: formatCount(summary?.activeToday), + detail: '支持按任意时间窗查询轨迹、历史数据、里程和导出。', + color: 'blue' as const, + onClick: () => onOpenHistory() + } + ]; const capabilities = [ { title: '实时地图', @@ -765,36 +814,6 @@ export function Dashboard({ ].filter(Boolean); copyText(lines.join('\n'), '运营交接摘要'); }; - const copySourceReadinessHandoff = () => { - const plan = sourceReadiness; - if (!plan?.sources?.length) { - Toast.warning('暂无数据源就绪度数据'); - return; - } - const lines = [ - '【三源接入到车辆服务交接摘要】', - `车辆规模:${formatCount(plan.onlineVehicles)} / ${formatCount(plan.totalVehicles)} 在线`, - `接入运行:连接 ${formatCount(plan.activeConnections)};Redis 在线 Key ${formatCount(plan.redisOnlineKeys)};Kafka Lag ${formatLag(plan.kafkaLag)}`, - `平台版本:${plan.platformRelease || '-'}`, - '', - ...plan.sources.map((item, index) => [ - `${index + 1}. ${item.protocol} - ${item.status}`, - ` 角色:${item.role}`, - ` 在线:${formatCount(item.online)} / ${formatCount(item.total)},在线率 ${item.onlineRate.toLocaleString(undefined, { maximumFractionDigits: 1 })}%`, - ` 缺失车辆:${formatCount(item.missingVehicles)}`, - ` 证据:${item.evidence}`, - ` 动作:${item.action}`, - ` 验收:${item.acceptance}`, - ` 实时:${appURL(item.realtimeHash)}`, - ` 车辆:${appURL(item.vehiclesHash)}`, - ` RAW:${appURL(item.historyHash)}`, - ` 告警:${appURL(item.alertHash)}` - ].join('\n')), - '', - `驾驶舱:${appURL(buildAppHash({ page: 'dashboard' }))}` - ]; - copyText(lines.join('\n'), '三源接入交接摘要'); - }; const copyScenarioBlueprint = () => { const lines = [ '【车辆数据中台功能蓝图】', @@ -968,8 +987,59 @@ export function Dashboard({ return (
- + + +
+
+ 先看车,再看数据来源 + + 车辆是主对象。32960、808 和宇通 MQTT 只是证据来源,客户每天需要先确认车辆在哪里、是否在线、今天跑了多少、异常能否追溯和导出。 + + + + + + + +
+ +
+
+
+ {amapConfigured ? '地图可用' : '地图待配置'} + 0 ? 'orange' : 'green'}>{formatCount(summary?.issueVehicles)} 个告警 +
+
+ {customerHealthItems.map((item) => ( + + ))} +
+
+
+ +
+ {customerHeroActions.map((item) => ( + + ))} +
+
{kpis.map((item) => ( @@ -995,52 +1065,6 @@ export function Dashboard({ - 数据源到车辆服务就绪度} - style={{ marginBottom: 16 }} - > -
- {(sourceReadiness?.sources ?? []).map((item) => ( -
-
- - {item.protocol} - {item.status} - - {item.role} -
-
-
- 在线 - {formatCount(item.online)} / {formatCount(item.total)} -
-
- 在线率 - {item.onlineRate.toLocaleString(undefined, { maximumFractionDigits: 1 })}% -
-
- 缺失车辆 - {formatCount(item.missingVehicles)} -
-
- {item.evidence} -
- - - - -
-
- ))} - {sourceReadiness?.sources?.length ? null : ( -
- 待接入 - 数据源就绪度加载后会展示 32960、808 和宇通 MQTT 对车辆服务的支撑状态。 -
- )} -
-
diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 5109e76f..e632092e 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -235,6 +235,136 @@ body { font-size: 13px; } +.vp-customer-hero { + margin-bottom: 16px; + overflow: hidden; +} + +.vp-customer-hero .semi-card-body { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(360px, 0.7fr); + min-height: 420px; +} + +.vp-customer-hero-map { + position: relative; + min-width: 0; + min-height: 420px; + background: #eef5ff; +} + +.vp-customer-hero-copy { + position: absolute; + z-index: 2; + left: 24px; + top: 24px; + max-width: 560px; + padding: 18px; + border: 1px solid rgba(22, 100, 255, 0.16); + border-radius: 8px; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 12px 32px rgba(16, 24, 40, 0.12); + display: grid; + gap: 12px; +} + +.vp-customer-hero-map-canvas { + min-height: 420px; + height: 420px; + border: 0; + border-radius: 0; +} + +.vp-customer-hero-side { + min-width: 0; + padding: 22px; + background: #ffffff; + border-left: 1px solid var(--vp-border); + display: grid; + align-content: start; + gap: 16px; +} + +.vp-customer-hero-side-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.vp-customer-health-grid { + display: grid; + gap: 10px; +} + +.vp-customer-health-item { + min-height: 78px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: 8px; + background: #fbfcff; + text-align: left; + cursor: pointer; + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 10px; + font: inherit; +} + +.vp-customer-health-item:hover, +.vp-customer-health-item:focus-visible { + border-color: rgba(22, 100, 255, 0.45); + background: #f5f9ff; + outline: none; +} + +.vp-customer-health-item strong { + justify-self: end; + color: var(--vp-text); + font-size: 22px; + line-height: 24px; +} + +.vp-customer-health-item span { + grid-column: 1 / -1; + color: var(--vp-text-muted); + font-size: 12px; + line-height: 18px; +} + +.vp-customer-workflow-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.vp-customer-workflow-item { + min-height: 150px; + padding: 16px; + border: 1px solid var(--vp-border); + border-radius: 8px; + background: #ffffff; + cursor: pointer; + text-align: left; + font: inherit; + display: grid; + align-content: space-between; + gap: 12px; +} + +.vp-customer-workflow-item:hover, +.vp-customer-workflow-item:focus-visible { + border-color: rgba(22, 100, 255, 0.45); + box-shadow: var(--vp-shadow-sm); + outline: none; +} + +.vp-customer-workflow-item strong { + color: var(--vp-text); + font-size: 20px; + line-height: 26px; +} + .vp-result-summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); 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 85dab955..35523d2e 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -24,7 +24,8 @@ test('renders vehicle platform shell', () => { expect(screen.getByText('一车一服务 / 多源归并')).toBeInTheDocument(); expect(screen.getByText('车辆工作台')).toBeInTheDocument(); expect(screen.getByText('查询分析')).toBeInTheDocument(); - expect(screen.getByText('告警运维')).toBeInTheDocument(); + expect(screen.getAllByText('告警通知').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('系统运维')).toBeInTheDocument(); expect(screen.getAllByText('运营驾驶舱').length).toBeGreaterThanOrEqual(1); }); @@ -593,7 +594,7 @@ test('dashboard presents one vehicle service operating posture', async () => { render(); expect(await screen.findByText('统一车辆服务入口')).toBeInTheDocument(); - expect(screen.getByText('208 / 1,033 在线')).toBeInTheDocument(); + expect(screen.getAllByText('208 / 1,033 在线').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('181 多源覆盖')).toBeInTheDocument(); expect(screen.getByText('7 告警事件')).toBeInTheDocument(); }); @@ -790,8 +791,12 @@ 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('实时车辆地图')).toBeInTheDocument(); + expect(screen.getByText('历史数据导出')).toBeInTheDocument(); expect(screen.getByText('车辆服务作业台')).toBeInTheDocument(); - expect(screen.getByText('数据源到车辆服务就绪度')).toBeInTheDocument(); expect(screen.getByText('地图运营能力')).toBeInTheDocument(); expect(screen.getByText('数据流转作业链')).toBeInTheDocument(); expect(screen.getByText('车联网场景导航')).toBeInTheDocument(); @@ -799,11 +804,6 @@ test('dashboard exposes vehicle data center capability matrix', async () => { expect(screen.getByText('服务端 API')).toBeInTheDocument(); expect(screen.getByText('安全代理')).toBeInTheDocument(); expect(screen.getByText('安全码暴露')).toBeInTheDocument(); - expect(screen.getByText('GB32960')).toBeInTheDocument(); - expect(screen.getByText('JT808')).toBeInTheDocument(); - expect(screen.getByText('YUTONG_MQTT')).toBeInTheDocument(); - expect(screen.getByText('整车与氢能实时数据主来源')).toBeInTheDocument(); - expect(screen.getByText('在线 73/340,缺失车辆 693,Kafka Lag 0')).toBeInTheDocument(); expect(screen.getByText('协议接入')).toBeInTheDocument(); expect(screen.getByText('统一解析')).toBeInTheDocument(); expect(screen.getByText('实时投影')).toBeInTheDocument(); @@ -843,19 +843,15 @@ test('dashboard exposes vehicle data center capability matrix', async () => { fireEvent.click(screen.getByRole('button', { name: '复制流转图' })); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【车辆数据中台数据流转图】')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('03. 实时投影 / Redis KV')); - fireEvent.click(screen.getByRole('button', { name: '复制接入交接' })); - expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【三源接入到车辆服务交接摘要】')); - expect(writeText).toHaveBeenCalledWith(expect.stringContaining('1. GB32960 - 覆盖不足')); - expect(writeText).toHaveBeenCalledWith(expect.stringContaining('RAW:http://localhost:3000/#/history-query?protocol=GB32960&tab=raw&includeFields=true')); - fireEvent.click(screen.getAllByRole('button', { name: '实时' })[0]); - expect(window.location.hash).toBe('#/realtime?protocol=GB32960&online=online'); + fireEvent.click(screen.getByRole('button', { name: '客户工作流 实时车辆地图' })); + expect(window.location.hash).toBe('#/map?online=online'); cleanup(); await renderDashboard(); - fireEvent.click(screen.getAllByRole('button', { name: 'RAW' })[0]); + fireEvent.click(screen.getByRole('button', { name: '客户工作流 历史数据导出' })); expect(window.location.hash.startsWith('#/history-query')).toBe(true); - expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('protocol')).toBe('GB32960'); + expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('tab')).toBe('raw'); expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('includeFields')).toBe('true'); cleanup();