diff --git a/vehicle-data-platform/apps/web/src/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/layout/AppShell.tsx index b39bcdb5..0ad9f1dc 100644 --- a/vehicle-data-platform/apps/web/src/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/layout/AppShell.tsx @@ -16,13 +16,13 @@ import type { VehicleSourceConsistency, VehicleServiceStatus } from '../api/type export type PageKey = 'dashboard' | 'vehicles' | 'realtime' | 'detail' | 'history' | 'mileage' | 'quality'; const navItems = [ - { itemKey: 'dashboard', text: '总览工作台', icon: }, - { itemKey: 'vehicles', text: '车辆服务', icon: }, - { itemKey: 'realtime', text: '实时车辆', icon: }, - { itemKey: 'detail', text: '车辆详情', icon: }, - { itemKey: 'history', text: '历史数据', icon: }, - { itemKey: 'mileage', text: '里程统计', icon: }, - { itemKey: 'quality', text: '质量治理', icon: } + { itemKey: 'dashboard', text: '运营总览', icon: }, + { itemKey: 'vehicles', text: '车辆中心', icon: }, + { itemKey: 'realtime', text: '实时监控', icon: }, + { itemKey: 'detail', text: '车辆档案', icon: }, + { itemKey: 'history', text: '轨迹回放', icon: }, + { itemKey: 'mileage', text: '统计分析', icon: }, + { itemKey: 'quality', text: '告警通知', icon: } ]; function linkHealthClassName(count: number | null) { diff --git a/vehicle-data-platform/apps/web/src/pages/Quality.tsx b/vehicle-data-platform/apps/web/src/pages/Quality.tsx index ff6790b6..77448c10 100644 --- a/vehicle-data-platform/apps/web/src/pages/Quality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Quality.tsx @@ -190,7 +190,7 @@ export function Quality({ return (
- +
{[ { label: '问题车辆', value: summary.issueVehicleCount.toLocaleString() }, @@ -234,6 +234,20 @@ export function Quality({ + +
+ {[ + { label: '事件触发', detail: '断链、无来源、VIN 缺失、字段缺失和容量异常进入告警池。' }, + { label: '分级通知', detail: '按错误、警告、关注分层推送给平台、运维和业务责任人。' }, + { label: '处置回执', detail: '告警需要关联车辆服务、来源证据和处理结论,形成可追踪闭环。' } + ].map((item) => ( +
+ {item.label} +
{item.detail}
+
+ ))} +
+
{health?.capacityFindings?.length ? ( diff --git a/vehicle-data-platform/apps/web/src/pages/Realtime.tsx b/vehicle-data-platform/apps/web/src/pages/Realtime.tsx index 9834f194..539a1a4f 100644 --- a/vehicle-data-platform/apps/web/src/pages/Realtime.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Realtime.tsx @@ -32,6 +32,19 @@ function sourceEvidenceText(row: VehicleRealtimeRow) { return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`; } +function isValidCoordinate(row: VehicleRealtimeRow) { + return Number.isFinite(row.longitude) && Number.isFinite(row.latitude) && row.longitude !== 0 && row.latitude !== 0; +} + +function mapPointStyle(row: VehicleRealtimeRow, index: number) { + if (!isValidCoordinate(row)) { + return { left: `${18 + (index % 4) * 18}%`, top: `${28 + (index % 3) * 17}%` }; + } + const left = Math.min(88, Math.max(10, ((row.longitude + 180) / 360) * 100)); + const top = Math.min(82, Math.max(12, ((90 - row.latitude) / 180) * 100)); + return { left: `${left}%`, top: `${top}%` }; +} + const onlineLabel: Record = { online: '在线', offline: '离线' @@ -57,6 +70,7 @@ export function Realtime({ const [loading, setLoading] = useState(true); const [filters, setFilters] = useState>(initialFilters); const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 50, total: 0 }); + const amapConfigured = Boolean(String(import.meta.env.VITE_AMAP_WEB_JS_KEY ?? '').trim()); const load = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { setLoading(true); @@ -90,10 +104,14 @@ export function Realtime({ filters.online ? `在线:${onlineLabel[filters.online] ?? filters.online}` : '', filters.serviceStatus ? `服务状态:${serviceStatusLabel[filters.serviceStatus] ?? filters.serviceStatus}` : '' ].filter(Boolean); + const onlineCount = rows.filter((row) => row.online).length; + const locatedCount = rows.filter(isValidCoordinate).length; + const degradedCount = rows.filter((row) => row.onlineSourceCount < row.sourceCount).length; + const primaryProtocols = new Set(rows.map((row) => row.primaryProtocol).filter(Boolean)); return (
- +
{ const nextFilters = values as Record; @@ -132,6 +150,46 @@ export function Realtime({ ) : null} +
+
+
+ + + {amapConfigured ? '高德地图配置就绪' : '高德地图待配置'} + + {locatedCount.toLocaleString()} 辆有定位 + {onlineCount.toLocaleString()} 辆在线 + +
+
+ {rows.slice(0, 80).map((row, index) => ( + + ))} +
+
+
+ {[ + { label: '当前车辆', value: pagination.total.toLocaleString(), color: 'blue' as const }, + { label: '在线车辆', value: onlineCount.toLocaleString(), color: 'green' as const }, + { label: '定位有效', value: locatedCount.toLocaleString(), color: 'green' as const }, + { label: '降级服务', value: degradedCount.toLocaleString(), color: degradedCount > 0 ? 'orange' as const : 'green' as const }, + { label: '来源类型', value: primaryProtocols.size.toLocaleString(), color: 'blue' as const } + ].map((item) => ( +
+ {item.label} +
{item.value}
+
+ ))} + + 高德 Web JS Key 和安全密钥通过运行环境配置,前端只读取公开 Key;安全密钥后续走服务端代理,避免明文下发。 + +
+
{rows.length === 0 && !loading ? ( @@ -199,7 +257,7 @@ export function Realtime({ key={row.vin} className="vp-map-dot" title={`${row.plate} ${row.primaryProtocol}`} - style={{ left: `${18 + index * 24}%`, top: `${26 + (index % 3) * 18}%` }} + style={mapPointStyle(row, index)} /> ))}
diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 6ef71409..2bbc5a28 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -252,6 +252,32 @@ body { background-size: 36px 36px; } +.vp-monitor-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 260px; + gap: 16px; + margin-bottom: 16px; +} + +.vp-monitor-map { + min-height: 430px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + overflow: hidden; + background: var(--vp-surface); +} + +.vp-monitor-map-header { + min-height: 50px; + padding: 12px; + border-bottom: 1px solid var(--vp-border); + background: #fbfcff; +} + +.vp-map-monitor { + height: 380px; +} + .vp-map-dot { position: absolute; width: 10px; @@ -261,6 +287,55 @@ body { box-shadow: 0 0 0 5px rgba(22, 100, 255, 0.16); } +.vp-map-dot-online { + background: var(--vp-success); + box-shadow: 0 0 0 5px rgba(18, 183, 106, 0.16); +} + +.vp-map-dot-offline { + background: var(--vp-warning); + box-shadow: 0 0 0 5px rgba(247, 144, 9, 0.16); +} + +.vp-monitor-side { + display: grid; + gap: 12px; + align-content: start; +} + +.vp-monitor-metric { + min-height: 72px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; +} + +.vp-monitor-metric-value { + margin-top: 8px; + color: var(--vp-text); + font-size: 24px; + font-weight: 700; + line-height: 30px; +} + +.vp-alert-flow { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.vp-alert-flow-item { + min-height: 96px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + display: grid; + gap: 10px; + align-content: start; +} + .vp-json { margin: 0; padding: 16px; @@ -395,7 +470,9 @@ body { @media (max-width: 900px) { .vp-evidence-grid, .vp-action-grid, - .vp-conclusion-grid { + .vp-conclusion-grid, + .vp-monitor-layout, + .vp-alert-flow { grid-template-columns: 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 f1de18fb..e170a24b 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -2747,7 +2747,7 @@ test('shows vehicle context when opening detail from sidebar navigation', async render(); - fireEvent.click(screen.getAllByText('车辆详情')[0]); + fireEvent.click(screen.getAllByText('车辆档案')[0]); expect(await screen.findByText('粤AGQ8398 / LB9A32A24R0LS1426')).toBeInTheDocument(); expect(screen.getByText('当前车辆:车辆离线')).toBeInTheDocument(); @@ -3807,7 +3807,9 @@ test('frames realtime page as one vehicle service with source evidence', async ( render(); - expect(await screen.findByRole('heading', { name: '车辆服务实时态' })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: '实时监控' })).toBeInTheDocument(); + expect(screen.getByText('高德地图待配置')).toBeInTheDocument(); + expect(screen.getByText('定位有效')).toBeInTheDocument(); expect(screen.getByText('车辆核心数据')).toBeInTheDocument(); expect(screen.getByText('来源证据')).toBeInTheDocument(); expect(screen.getByText('GB32960 在线')).toBeInTheDocument(); @@ -3933,7 +3935,7 @@ test('updates realtime hash when source filters are submitted', async () => { render(); - await screen.findByRole('heading', { name: '车辆服务实时态' }); + await screen.findByRole('heading', { name: '实时监控' }); fireEvent.change(screen.getByPlaceholderText('VIN / 车牌 / 手机号 / OEM'), { target: { value: '粤ART002' } }); fireEvent.click(screen.getByText('全部来源')); fireEvent.click(await screen.findByText('JT808')); @@ -4613,7 +4615,9 @@ test('opens quality governance from vehicle detail overview', async () => { await waitFor(() => { expect(window.location.hash).toBe('#/quality?keyword=VIN001'); }); - expect(await screen.findByRole('heading', { name: '质量治理' })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: '告警通知' })).toBeInTheDocument(); + expect(screen.getByText('告警通知闭环')).toBeInTheDocument(); + expect(screen.getByText('事件触发')).toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/issues?keyword=VIN001&limit=20&offset=0'), undefined); }); @@ -4638,7 +4642,7 @@ test('quality health storage card stays pending before ops health loads', async render(); - expect(await screen.findByRole('heading', { name: '质量治理' })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: '告警通知' })).toBeInTheDocument(); expect(screen.getByText('存储读取')).toBeInTheDocument(); expect(screen.getByText('检测中')).toBeInTheDocument(); expect(screen.queryByText('异常')).not.toBeInTheDocument(); @@ -4664,7 +4668,7 @@ test('quality health shows active connection capacity metric', async () => { render(); - expect(await screen.findByRole('heading', { name: '质量治理' })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: '告警通知' })).toBeInTheDocument(); expect(await screen.findByText('活跃连接')).toBeInTheDocument(); expect(screen.getByText('120,000')).toBeInTheDocument(); }); @@ -4689,7 +4693,7 @@ test('quality health shows structured capacity findings', async () => { render(); - expect(await screen.findByRole('heading', { name: '质量治理' })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: '告警通知' })).toBeInTheDocument(); expect(await screen.findByText('容量检查发现')).toBeInTheDocument(); expect(screen.getByText('kafka lag 42')).toBeInTheDocument(); }); @@ -5615,7 +5619,7 @@ test('shows canonical vehicle archive on detail', async () => { render(); - expect(await screen.findByText('车辆档案')).toBeInTheDocument(); + expect((await screen.findAllByText('车辆档案')).length).toBeGreaterThan(0); expect(screen.getByText('车辆主键')).toBeInTheDocument(); expect(screen.getAllByText('VIN-ARCHIVE-001').length).toBeGreaterThan(0); expect(screen.getByText('档案完整度')).toBeInTheDocument(); diff --git a/vehicle-data-platform/apps/web/src/vite-env.d.ts b/vehicle-data-platform/apps/web/src/vite-env.d.ts new file mode 100644 index 00000000..76530591 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_AMAP_WEB_JS_KEY?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +}