feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

View File

@@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { VehicleMap } from './VehicleMap';
class TestMap {
add = vi.fn();
addControl = vi.fn();
setFitView = vi.fn();
destroy = vi.fn();
constructor(
public container: HTMLDivElement,
public options: Record<string, unknown>
) {}
}
class TestOverlay {
setMap = vi.fn();
on = vi.fn();
}
class TestScale {}
afterEach(() => {
delete window.__LINGNIU_APP_CONFIG__;
delete window.AMapLoader;
vi.restoreAllMocks();
});
test('loads AMap base map even when realtime vehicle points are empty', async () => {
const mapLoad = vi.fn().mockResolvedValue({
Map: TestMap,
Marker: TestOverlay,
Polyline: TestOverlay,
Scale: TestScale
});
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: mapLoad };
render(<VehicleMap points={[]} fallbackLabel="等待真实车辆坐标" />);
expect(await screen.findByText('高德地图已加载')).toBeInTheDocument();
expect(screen.queryByText('等待真实车辆坐标')).not.toBeInTheDocument();
expect(mapLoad).toHaveBeenCalledWith({
key: 'amap-web-key',
version: '2.0',
plugins: ['AMap.Scale']
});
});

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { Tag } from '@douyinfe/semi-ui';
import { getAMapConfig, isAMapConfigured } from '../config/appConfig';
import { isValidAMapCoordinate, loadAMap, wgs84ToGcj02, type AMapMap, type AMapOverlay } from '../integrations/amap';
export type VehicleMapPoint = {
id: string;
@@ -11,72 +12,8 @@ export type VehicleMapPoint = {
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;
on?: (eventName: string, handler: () => void) => 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;
return isValidAMapCoordinate(point.longitude, point.latitude);
}
function pointToPixelStyle(point: VehicleMapPoint, index: number) {
@@ -88,6 +25,38 @@ function pointToPixelStyle(point: VehicleMapPoint, index: number) {
return { left: `${left}%`, top: `${top}%` };
}
function escapeHTML(value: string) {
return value.replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char] ?? char));
}
function markerContent(point: VehicleMapPoint, index: number, selected: boolean, mode: 'realtime' | 'track') {
const statusClass = point.online === false ? 'vp-amap-marker-offline' : 'vp-amap-marker-online';
const selectedClass = selected ? 'vp-amap-marker-selected' : '';
const markerText = mode === 'track' ? String(index + 1) : '';
const label = selected ? `<span class="vp-amap-marker-label">${escapeHTML(point.label)}</span>` : '';
return `<button type="button" class="vp-amap-marker-shell ${selected ? 'vp-amap-marker-shell-selected' : ''}" data-map-vehicle-id="${escapeHTML(point.id)}" aria-label="选择地图车辆 ${escapeHTML(point.label)}"><span class="vp-amap-marker ${statusClass} ${selectedClass}">${markerText}</span>${label}</button>`;
}
function initialMapView(points: VehicleMapPoint[], mode: 'realtime' | 'track') {
const firstPoint = points[0];
if (firstPoint) {
return {
center: wgs84ToGcj02(firstPoint.longitude, firstPoint.latitude),
zoom: mode === 'track' ? 13 : 11
};
}
return {
center: wgs84ToGcj02(104.1954, 35.8617),
zoom: 4
};
}
export function VehicleMap({
points,
mode = 'realtime',
@@ -116,7 +85,7 @@ export function VehicleMap({
useEffect(() => {
let cancelled = false;
if (!isAMapConfigured(config) || validPoints.length === 0 || !containerRef.current) {
if (!isAMapConfigured(config) || !containerRef.current) {
setStatus('fallback');
return;
}
@@ -124,10 +93,11 @@ export function VehicleMap({
loadAMap()
.then((AMap) => {
if (cancelled || !containerRef.current) return;
const mapView = initialMapView(validPoints, mode);
if (!mapRef.current) {
mapRef.current = new AMap.Map(containerRef.current, {
zoom: 11,
center: [validPoints[0].longitude, validPoints[0].latitude],
zoom: mapView.zoom,
center: mapView.center,
viewMode: '2D',
mapStyle: 'amap://styles/normal'
});
@@ -135,11 +105,12 @@ export function VehicleMap({
}
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
const markers = validPoints.slice(0, 500).map((point, index) => {
const selected = point.id === selectedId;
const marker = new AMap.Marker({
position: [point.longitude, point.latitude],
position: wgs84ToGcj02(point.longitude, point.latitude),
title: point.title || point.label,
zIndex: point.id === selectedId ? 300 : mode === 'track' ? 100 + index : 100,
content: `<div class="vp-amap-marker ${point.online === false ? 'vp-amap-marker-offline' : 'vp-amap-marker-online'} ${point.id === selectedId ? 'vp-amap-marker-selected' : ''}">${mode === 'track' ? index + 1 : ''}</div>`
zIndex: selected ? 300 : mode === 'track' ? 100 + index : 100,
content: markerContent(point, index, selected, mode)
});
marker.on?.('click', () => onPointSelect?.(point));
return marker;
@@ -147,7 +118,7 @@ export function VehicleMap({
const nextOverlays: AMapOverlay[] = [...markers];
if (mode === 'track' && validPoints.length > 1) {
nextOverlays.push(new AMap.Polyline({
path: validPoints.map((point) => [point.longitude, point.latitude]),
path: validPoints.map((point) => wgs84ToGcj02(point.longitude, point.latitude)),
strokeColor: '#1664ff',
strokeOpacity: 0.85,
strokeWeight: 5,
@@ -155,8 +126,10 @@ export function VehicleMap({
}));
}
overlaysRef.current = nextOverlays;
mapRef.current.add(nextOverlays);
mapRef.current.setFitView(nextOverlays, false, [56, 56, 56, 56]);
if (nextOverlays.length > 0) {
mapRef.current.add(nextOverlays);
mapRef.current.setFitView(nextOverlays, false, [56, 56, 56, 56]);
}
setStatus('ready');
})
.catch(() => {
@@ -196,7 +169,11 @@ export function VehicleMap({
{status === 'loading' ? '地图加载中' : fallbackLabel || '高德地图未配置,显示坐标预览'}
</Tag>
</div>
) : null}
) : (
<Tag className="vp-map-provider-status" color="green">
</Tag>
)}
{children}
</div>
);