feat(platform): integrate runtime amap vehicle maps
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/app-config.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
191
vehicle-data-platform/apps/web/src/components/VehicleMap.tsx
Normal file
191
vehicle-data-platform/apps/web/src/components/VehicleMap.tsx
Normal file
@@ -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<string, unknown>) => AMapMap;
|
||||
Marker: new (options: Record<string, unknown>) => AMapOverlay;
|
||||
Polyline: new (options: Record<string, unknown>) => 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<AMapLike> | 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<void>((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>(`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<AMapLike>;
|
||||
});
|
||||
}
|
||||
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<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<AMapMap | null>(null);
|
||||
const overlaysRef = useRef<AMapOverlay[]>([]);
|
||||
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: `<div class="vp-amap-marker ${point.online === false ? 'vp-amap-marker-offline' : 'vp-amap-marker-online'}">${mode === 'track' ? index + 1 : ''}</div>`
|
||||
}));
|
||||
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 (
|
||||
<div className={`vp-map ${heightClassName}`}>
|
||||
<div ref={containerRef} className="vp-amap-canvas" />
|
||||
{showFallback ? (
|
||||
<div className="vp-map-fallback">
|
||||
{fallbackPoints.map((point, index) => (
|
||||
<span
|
||||
key={`${point.id}-${index}`}
|
||||
className={`vp-map-dot ${mode === 'track' && index === 0 ? 'vp-map-dot-start' : mode === 'track' && index === fallbackPoints.length - 1 ? 'vp-map-dot-end' : point.online === false ? 'vp-map-dot-offline' : 'vp-map-dot-online'}`}
|
||||
title={point.title || point.label}
|
||||
style={pointToPixelStyle(point, index)}
|
||||
/>
|
||||
))}
|
||||
<Tag className="vp-map-fallback-status" color={isAMapConfigured(config) ? 'blue' : 'orange'}>
|
||||
{status === 'loading' ? '地图加载中' : fallbackLabel || '高德地图未配置,显示坐标预览'}
|
||||
</Tag>
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
vehicle-data-platform/apps/web/src/config/appConfig.test.ts
Normal file
27
vehicle-data-platform/apps/web/src/config/appConfig.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
18
vehicle-data-platform/apps/web/src/config/appConfig.ts
Normal file
18
vehicle-data-platform/apps/web/src/config/appConfig.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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({
|
||||
<Tag color="blue">{validLocations.length.toLocaleString()} 个有效轨迹点</Tag>
|
||||
<Tag color={currentProtocol ? 'blue' : 'green'}>{currentProtocol || '全部来源'}</Tag>
|
||||
<Tag color="green">{formatNumber(mileageDelta, ' km')}</Tag>
|
||||
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? '高德地图配置就绪' : '高德地图待配置'}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="vp-map vp-map-monitor">
|
||||
{validLocations.slice(0, 120).map((row, index) => (
|
||||
<span
|
||||
key={`${row.vin}-${row.deviceTime}-${index}`}
|
||||
className={`vp-map-dot ${index === 0 ? 'vp-map-dot-start' : index === validLocations.length - 1 ? 'vp-map-dot-end' : 'vp-map-dot-online'}`}
|
||||
title={`${row.deviceTime} ${row.speedKmh ?? '-'} km/h`}
|
||||
style={mapPointStyle(row, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<VehicleMap
|
||||
points={playbackPoints}
|
||||
mode="track"
|
||||
fallbackLabel="高德地图未配置,显示轨迹坐标预览"
|
||||
/>
|
||||
</div>
|
||||
<div className="vp-playback-side">
|
||||
{[
|
||||
@@ -316,7 +314,7 @@ export function History({
|
||||
<Space wrap style={{ marginTop: 12 }}>
|
||||
<Tag color="grey">起点:{firstLocation?.deviceTime || firstLocation?.serverTime || '-'}</Tag>
|
||||
<Tag color="grey">终点:{lastLocation?.deviceTime || lastLocation?.serverTime || '-'}</Tag>
|
||||
<Tag color="grey">地图接入:高德 JS API 运行配置预留</Tag>
|
||||
<Tag color="grey">地图接入:高德 JS API 运行时配置</Tag>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card bordered style={{ marginTop: 16 }}>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
online: '在线',
|
||||
offline: '离线'
|
||||
@@ -70,7 +63,7 @@ export function Realtime({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<Record<string, string>>(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<string, string> = 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 (
|
||||
<div className="vp-page">
|
||||
@@ -161,16 +162,11 @@ export function Realtime({
|
||||
<Tag color="green">{onlineCount.toLocaleString()} 辆在线</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="vp-map vp-map-monitor">
|
||||
{rows.slice(0, 80).map((row, index) => (
|
||||
<span
|
||||
key={`${row.vin}-${row.primaryProtocol}-${index}`}
|
||||
className={`vp-map-dot ${row.online ? 'vp-map-dot-online' : 'vp-map-dot-offline'}`}
|
||||
title={`${row.plate || row.vin} ${row.primaryProtocol || ''}`}
|
||||
style={mapPointStyle(row, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<VehicleMap
|
||||
points={mapPoints}
|
||||
maxFallbackPoints={80}
|
||||
fallbackLabel="高德地图未配置,显示实时坐标预览"
|
||||
/>
|
||||
</div>
|
||||
<div className="vp-monitor-side">
|
||||
{[
|
||||
@@ -251,16 +247,7 @@ export function Realtime({
|
||||
)}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="地图视图" itemKey="map">
|
||||
<div className="vp-map">
|
||||
{rows.map((row, index) => (
|
||||
<span
|
||||
key={row.vin}
|
||||
className="vp-map-dot"
|
||||
title={`${row.plate} ${row.primaryProtocol}`}
|
||||
style={mapPointStyle(row, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<VehicleMap points={mapPoints} heightClassName="" fallbackLabel="高德地图未配置,显示实时坐标预览" />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
@@ -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;
|
||||
|
||||
19
vehicle-data-platform/apps/web/src/vite-env.d.ts
vendored
19
vehicle-data-platform/apps/web/src/vite-env.d.ts
vendored
@@ -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<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user