feat(platform): add amap reverse geocode for trajectory
This commit is contained in:
@@ -8,8 +8,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -61,6 +63,7 @@ func NewServer(cfg config.Config) http.Handler {
|
|||||||
PlatformRelease: strings.TrimSpace(cfg.PlatformRelease),
|
PlatformRelease: strings.TrimSpace(cfg.PlatformRelease),
|
||||||
}))
|
}))
|
||||||
handler := static.Handler(cfg.StaticDir, api)
|
handler := static.Handler(cfg.StaticDir, api)
|
||||||
|
handler = withAMapReverseGeocodeAPI(handler, cfg, "https://restapi.amap.com", http.DefaultClient)
|
||||||
handler = withAppConfig(handler, cfg)
|
handler = withAppConfig(handler, cfg)
|
||||||
handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient)
|
handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient)
|
||||||
return withRequestTimeout(handler, cfg.RequestTimeout)
|
return withRequestTimeout(handler, cfg.RequestTimeout)
|
||||||
@@ -195,6 +198,157 @@ func amapProxyURL(upstreams amapProxyUpstreams, serviceHost string, securityCode
|
|||||||
return base.String(), nil
|
return base.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mapReverseGeocodeResponse struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
FormattedAddress string `json:"formattedAddress"`
|
||||||
|
Province string `json:"province,omitempty"`
|
||||||
|
City string `json:"city,omitempty"`
|
||||||
|
District string `json:"district,omitempty"`
|
||||||
|
Township string `json:"township,omitempty"`
|
||||||
|
Adcode string `json:"adcode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func withAMapReverseGeocodeAPI(next http.Handler, cfg config.Config, upstream string, client *http.Client) http.Handler {
|
||||||
|
apiKey := strings.TrimSpace(cfg.AMapAPIKey)
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
|
upstream = strings.TrimRight(upstream, "/")
|
||||||
|
if upstream == "" {
|
||||||
|
upstream = "https://restapi.amap.com"
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/map/reverse-geocode" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "请求方法不支持", "", requestTraceID(r))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if apiKey == "" {
|
||||||
|
httpx.WriteError(w, http.StatusServiceUnavailable, "AMAP_API_UNCONFIGURED", "高德服务端 API 未配置", "请配置 AMAP_API_KEY 后再解析地址", requestTraceID(r))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
longitude, latitude, err := parseReverseGeocodeCoordinate(r.URL.Query())
|
||||||
|
if err != nil {
|
||||||
|
httpx.WriteError(w, http.StatusBadRequest, "BAD_COORDINATE", "经纬度参数无效", err.Error(), requestTraceID(r))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target, err := url.Parse(upstream + "/v3/geocode/regeo")
|
||||||
|
if err != nil {
|
||||||
|
httpx.WriteError(w, http.StatusBadGateway, "AMAP_REVERSE_GEOCODE_URL_INVALID", "高德逆地理编码地址无效", err.Error(), requestTraceID(r))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := target.Query()
|
||||||
|
query.Set("key", apiKey)
|
||||||
|
query.Set("location", fmt.Sprintf("%.6f,%.6f", longitude, latitude))
|
||||||
|
query.Set("extensions", "base")
|
||||||
|
query.Set("radius", "1000")
|
||||||
|
query.Set("output", "JSON")
|
||||||
|
target.RawQuery = query.Encode()
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
httpx.WriteError(w, http.StatusBadGateway, "AMAP_REVERSE_GEOCODE_REQUEST_INVALID", "高德逆地理编码请求无效", err.Error(), requestTraceID(r))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.WriteError(w, http.StatusBadGateway, "AMAP_REVERSE_GEOCODE_FAILED", "高德逆地理编码请求失败", err.Error(), requestTraceID(r))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
httpx.WriteError(w, http.StatusBadGateway, "AMAP_REVERSE_GEOCODE_BAD_STATUS", "高德逆地理编码返回异常", resp.Status, requestTraceID(r))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Info string `json:"info"`
|
||||||
|
Infocode string `json:"infocode"`
|
||||||
|
Regeocode struct {
|
||||||
|
FormattedAddress string `json:"formatted_address"`
|
||||||
|
AddressComponent struct {
|
||||||
|
Province json.RawMessage `json:"province"`
|
||||||
|
City json.RawMessage `json:"city"`
|
||||||
|
District json.RawMessage `json:"district"`
|
||||||
|
Township json.RawMessage `json:"township"`
|
||||||
|
Adcode json.RawMessage `json:"adcode"`
|
||||||
|
} `json:"addressComponent"`
|
||||||
|
} `json:"regeocode"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||||
|
httpx.WriteError(w, http.StatusBadGateway, "AMAP_REVERSE_GEOCODE_BAD_JSON", "高德逆地理编码响应解析失败", err.Error(), requestTraceID(r))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Status != "1" {
|
||||||
|
detail := strings.TrimSpace(body.Info)
|
||||||
|
if body.Infocode != "" {
|
||||||
|
detail = strings.TrimSpace(detail + " " + body.Infocode)
|
||||||
|
}
|
||||||
|
httpx.WriteError(w, http.StatusBadGateway, "AMAP_REVERSE_GEOCODE_REJECTED", "高德逆地理编码未成功", detail, requestTraceID(r))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := mapReverseGeocodeResponse{
|
||||||
|
Provider: "AMap",
|
||||||
|
Longitude: longitude,
|
||||||
|
Latitude: latitude,
|
||||||
|
FormattedAddress: body.Regeocode.FormattedAddress,
|
||||||
|
Province: amapJSONText(body.Regeocode.AddressComponent.Province),
|
||||||
|
City: amapJSONText(body.Regeocode.AddressComponent.City),
|
||||||
|
District: amapJSONText(body.Regeocode.AddressComponent.District),
|
||||||
|
Township: amapJSONText(body.Regeocode.AddressComponent.Township),
|
||||||
|
Adcode: amapJSONText(body.Regeocode.AddressComponent.Adcode),
|
||||||
|
}
|
||||||
|
httpx.WriteOK(w, requestTraceID(r), result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseReverseGeocodeCoordinate(query url.Values) (float64, float64, error) {
|
||||||
|
longitude, err := strconv.ParseFloat(strings.TrimSpace(firstNonEmpty(query.Get("longitude"), query.Get("lng"))), 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("longitude 必须是数字")
|
||||||
|
}
|
||||||
|
latitude, err := strconv.ParseFloat(strings.TrimSpace(firstNonEmpty(query.Get("latitude"), query.Get("lat"))), 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("latitude 必须是数字")
|
||||||
|
}
|
||||||
|
if !isCoordinate(longitude, -180, 180) || !isCoordinate(latitude, -90, 90) || (longitude == 0 && latitude == 0) {
|
||||||
|
return 0, 0, fmt.Errorf("longitude/latitude 超出范围或为空坐标")
|
||||||
|
}
|
||||||
|
return longitude, latitude, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCoordinate(value float64, min float64, max float64) bool {
|
||||||
|
return !math.IsNaN(value) && !math.IsInf(value, 0) && value >= min && value <= max
|
||||||
|
}
|
||||||
|
|
||||||
|
func amapJSONText(value json.RawMessage) string {
|
||||||
|
if len(value) == 0 || string(value) == "null" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var text string
|
||||||
|
if err := json.Unmarshal(value, &text); err == nil {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
var texts []string
|
||||||
|
if err := json.Unmarshal(value, &texts); err == nil {
|
||||||
|
return strings.Join(texts, "")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func withRequestTimeout(next http.Handler, timeout time.Duration) http.Handler {
|
func withRequestTimeout(next http.Handler, timeout time.Duration) http.Handler {
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
return next
|
return next
|
||||||
|
|||||||
@@ -156,3 +156,91 @@ func TestAMapSecurityProxyRoutesKnownMapResources(t *testing.T) {
|
|||||||
t.Fatalf("unexpected proxy routing: %+v", seen)
|
t.Fatalf("unexpected proxy routing: %+v", seen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAMapReverseGeocodeAPIUsesServerSideKey(t *testing.T) {
|
||||||
|
var gotKey string
|
||||||
|
var gotLocation string
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotKey = r.URL.Query().Get("key")
|
||||||
|
gotLocation = r.URL.Query().Get("location")
|
||||||
|
if r.URL.Path != "/v3/geocode/regeo" {
|
||||||
|
t.Fatalf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"status":"1",
|
||||||
|
"info":"OK",
|
||||||
|
"regeocode":{
|
||||||
|
"formatted_address":"广东省广州市天河区测试路",
|
||||||
|
"addressComponent":{
|
||||||
|
"province":"广东省",
|
||||||
|
"city":"广州市",
|
||||||
|
"district":"天河区",
|
||||||
|
"township":"五山街道",
|
||||||
|
"adcode":"440106"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
handler := withAMapReverseGeocodeAPI(http.NotFoundHandler(), config.Config{AMapAPIKey: "server-api-key"}, upstream.URL, http.DefaultClient)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/map/reverse-geocode?longitude=113.1234567&latitude=23.7654321", nil)
|
||||||
|
req.Header.Set("X-Trace-Id", "trace-map")
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if gotKey != "server-api-key" || gotLocation != "113.123457,23.765432" {
|
||||||
|
t.Fatalf("key=%q location=%q", gotKey, gotLocation)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Data struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
FormattedAddress string `json:"formattedAddress"`
|
||||||
|
Province string `json:"province"`
|
||||||
|
City string `json:"city"`
|
||||||
|
District string `json:"district"`
|
||||||
|
Township string `json:"township"`
|
||||||
|
Adcode string `json:"adcode"`
|
||||||
|
} `json:"data"`
|
||||||
|
TraceID string `json:"traceId"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("response should be JSON: %v body=%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if body.TraceID != "trace-map" || body.Data.Provider != "AMap" || body.Data.FormattedAddress != "广东省广州市天河区测试路" || body.Data.Adcode != "440106" {
|
||||||
|
t.Fatalf("unexpected reverse geocode body: %+v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMapReverseGeocodeAPIRequiresServerSideKey(t *testing.T) {
|
||||||
|
handler := withAMapReverseGeocodeAPI(http.NotFoundHandler(), config.Config{}, "https://example.com", http.DefaultClient)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/map/reverse-geocode?longitude=113.1&latitude=23.1", nil)
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "AMAP_API_UNCONFIGURED") {
|
||||||
|
t.Fatalf("body should mention missing AMap API config: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMapReverseGeocodeAPIRejectsBadCoordinate(t *testing.T) {
|
||||||
|
handler := withAMapReverseGeocodeAPI(http.NotFoundHandler(), config.Config{AMapAPIKey: "server-api-key"}, "https://example.com", http.DefaultClient)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/map/reverse-geocode?longitude=0&latitude=0", nil)
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "BAD_COORDINATE") {
|
||||||
|
t.Fatalf("body should mention bad coordinate: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -172,6 +172,31 @@ test('vehicleServiceSummary reads the vehicle service summary endpoint', async (
|
|||||||
expect(result.serviceStatuses.find((item) => item.status === 'no_data')?.count).toBe(461);
|
expect(result.serviceStatuses.find((item) => item.status === 'no_data')?.count).toBe(461);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reverseGeocode reads the server-side AMap reverse geocode endpoint', async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
provider: 'AMap',
|
||||||
|
longitude: 113.2,
|
||||||
|
latitude: 23.1,
|
||||||
|
formattedAddress: '广东省广州市天河区测试路',
|
||||||
|
province: '广东省',
|
||||||
|
city: '广州市',
|
||||||
|
district: '天河区',
|
||||||
|
adcode: '440106'
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await api.reverseGeocode(new URLSearchParams({ longitude: '113.2', latitude: '23.1' }));
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/map/reverse-geocode?longitude=113.2&latitude=23.1', undefined);
|
||||||
|
expect(result.formattedAddress).toBe('广东省广州市天河区测试路');
|
||||||
|
});
|
||||||
|
|
||||||
test('qualityNotificationPlan reads alert rules and priority issues from backend', async () => {
|
test('qualityNotificationPlan reads alert rules and priority issues from backend', async () => {
|
||||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
HistoryLocationRow,
|
HistoryLocationRow,
|
||||||
MileageSummary,
|
MileageSummary,
|
||||||
|
MapReverseGeocode,
|
||||||
OnlineStatisticsSummary,
|
OnlineStatisticsSummary,
|
||||||
OnlineVehicleStatusRow,
|
OnlineVehicleStatusRow,
|
||||||
OpsHealth,
|
OpsHealth,
|
||||||
@@ -115,5 +116,6 @@ export const api = {
|
|||||||
alertEventSummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/alert-events/summary?${params.toString()}`),
|
alertEventSummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/alert-events/summary?${params.toString()}`),
|
||||||
alertEvents: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/alert-events?${params.toString()}`),
|
alertEvents: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/alert-events?${params.toString()}`),
|
||||||
alertEventNotificationPlan: (params = new URLSearchParams()) => request<QualityNotificationPlan>(`/api/alert-events/notification-plan?${params.toString()}`),
|
alertEventNotificationPlan: (params = new URLSearchParams()) => request<QualityNotificationPlan>(`/api/alert-events/notification-plan?${params.toString()}`),
|
||||||
|
reverseGeocode: (params = new URLSearchParams()) => request<MapReverseGeocode>(`/api/map/reverse-geocode?${params.toString()}`),
|
||||||
opsHealth: () => request<OpsHealth>('/api/ops/health')
|
opsHealth: () => request<OpsHealth>('/api/ops/health')
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -382,6 +382,18 @@ export interface RuntimeInfo {
|
|||||||
platformRelease?: string;
|
platformRelease?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MapReverseGeocode {
|
||||||
|
provider: string;
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
formattedAddress: string;
|
||||||
|
province?: string;
|
||||||
|
city?: string;
|
||||||
|
district?: string;
|
||||||
|
township?: string;
|
||||||
|
adcode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Page<T> {
|
export interface Page<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Button, Card, Form, Select, SideSheet, Space, Table, Tabs, Tag, Toast,
|
|||||||
import { IconCopy, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
import { IconCopy, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { api, type RawFrameQuery } from '../api/client';
|
import { api, type RawFrameQuery } from '../api/client';
|
||||||
import type { HistoryLocationRow, Page, RawFrameRow } from '../api/types';
|
import type { HistoryLocationRow, MapReverseGeocode, Page, RawFrameRow } from '../api/types';
|
||||||
import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap';
|
import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap';
|
||||||
import { PageHeader } from '../components/PageHeader';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
import { isAMapConfigured } from '../config/appConfig';
|
import { isAMapConfigured } from '../config/appConfig';
|
||||||
@@ -351,6 +351,7 @@ function trajectoryReviewPackageText({
|
|||||||
currentPlayback,
|
currentPlayback,
|
||||||
currentPlaybackIndex,
|
currentPlaybackIndex,
|
||||||
playbackCount,
|
playbackCount,
|
||||||
|
currentAddress,
|
||||||
anomalySummary
|
anomalySummary
|
||||||
}: {
|
}: {
|
||||||
filters: HistoryFilters;
|
filters: HistoryFilters;
|
||||||
@@ -368,6 +369,7 @@ function trajectoryReviewPackageText({
|
|||||||
currentPlayback?: HistoryLocationRow;
|
currentPlayback?: HistoryLocationRow;
|
||||||
currentPlaybackIndex: number;
|
currentPlaybackIndex: number;
|
||||||
playbackCount: number;
|
playbackCount: number;
|
||||||
|
currentAddress?: MapReverseGeocode;
|
||||||
anomalySummary: TrajectoryAnomalySummary;
|
anomalySummary: TrajectoryAnomalySummary;
|
||||||
}) {
|
}) {
|
||||||
const vehicle = filters.keyword?.trim() || '';
|
const vehicle = filters.keyword?.trim() || '';
|
||||||
@@ -390,6 +392,7 @@ function trajectoryReviewPackageText({
|
|||||||
`起点:${firstLocation?.deviceTime || firstLocation?.serverTime || '-'}`,
|
`起点:${firstLocation?.deviceTime || firstLocation?.serverTime || '-'}`,
|
||||||
`终点:${lastLocation?.deviceTime || lastLocation?.serverTime || '-'}`,
|
`终点:${lastLocation?.deviceTime || lastLocation?.serverTime || '-'}`,
|
||||||
`当前回放点:${currentPlayback ? `点 ${currentPlaybackIndex + 1}/${playbackCount},${currentPlayback.deviceTime || currentPlayback.serverTime || '-'},${formatNumber(currentPlayback.speedKmh, ' km/h')},${formatNumber(currentPlayback.totalMileageKm, ' km')}` : '-'}`,
|
`当前回放点:${currentPlayback ? `点 ${currentPlaybackIndex + 1}/${playbackCount},${currentPlayback.deviceTime || currentPlayback.serverTime || '-'},${formatNumber(currentPlayback.speedKmh, ' km/h')},${formatNumber(currentPlayback.totalMileageKm, ' km')}` : '-'}`,
|
||||||
|
`当前点地址:${currentAddress?.formattedAddress || '-'}`,
|
||||||
`异常判读:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`,
|
`异常判读:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`,
|
||||||
`最大断点:${formatDurationMinutes(anomalySummary.maxGapMinutes)}`,
|
`最大断点:${formatDurationMinutes(anomalySummary.maxGapMinutes)}`,
|
||||||
`最大里程回退:${formatNumber(anomalySummary.maxMileageRollbackKm, ' km')}`,
|
`最大里程回退:${formatNumber(anomalySummary.maxMileageRollbackKm, ' km')}`,
|
||||||
@@ -475,6 +478,8 @@ export function History({
|
|||||||
const [playbackIndex, setPlaybackIndex] = useState(0);
|
const [playbackIndex, setPlaybackIndex] = useState(0);
|
||||||
const [playbackPlaying, setPlaybackPlaying] = useState(false);
|
const [playbackPlaying, setPlaybackPlaying] = useState(false);
|
||||||
const [playbackSpeedMs, setPlaybackSpeedMs] = useState(1200);
|
const [playbackSpeedMs, setPlaybackSpeedMs] = useState(1200);
|
||||||
|
const [reverseGeocodeByPoint, setReverseGeocodeByPoint] = useState<Record<string, MapReverseGeocode>>({});
|
||||||
|
const [reverseGeocoding, setReverseGeocoding] = useState(false);
|
||||||
|
|
||||||
const buildParams = (nextFilters: HistoryFilters, limit: number, offset: number, raw: boolean) => {
|
const buildParams = (nextFilters: HistoryFilters, limit: number, offset: number, raw: boolean) => {
|
||||||
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||||
@@ -507,6 +512,7 @@ export function History({
|
|||||||
setLocationPagination({ currentPage: page, pageSize });
|
setLocationPagination({ currentPage: page, pageSize });
|
||||||
setPlaybackIndex(0);
|
setPlaybackIndex(0);
|
||||||
setPlaybackPlaying(false);
|
setPlaybackPlaying(false);
|
||||||
|
setReverseGeocodeByPoint({});
|
||||||
})
|
})
|
||||||
.catch((error: Error) => Toast.error(error.message))
|
.catch((error: Error) => Toast.error(error.message))
|
||||||
.finally(() => setLoadingLocations(false));
|
.finally(() => setLoadingLocations(false));
|
||||||
@@ -622,6 +628,7 @@ export function History({
|
|||||||
const currentPlaybackIndex = playbackRows.length === 0 ? -1 : Math.min(playbackIndex, playbackRows.length - 1);
|
const currentPlaybackIndex = playbackRows.length === 0 ? -1 : Math.min(playbackIndex, playbackRows.length - 1);
|
||||||
const currentPlayback = currentPlaybackIndex >= 0 ? playbackRows[currentPlaybackIndex] : undefined;
|
const currentPlayback = currentPlaybackIndex >= 0 ? playbackRows[currentPlaybackIndex] : undefined;
|
||||||
const currentPlaybackPointId = currentPlayback ? playbackPointId(currentPlayback, currentPlaybackIndex) : undefined;
|
const currentPlaybackPointId = currentPlayback ? playbackPointId(currentPlayback, currentPlaybackIndex) : undefined;
|
||||||
|
const currentPlaybackAddress = currentPlaybackPointId ? reverseGeocodeByPoint[currentPlaybackPointId] : undefined;
|
||||||
const playbackAtEnd = playbackRows.length > 0 && currentPlaybackIndex >= playbackRows.length - 1;
|
const playbackAtEnd = playbackRows.length > 0 && currentPlaybackIndex >= playbackRows.length - 1;
|
||||||
const playbackPoints: VehicleMapPoint[] = validLocations.map((row, index) => ({
|
const playbackPoints: VehicleMapPoint[] = validLocations.map((row, index) => ({
|
||||||
id: playbackPointId(row, index),
|
id: playbackPointId(row, index),
|
||||||
@@ -715,6 +722,7 @@ export function History({
|
|||||||
currentPlayback,
|
currentPlayback,
|
||||||
currentPlaybackIndex,
|
currentPlaybackIndex,
|
||||||
playbackCount: playbackRows.length,
|
playbackCount: playbackRows.length,
|
||||||
|
currentAddress: currentPlaybackAddress,
|
||||||
anomalySummary: trajectoryAnomalies
|
anomalySummary: trajectoryAnomalies
|
||||||
}),
|
}),
|
||||||
'轨迹复盘交接包'
|
'轨迹复盘交接包'
|
||||||
@@ -728,6 +736,28 @@ export function History({
|
|||||||
}
|
}
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
};
|
};
|
||||||
|
const resolveCurrentPlaybackAddress = () => {
|
||||||
|
if (!currentPlayback || !currentPlaybackPointId || !hasValidCoordinate(currentPlayback)) {
|
||||||
|
Toast.warning('当前回放点没有有效坐标');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentPlaybackAddress) {
|
||||||
|
Toast.info('当前回放点地址已解析');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
longitude: String(currentPlayback.longitude),
|
||||||
|
latitude: String(currentPlayback.latitude)
|
||||||
|
});
|
||||||
|
setReverseGeocoding(true);
|
||||||
|
api.reverseGeocode(params)
|
||||||
|
.then((address) => {
|
||||||
|
setReverseGeocodeByPoint((current) => ({ ...current, [currentPlaybackPointId]: address }));
|
||||||
|
Toast.success('已解析当前点地址');
|
||||||
|
})
|
||||||
|
.catch((error: Error) => Toast.error(error.message))
|
||||||
|
.finally(() => setReverseGeocoding(false));
|
||||||
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playbackPlaying || playbackRows.length <= 1) return undefined;
|
if (!playbackPlaying || playbackRows.length <= 1) return undefined;
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
@@ -913,6 +943,12 @@ export function History({
|
|||||||
<Tag color="green">{formatNumber(currentPlayback.speedKmh, ' km/h')}</Tag>
|
<Tag color="green">{formatNumber(currentPlayback.speedKmh, ' km/h')}</Tag>
|
||||||
<Tag color="blue">{formatNumber(currentPlayback.totalMileageKm, ' km')}</Tag>
|
<Tag color="blue">{formatNumber(currentPlayback.totalMileageKm, ' km')}</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
|
<div className="vp-playback-address">
|
||||||
|
<Tag color={currentPlaybackAddress ? 'green' : 'grey'}>{currentPlaybackAddress?.provider || '地址未解析'}</Tag>
|
||||||
|
<Typography.Text type={currentPlaybackAddress ? 'secondary' : 'tertiary'} ellipsis={{ showTooltip: true }}>
|
||||||
|
{currentPlaybackAddress?.formattedAddress || `${currentPlayback.longitude}, ${currentPlayback.latitude}`}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -937,6 +973,7 @@ export function History({
|
|||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button size="small" disabled={currentPlaybackIndex <= 0} onClick={() => movePlayback(-1)}>上一点</Button>
|
<Button size="small" disabled={currentPlaybackIndex <= 0} onClick={() => movePlayback(-1)}>上一点</Button>
|
||||||
<Button size="small" disabled={currentPlaybackIndex >= playbackRows.length - 1} onClick={() => movePlayback(1)}>下一点</Button>
|
<Button size="small" disabled={currentPlaybackIndex >= playbackRows.length - 1} onClick={() => movePlayback(1)}>下一点</Button>
|
||||||
|
<Button size="small" loading={reverseGeocoding} disabled={!hasValidCoordinate(currentPlayback)} onClick={resolveCurrentPlaybackAddress}>解析地址</Button>
|
||||||
<Button size="small" disabled={!canOpenVehicle(currentPlayback.vin)} onClick={openPlaybackVehicle}>回放点车辆服务</Button>
|
<Button size="small" disabled={!canOpenVehicle(currentPlayback.vin)} onClick={openPlaybackVehicle}>回放点车辆服务</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1090,6 +1090,16 @@ button.vp-realtime-command-item:focus-visible {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vp-playback-address {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--vp-border);
|
||||||
|
border-radius: var(--vp-radius);
|
||||||
|
background: #fbfcff;
|
||||||
|
}
|
||||||
|
|
||||||
.vp-playback-timeline {
|
.vp-playback-timeline {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -6146,6 +6146,82 @@ test('opens amap marker when trajectory has one valid point', async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolves current trajectory point address through server-side amap api', async () => {
|
||||||
|
window.history.replaceState(null, '', '/#/history?keyword=VIN-TRACK-ADDRESS&protocol=JT808');
|
||||||
|
const writeText = vi.fn(() => Promise.resolve());
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText }
|
||||||
|
});
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||||
|
const path = String(input);
|
||||||
|
if (path.includes('/api/history/locations')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{ vin: 'VIN-TRACK-ADDRESS', plate: '粤A地址1', protocol: 'JT808', longitude: 113.2, latitude: 23.1, speedKmh: 10, socPercent: 0, totalMileageKm: 100, lastSeen: '2026-07-03 10:00:00', deviceTime: '2026-07-03 10:00:00', serverTime: '2026-07-03 10:00:01' },
|
||||||
|
{ vin: 'VIN-TRACK-ADDRESS', plate: '粤A地址1', protocol: 'JT808', longitude: 113.3, latitude: 23.2, speedKmh: 42, socPercent: 0, totalMileageKm: 112.4, lastSeen: '2026-07-03 10:10:00', deviceTime: '2026-07-03 10:10:00', serverTime: '2026-07-03 10:10:01' }
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
if (path.includes('/api/map/reverse-geocode')) {
|
||||||
|
expect(path).toContain('longitude=113.2');
|
||||||
|
expect(path).toContain('latitude=23.1');
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
provider: 'AMap',
|
||||||
|
longitude: 113.2,
|
||||||
|
latitude: 23.1,
|
||||||
|
formattedAddress: '广东省广州市天河区测试路',
|
||||||
|
province: '广东省',
|
||||||
|
city: '广州市',
|
||||||
|
district: '天河区',
|
||||||
|
adcode: '440106'
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
if (path.includes('/api/history/raw-frames/query')) {
|
||||||
|
expect(init?.method).toBe('POST');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { items: [], total: 0, limit: 10, offset: 0 },
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('当前回放点')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('地址未解析')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '解析地址' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('广东省广州市天河区测试路')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('AMap')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByText('复制轨迹复盘包'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('当前点地址:广东省广州市天河区测试路'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('controls trajectory playback current point from history locations', async () => {
|
test('controls trajectory playback current point from history locations', async () => {
|
||||||
window.history.replaceState(null, '', '/#/history?keyword=VIN-TRACK-CONTROL&protocol=JT808');
|
window.history.replaceState(null, '', '/#/history?keyword=VIN-TRACK-CONTROL&protocol=JT808');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user