feat(platform): add amap reverse geocode for trajectory
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<QualitySummary>(`/api/alert-events/summary?${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()}`),
|
||||
reverseGeocode: (params = new URLSearchParams()) => request<MapReverseGeocode>(`/api/map/reverse-geocode?${params.toString()}`),
|
||||
opsHealth: () => request<OpsHealth>('/api/ops/health')
|
||||
};
|
||||
|
||||
@@ -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<T> {
|
||||
items: T[];
|
||||
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 { 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<Record<string, MapReverseGeocode>>({});
|
||||
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({
|
||||
<Tag color="green">{formatNumber(currentPlayback.speedKmh, ' km/h')}</Tag>
|
||||
<Tag color="blue">{formatNumber(currentPlayback.totalMileageKm, ' km')}</Tag>
|
||||
</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>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -937,6 +973,7 @@ export function History({
|
||||
<Space wrap>
|
||||
<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" loading={reverseGeocoding} disabled={!hasValidCoordinate(currentPlayback)} onClick={resolveCurrentPlaybackAddress}>解析地址</Button>
|
||||
<Button size="small" disabled={!canOpenVehicle(currentPlayback.vin)} onClick={openPlaybackVehicle}>回放点车辆服务</Button>
|
||||
</Space>
|
||||
<input
|
||||
|
||||
@@ -1090,6 +1090,16 @@ button.vp-realtime-command-item:focus-visible {
|
||||
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 {
|
||||
margin-top: 16px;
|
||||
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 () => {
|
||||
window.history.replaceState(null, '', '/#/history?keyword=VIN-TRACK-CONTROL&protocol=JT808');
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
|
||||
Reference in New Issue
Block a user