From da3a639dd899eee45033ecc04f4d789852ee10d1 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 21:25:32 +0800 Subject: [PATCH] feat(platform): add amap reverse geocode for trajectory --- .../apps/api/internal/app/server.go | 154 ++++++++++++++++++ .../apps/api/internal/app/server_test.go | 88 ++++++++++ .../apps/web/src/api/client.test.ts | 25 +++ .../apps/web/src/api/client.ts | 2 + .../apps/web/src/api/types.ts | 12 ++ .../apps/web/src/pages/History.tsx | 39 ++++- .../apps/web/src/styles/global.css | 10 ++ .../apps/web/src/test/App.test.tsx | 76 +++++++++ 8 files changed, 405 insertions(+), 1 deletion(-) diff --git a/vehicle-data-platform/apps/api/internal/app/server.go b/vehicle-data-platform/apps/api/internal/app/server.go index 57224d21..0a95c95d 100644 --- a/vehicle-data-platform/apps/api/internal/app/server.go +++ b/vehicle-data-platform/apps/api/internal/app/server.go @@ -8,8 +8,10 @@ import ( "fmt" "io" "log" + "math" "net/http" "net/url" + "strconv" "strings" "sync" "time" @@ -61,6 +63,7 @@ func NewServer(cfg config.Config) http.Handler { PlatformRelease: strings.TrimSpace(cfg.PlatformRelease), })) handler := static.Handler(cfg.StaticDir, api) + handler = withAMapReverseGeocodeAPI(handler, cfg, "https://restapi.amap.com", http.DefaultClient) handler = withAppConfig(handler, cfg) handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient) return withRequestTimeout(handler, cfg.RequestTimeout) @@ -195,6 +198,157 @@ func amapProxyURL(upstreams amapProxyUpstreams, serviceHost string, securityCode 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 { if timeout <= 0 { return next 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 abd3fb93..eadc380e 100644 --- a/vehicle-data-platform/apps/api/internal/app/server_test.go +++ b/vehicle-data-platform/apps/api/internal/app/server_test.go @@ -156,3 +156,91 @@ func TestAMapSecurityProxyRoutesKnownMapResources(t *testing.T) { 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()) + } +} diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index 49105ff1..aceb6bd2 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -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); }); +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 () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 3b2f7337..84385207 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -4,6 +4,7 @@ import type { DashboardSummary, HistoryLocationRow, MileageSummary, + MapReverseGeocode, OnlineStatisticsSummary, OnlineVehicleStatusRow, OpsHealth, @@ -115,5 +116,6 @@ export const api = { alertEventSummary: (params = new URLSearchParams()) => request(`/api/alert-events/summary?${params.toString()}`), alertEvents: (params = new URLSearchParams()) => request>(`/api/alert-events?${params.toString()}`), alertEventNotificationPlan: (params = new URLSearchParams()) => request(`/api/alert-events/notification-plan?${params.toString()}`), + reverseGeocode: (params = new URLSearchParams()) => request(`/api/map/reverse-geocode?${params.toString()}`), opsHealth: () => request('/api/ops/health') }; diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index feb4b640..ea985306 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -382,6 +382,18 @@ export interface RuntimeInfo { 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 { items: T[]; total: number; diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index 7d5cece3..0601fb77 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -2,7 +2,7 @@ import { Button, Card, Form, Select, SideSheet, Space, Table, Tabs, Tag, Toast, import { IconCopy, 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 type { HistoryLocationRow, MapReverseGeocode, Page, RawFrameRow } from '../api/types'; import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap'; import { PageHeader } from '../components/PageHeader'; import { isAMapConfigured } from '../config/appConfig'; @@ -351,6 +351,7 @@ function trajectoryReviewPackageText({ currentPlayback, currentPlaybackIndex, playbackCount, + currentAddress, anomalySummary }: { filters: HistoryFilters; @@ -368,6 +369,7 @@ function trajectoryReviewPackageText({ currentPlayback?: HistoryLocationRow; currentPlaybackIndex: number; playbackCount: number; + currentAddress?: MapReverseGeocode; anomalySummary: TrajectoryAnomalySummary; }) { const vehicle = filters.keyword?.trim() || ''; @@ -390,6 +392,7 @@ function trajectoryReviewPackageText({ `起点:${firstLocation?.deviceTime || firstLocation?.serverTime || '-'}`, `终点:${lastLocation?.deviceTime || lastLocation?.serverTime || '-'}`, `当前回放点:${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()}`, `最大断点:${formatDurationMinutes(anomalySummary.maxGapMinutes)}`, `最大里程回退:${formatNumber(anomalySummary.maxMileageRollbackKm, ' km')}`, @@ -475,6 +478,8 @@ export function History({ const [playbackIndex, setPlaybackIndex] = useState(0); const [playbackPlaying, setPlaybackPlaying] = useState(false); const [playbackSpeedMs, setPlaybackSpeedMs] = useState(1200); + const [reverseGeocodeByPoint, setReverseGeocodeByPoint] = useState>({}); + const [reverseGeocoding, setReverseGeocoding] = useState(false); const buildParams = (nextFilters: HistoryFilters, limit: number, offset: number, raw: boolean) => { const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); @@ -507,6 +512,7 @@ export function History({ setLocationPagination({ currentPage: page, pageSize }); setPlaybackIndex(0); setPlaybackPlaying(false); + setReverseGeocodeByPoint({}); }) .catch((error: Error) => Toast.error(error.message)) .finally(() => setLoadingLocations(false)); @@ -622,6 +628,7 @@ export function History({ const currentPlaybackIndex = playbackRows.length === 0 ? -1 : Math.min(playbackIndex, playbackRows.length - 1); const currentPlayback = currentPlaybackIndex >= 0 ? playbackRows[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 playbackPoints: VehicleMapPoint[] = validLocations.map((row, index) => ({ id: playbackPointId(row, index), @@ -715,6 +722,7 @@ export function History({ currentPlayback, currentPlaybackIndex, playbackCount: playbackRows.length, + currentAddress: currentPlaybackAddress, anomalySummary: trajectoryAnomalies }), '轨迹复盘交接包' @@ -728,6 +736,28 @@ export function History({ } 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(() => { if (!playbackPlaying || playbackRows.length <= 1) return undefined; const timer = window.setInterval(() => { @@ -913,6 +943,12 @@ export function History({ {formatNumber(currentPlayback.speedKmh, ' km/h')} {formatNumber(currentPlayback.totalMileageKm, ' km')} +
+ {currentPlaybackAddress?.provider || '地址未解析'} + + {currentPlaybackAddress?.formattedAddress || `${currentPlayback.longitude}, ${currentPlayback.latitude}`} + +
+ { ); }); +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(); + + 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 () => { window.history.replaceState(null, '', '/#/history?keyword=VIN-TRACK-CONTROL&protocol=JT808'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {