diff --git a/vehicle-data-platform/apps/api/internal/app/server.go b/vehicle-data-platform/apps/api/internal/app/server.go index 19190702..cd985f82 100644 --- a/vehicle-data-platform/apps/api/internal/app/server.go +++ b/vehicle-data-platform/apps/api/internal/app/server.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "database/sql" + "encoding/json" + "fmt" "log" "net/http" "sync" @@ -49,7 +51,33 @@ func NewServer(cfg config.Config) http.Handler { api := platform.NewHandler(platform.NewServiceWithRuntime(store, platform.RuntimeInfo{ RequestTimeoutMs: int(cfg.RequestTimeout / time.Millisecond), })) - return withRequestTimeout(static.Handler(cfg.StaticDir, api), cfg.RequestTimeout) + return withRequestTimeout(withAppConfig(static.Handler(cfg.StaticDir, api), cfg), cfg.RequestTimeout) +} + +func withAppConfig(next http.Handler, cfg config.Config) http.Handler { + type appConfig struct { + AMapWebJSKey string `json:"amapWebJsKey,omitempty"` + AMapSecurityCode string `json:"amapSecurityJsCode,omitempty"` + AMapServiceHost string `json:"amapSecurityServiceHost,omitempty"` + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/app-config.js" { + next.ServeHTTP(w, r) + return + } + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + body, err := json.Marshal(appConfig{ + AMapWebJSKey: cfg.AMapWebJSKey, + AMapSecurityCode: cfg.AMapSecurityCode, + AMapServiceHost: cfg.AMapServiceHost, + }) + if err != nil { + http.Error(w, "failed to render app config", http.StatusInternalServerError) + return + } + _, _ = fmt.Fprintf(w, "window.__LINGNIU_APP_CONFIG__=%s;\n", body) + }) } func withRequestTimeout(next http.Handler, timeout time.Duration) http.Handler { diff --git a/vehicle-data-platform/apps/api/internal/app/server_test.go b/vehicle-data-platform/apps/api/internal/app/server_test.go index fe1def55..ea9508e9 100644 --- a/vehicle-data-platform/apps/api/internal/app/server_test.go +++ b/vehicle-data-platform/apps/api/internal/app/server_test.go @@ -2,8 +2,10 @@ package app import ( "encoding/json" + "lingniu/vehicle-data-platform/apps/api/internal/config" "net/http" "net/http/httptest" + "strings" "testing" "time" ) @@ -56,3 +58,28 @@ func TestWithRequestTimeoutReturnsEnvelopeWithTraceID(t *testing.T) { t.Fatalf("unexpected timeout envelope: %+v body=%s", body, rec.Body.String()) } } + +func TestAppConfigScriptIsRuntimeRendered(t *testing.T) { + handler := withAppConfig(http.NotFoundHandler(), config.Config{ + AMapWebJSKey: "web-key", + AMapSecurityCode: "security-code", + AMapServiceHost: "/_AMapService", + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/app-config.js", nil) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q, want no-store", got) + } + body := rec.Body.String() + for _, want := range []string{"window.__LINGNIU_APP_CONFIG__=", `"amapWebJsKey":"web-key"`, `"amapSecurityJsCode":"security-code"`, `"amapSecurityServiceHost":"/_AMapService"`} { + if !strings.Contains(body, want) { + t.Fatalf("app config script missing %q: %s", want, body) + } + } +} diff --git a/vehicle-data-platform/apps/api/internal/config/config.go b/vehicle-data-platform/apps/api/internal/config/config.go index 04731300..f2756cda 100644 --- a/vehicle-data-platform/apps/api/internal/config/config.go +++ b/vehicle-data-platform/apps/api/internal/config/config.go @@ -20,6 +20,9 @@ type Config struct { CapacityCheckBin string AuthToken string RequestTimeout time.Duration + AMapWebJSKey string + AMapSecurityCode string + AMapServiceHost string } func Load() Config { @@ -37,6 +40,9 @@ func Load() Config { CapacityCheckBin: os.Getenv("CAPACITY_CHECK_BIN"), AuthToken: os.Getenv("AUTH_TOKEN"), RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond, + AMapWebJSKey: os.Getenv("AMAP_WEB_JS_KEY"), + AMapSecurityCode: os.Getenv("AMAP_SECURITY_JS_CODE"), + AMapServiceHost: os.Getenv("AMAP_SECURITY_SERVICE_HOST"), } } diff --git a/vehicle-data-platform/apps/web/index.html b/vehicle-data-platform/apps/web/index.html index 18d0c83e..83c5c03e 100644 --- a/vehicle-data-platform/apps/web/index.html +++ b/vehicle-data-platform/apps/web/index.html @@ -7,6 +7,7 @@
+ diff --git a/vehicle-data-platform/apps/web/src/components/VehicleMap.tsx b/vehicle-data-platform/apps/web/src/components/VehicleMap.tsx new file mode 100644 index 00000000..223b8f2b --- /dev/null +++ b/vehicle-data-platform/apps/web/src/components/VehicleMap.tsx @@ -0,0 +1,191 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { Tag } from '@douyinfe/semi-ui'; +import { getAMapConfig, isAMapConfigured } from '../config/appConfig'; + +export type VehicleMapPoint = { + id: string; + label: string; + longitude: number; + latitude: number; + online?: boolean; + title?: string; +}; + +type AMapLike = { + Map: new (container: HTMLDivElement, options: Record) => AMapMap; + Marker: new (options: Record) => AMapOverlay; + Polyline: new (options: Record) => AMapOverlay; + Scale: new () => unknown; +}; + +type AMapMap = { + add: (overlay: AMapOverlay | AMapOverlay[]) => void; + addControl: (control: unknown) => void; + setFitView: (overlays?: AMapOverlay[] | null, immediate?: boolean, padding?: number[]) => void; + destroy: () => void; +}; + +type AMapOverlay = { + setMap?: (map: AMapMap | null) => void; +}; + +let amapLoaderPromise: Promise | null = null; + +function validPoint(point: VehicleMapPoint) { + return Number.isFinite(point.longitude) && Number.isFinite(point.latitude) && point.longitude !== 0 && point.latitude !== 0; +} + +function loadScript(src: string) { + return new Promise((resolve, reject) => { + const existing = document.querySelector(`script[src="${src}"]`); + if (existing) { + existing.addEventListener('load', () => resolve(), { once: true }); + existing.addEventListener('error', () => reject(new Error('高德地图 Loader 加载失败')), { once: true }); + if (window.AMapLoader) resolve(); + return; + } + const script = document.createElement('script'); + script.src = src; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('高德地图 Loader 加载失败')); + document.head.appendChild(script); + }); +} + +function loadAMap() { + const config = getAMapConfig(); + if (!isAMapConfigured(config)) { + return Promise.reject(new Error('高德地图未配置')); + } + if (!amapLoaderPromise) { + if (config.securityServiceHost) { + window._AMapSecurityConfig = { serviceHost: config.securityServiceHost }; + } else if (config.securityJsCode) { + window._AMapSecurityConfig = { securityJsCode: config.securityJsCode }; + } + amapLoaderPromise = loadScript('https://webapi.amap.com/loader.js').then(() => { + if (!window.AMapLoader) { + throw new Error('高德地图 Loader 不可用'); + } + return window.AMapLoader.load({ + key: config.webJsKey, + version: '2.0', + plugins: ['AMap.Scale'] + }) as Promise; + }); + } + return amapLoaderPromise; +} + +function pointToPixelStyle(point: VehicleMapPoint, index: number) { + if (!validPoint(point)) { + return { left: `${18 + (index % 4) * 18}%`, top: `${28 + (index % 3) * 17}%` }; + } + const left = Math.min(88, Math.max(10, ((point.longitude + 180) / 360) * 100)); + const top = Math.min(82, Math.max(12, ((90 - point.latitude) / 180) * 100)); + return { left: `${left}%`, top: `${top}%` }; +} + +export function VehicleMap({ + points, + mode = 'realtime', + heightClassName = 'vp-map-monitor', + fallbackLabel, + maxFallbackPoints = 120, + children +}: { + points: VehicleMapPoint[]; + mode?: 'realtime' | 'track'; + heightClassName?: string; + fallbackLabel?: string; + maxFallbackPoints?: number; + children?: ReactNode; +}) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const overlaysRef = useRef([]); + const [status, setStatus] = useState<'ready' | 'fallback' | 'loading'>('fallback'); + const config = getAMapConfig(); + const validPoints = useMemo(() => points.filter(validPoint), [points]); + + useEffect(() => { + let cancelled = false; + if (!isAMapConfigured(config) || validPoints.length === 0 || !containerRef.current) { + setStatus('fallback'); + return; + } + setStatus('loading'); + loadAMap() + .then((AMap) => { + if (cancelled || !containerRef.current) return; + if (!mapRef.current) { + mapRef.current = new AMap.Map(containerRef.current, { + zoom: 11, + center: [validPoints[0].longitude, validPoints[0].latitude], + viewMode: '2D', + mapStyle: 'amap://styles/normal' + }); + mapRef.current.addControl(new AMap.Scale()); + } + overlaysRef.current.forEach((overlay) => overlay.setMap?.(null)); + const markers = validPoints.slice(0, 500).map((point, index) => new AMap.Marker({ + position: [point.longitude, point.latitude], + title: point.title || point.label, + zIndex: mode === 'track' ? 100 + index : 100, + content: `
${mode === 'track' ? index + 1 : ''}
` + })); + const nextOverlays: AMapOverlay[] = [...markers]; + if (mode === 'track' && validPoints.length > 1) { + nextOverlays.push(new AMap.Polyline({ + path: validPoints.map((point) => [point.longitude, point.latitude]), + strokeColor: '#1664ff', + strokeOpacity: 0.85, + strokeWeight: 5, + lineJoin: 'round' + })); + } + overlaysRef.current = nextOverlays; + mapRef.current.add(nextOverlays); + mapRef.current.setFitView(nextOverlays, false, [56, 56, 56, 56]); + setStatus('ready'); + }) + .catch(() => { + if (!cancelled) setStatus('fallback'); + }); + return () => { + cancelled = true; + }; + }, [config.webJsKey, config.securityJsCode, config.securityServiceHost, mode, validPoints]); + + useEffect(() => () => { + mapRef.current?.destroy(); + mapRef.current = null; + overlaysRef.current = []; + }, []); + + const fallbackPoints = (validPoints.length > 0 ? validPoints : points).slice(0, maxFallbackPoints); + const showFallback = status !== 'ready'; + + return ( +
+
+ {showFallback ? ( +
+ {fallbackPoints.map((point, index) => ( + + ))} + + {status === 'loading' ? '地图加载中' : fallbackLabel || '高德地图未配置,显示坐标预览'} + +
+ ) : null} + {children} +
+ ); +} diff --git a/vehicle-data-platform/apps/web/src/config/appConfig.test.ts b/vehicle-data-platform/apps/web/src/config/appConfig.test.ts new file mode 100644 index 00000000..7913f770 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/config/appConfig.test.ts @@ -0,0 +1,27 @@ +import { afterEach, expect, test } from 'vitest'; +import { getAMapConfig, isAMapConfigured } from './appConfig'; + +afterEach(() => { + delete window.__LINGNIU_APP_CONFIG__; +}); + +test('reads AMap runtime config from app config script', () => { + window.__LINGNIU_APP_CONFIG__ = { + amapWebJsKey: 'runtime-key', + amapSecurityJsCode: 'runtime-security', + amapSecurityServiceHost: '/_AMapService' + }; + + expect(getAMapConfig()).toEqual({ + webJsKey: 'runtime-key', + securityJsCode: 'runtime-security', + securityServiceHost: '/_AMapService' + }); + expect(isAMapConfigured()).toBe(true); +}); + +test('treats blank AMap key as not configured', () => { + window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: ' ' }; + + expect(isAMapConfigured()).toBe(false); +}); diff --git a/vehicle-data-platform/apps/web/src/config/appConfig.ts b/vehicle-data-platform/apps/web/src/config/appConfig.ts new file mode 100644 index 00000000..1d789b64 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/config/appConfig.ts @@ -0,0 +1,18 @@ +export type AMapRuntimeConfig = { + webJsKey: string; + securityJsCode: string; + securityServiceHost: string; +}; + +export function getAMapConfig(): AMapRuntimeConfig { + const runtime = window.__LINGNIU_APP_CONFIG__ ?? {}; + return { + webJsKey: String(runtime.amapWebJsKey || import.meta.env.VITE_AMAP_WEB_JS_KEY || '').trim(), + securityJsCode: String(runtime.amapSecurityJsCode || '').trim(), + securityServiceHost: String(runtime.amapSecurityServiceHost || '').trim() + }; +} + +export function isAMapConfigured(config = getAMapConfig()) { + return Boolean(config.webJsKey); +} diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index 922c07e5..cdfe1c18 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -3,7 +3,9 @@ import { IconRefresh, IconSearch } from '@douyinfe/semi-icons'; import { useEffect, useMemo, useState } from 'react'; import { api, type RawFrameQuery } from '../api/client'; import type { HistoryLocationRow, Page, RawFrameRow } from '../api/types'; +import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap'; import { PageHeader } from '../components/PageHeader'; +import { isAMapConfigured } from '../config/appConfig'; type HistoryFilters = { keyword?: string; @@ -51,15 +53,6 @@ function hasValidCoordinate(row: HistoryLocationRow) { return isFiniteNumber(row.longitude) && isFiniteNumber(row.latitude) && row.longitude !== 0 && row.latitude !== 0; } -function mapPointStyle(row: HistoryLocationRow, index: number) { - if (!hasValidCoordinate(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}%` }; -} - function formatNumber(value?: number, suffix = '') { if (!isFiniteNumber(value)) return '-'; return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}${suffix}`; @@ -197,7 +190,16 @@ export function History({ const lastLocation = locationItems[locationItems.length - 1]; const currentVehicleKeyword = filters.keyword?.trim() ?? ''; const currentProtocol = filters.protocol?.trim() ?? ''; + const amapConfigured = isAMapConfigured(); const selectedFieldCount = splitFields(filters.fields).length; + const playbackPoints: VehicleMapPoint[] = validLocations.map((row, index) => ({ + id: `${row.vin}-${row.deviceTime || row.serverTime || index}`, + label: row.plate || row.vin || `点 ${index + 1}`, + longitude: row.longitude, + latitude: row.latitude, + online: true, + title: `${row.deviceTime || row.serverTime || '-'} ${row.speedKmh ?? '-'} km/h` + })); const filterSummary = [ currentVehicleKeyword ? `车辆:${currentVehicleKeyword}` : '', currentProtocol ? `数据来源:${currentProtocol}` : '', @@ -270,18 +272,14 @@ export function History({ {validLocations.length.toLocaleString()} 个有效轨迹点 {currentProtocol || '全部来源'} {formatNumber(mileageDelta, ' km')} + {amapConfigured ? '高德地图配置就绪' : '高德地图待配置'}
-
- {validLocations.slice(0, 120).map((row, index) => ( - - ))} -
+
{[ @@ -316,7 +314,7 @@ export function History({ 起点:{firstLocation?.deviceTime || firstLocation?.serverTime || '-'} 终点:{lastLocation?.deviceTime || lastLocation?.serverTime || '-'} - 地图接入:高德 JS API 运行配置预留 + 地图接入:高德 JS API 运行时配置 diff --git a/vehicle-data-platform/apps/web/src/pages/Realtime.tsx b/vehicle-data-platform/apps/web/src/pages/Realtime.tsx index 539a1a4f..ea0ea595 100644 --- a/vehicle-data-platform/apps/web/src/pages/Realtime.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Realtime.tsx @@ -6,6 +6,8 @@ import { DataEmpty } from '../components/DataEmpty'; import { PageHeader } from '../components/PageHeader'; import { SourceStatusTags } from '../components/SourceStatusTags'; import { StatusTag } from '../components/StatusTag'; +import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap'; +import { isAMapConfigured } from '../config/appConfig'; function canOpenVehicle(vin?: string) { const value = vin?.trim(); @@ -36,15 +38,6 @@ 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: '离线' @@ -70,7 +63,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 amapConfigured = isAMapConfigured(); const load = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { setLoading(true); @@ -108,6 +101,14 @@ export function Realtime({ 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)); + const mapPoints: VehicleMapPoint[] = rows.map((row, index) => ({ + id: row.vin || `${row.primaryProtocol}-${index}`, + label: row.plate || row.vin || 'unknown', + longitude: row.longitude, + latitude: row.latitude, + online: row.online, + title: `${row.plate || row.vin || '-'} ${row.primaryProtocol || ''} ${row.lastSeen || ''}` + })); return (
@@ -161,16 +162,11 @@ export function Realtime({ {onlineCount.toLocaleString()} 辆在线
-
- {rows.slice(0, 80).map((row, index) => ( - - ))} -
+
{[ @@ -251,16 +247,7 @@ export function Realtime({ )} -
- {rows.map((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 4c38ccb0..fca28783 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -252,6 +252,50 @@ body { background-size: 36px 36px; } +.vp-amap-canvas { + position: absolute; + inset: 0; +} + +.vp-map-fallback { + position: absolute; + inset: 0; + background: + linear-gradient(90deg, rgba(22, 100, 255, 0.08) 1px, transparent 1px), + linear-gradient(0deg, rgba(22, 100, 255, 0.08) 1px, transparent 1px), + rgba(248, 251, 255, 0.94); + background-size: 36px 36px; +} + +.vp-map-fallback-status { + position: absolute; + left: 12px; + bottom: 12px; + box-shadow: var(--vp-shadow-sm); +} + +.vp-amap-marker { + min-width: 18px; + height: 18px; + padding: 0 5px; + border: 2px solid #fff; + border-radius: 999px; + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 14px; + text-align: center; + box-shadow: 0 8px 18px rgba(16, 24, 40, 0.2); +} + +.vp-amap-marker-online { + background: var(--vp-success); +} + +.vp-amap-marker-offline { + background: var(--vp-warning); +} + .vp-monitor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 260px; diff --git a/vehicle-data-platform/apps/web/src/vite-env.d.ts b/vehicle-data-platform/apps/web/src/vite-env.d.ts index 76530591..56f0b7e5 100644 --- a/vehicle-data-platform/apps/web/src/vite-env.d.ts +++ b/vehicle-data-platform/apps/web/src/vite-env.d.ts @@ -7,3 +7,22 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +interface Window { + __LINGNIU_APP_CONFIG__?: { + amapWebJsKey?: string; + amapSecurityJsCode?: string; + amapSecurityServiceHost?: string; + }; + _AMapSecurityConfig?: { + securityJsCode?: string; + serviceHost?: string; + }; + AMapLoader?: { + load: (options: { + key: string; + version: string; + plugins?: string[]; + }) => Promise; + }; +} diff --git a/vehicle-data-platform/docs/deployment.md b/vehicle-data-platform/docs/deployment.md index e62239f5..7c06974f 100644 --- a/vehicle-data-platform/docs/deployment.md +++ b/vehicle-data-platform/docs/deployment.md @@ -36,11 +36,16 @@ TDENGINE_DATABASE=lingniu_vehicle_ts CAPACITY_CHECK_BIN=/opt/lingniu-go-native/current/capacity-check AUTH_TOKEN=*** REQUEST_TIMEOUT_MS=5000 +AMAP_WEB_JS_KEY=*** +AMAP_SECURITY_SERVICE_HOST=/_AMapService ``` +`AMAP_WEB_JS_KEY` is served to the browser through `/app-config.js` so the same static build can be reused across environments. Prefer `AMAP_SECURITY_SERVICE_HOST` with a server-side proxy for production. `AMAP_SECURITY_JS_CODE` is also supported for debugging or controlled internal deployments, but it is exposed to the browser by design and should not be committed. + ## Health ```bash curl -fsS http://127.0.0.1:20300/api/ops/health curl -fsS 'http://127.0.0.1:20300/api/vehicles?limit=5' +curl -fsS http://127.0.0.1:20300/app-config.js ```