开发完成等待测试

This commit is contained in:
sladro 2026-01-22 14:11:52 +08:00
parent 7b436316f1
commit 5309b98bcf
6 changed files with 780 additions and 0 deletions

View File

@ -0,0 +1,153 @@
package com.qaup.collision.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.qaup.common.constant.HttpStatus;
import com.qaup.common.core.domain.AjaxResult;
import com.qaup.collision.datacollector.dao.DataCollectorDao;
import com.qaup.collision.datacollector.model.dto.BatteryStatusDTO;
import com.qaup.collision.datacollector.model.dto.MissionContextDTO;
import com.qaup.collision.datacollector.model.dto.UniversalVehicleStatusDTO;
import com.qaup.collision.datacollector.service.VehicleManagerCacheService;
import com.qaup.collision.datacollector.service.VehicleManagerQueryService;
import com.qaup.collision.datacollector.service.VehicleStatusAggregationService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/vehicle-manager")
@RequiredArgsConstructor
public class VehicleManagerApiController {
private final VehicleManagerQueryService queryService;
private final VehicleManagerCacheService cacheService;
private final VehicleStatusAggregationService vehicleStatusAggregationService;
private final DataCollectorDao dataCollectorDao;
@GetMapping("/vehicles/summary")
public AjaxResult getVehicleSummary(@RequestParam(defaultValue = "30000") long staleMillis) {
VehicleManagerQueryService.VehicleSummary summary = queryService.getVehicleSummary(staleMillis);
return AjaxResult.success(summary).put("timestamp", System.currentTimeMillis());
}
@GetMapping("/vehicles/{vehicleId}")
public AjaxResult getVehicleSnapshot(@PathVariable String vehicleId) {
Map<String, Object> snapshot = queryService.getVehicleSnapshot(vehicleId);
return AjaxResult.success(snapshot).put("timestamp", System.currentTimeMillis());
}
@GetMapping("/vehicles/{vehicleId}/battery")
public AjaxResult getVehicleBattery(@PathVariable String vehicleId) {
UniversalVehicleStatusDTO status = dataCollectorDao.getVehicleManagerStatus(vehicleId);
BatteryStatusDTO batteryStatus = status != null ? status.getBatteryStatus() : null;
if (batteryStatus == null) {
batteryStatus = BatteryStatusDTO.builder()
.mainBattery(BatteryStatusDTO.MainBatteryDTO.builder()
.chargeLevel(null)
.voltage(0.0)
.current(0.0)
.temperature(0.0)
.chargingStatus("DISCHARGING")
.build())
.build();
} else if (batteryStatus.getMainBattery() == null) {
batteryStatus.setMainBattery(BatteryStatusDTO.MainBatteryDTO.builder()
.chargeLevel(null)
.voltage(0.0)
.current(0.0)
.temperature(0.0)
.chargingStatus("DISCHARGING")
.build());
}
Map<String, Object> data = new HashMap<>();
data.put("vehicleId", vehicleId);
data.put("batteryStatus", batteryStatus);
data.put("source", "vehicle_manager_http");
data.put("upstreamTimestamp", status != null ? status.getOperationalStatus() != null ? status.getOperationalStatus().getLastHeartbeat() : null : null);
return AjaxResult.success(data).put("timestamp", System.currentTimeMillis());
}
@GetMapping("/vehicles/{vehicleId}/path")
public AjaxResult getVehiclePath(@PathVariable String vehicleId) {
VehicleManagerCacheService.CacheEntry entry = cacheService.getVehiclePath(vehicleId);
Map<String, Object> data = new HashMap<>();
data.put("vehicleId", vehicleId);
if (entry != null && entry.getData() != null && entry.getData().has("path")) {
JsonNode rawPath = entry.getData().get("path");
data.put("rawPath", rawPath);
data.put("waypoints", convertRawPathToWaypoints(rawPath));
data.put("source", "ws_cache");
return AjaxResult.success(data).put("timestamp", System.currentTimeMillis());
}
UniversalVehicleStatusDTO status = vehicleStatusAggregationService.getAggregatedStatus(vehicleId);
if (status == null || status.getMissionContext() == null) {
return AjaxResult.error(HttpStatus.NOT_FOUND, "车辆路径不可用").put("timestamp", System.currentTimeMillis());
}
MissionContextDTO missionContext = status.getMissionContext();
data.put("rawPath", null);
data.put("waypoints", missionContext.getWaypoints() != null ? missionContext.getWaypoints() : List.of());
data.put("source", "http_fallback");
return AjaxResult.success(data).put("timestamp", System.currentTimeMillis());
}
@GetMapping("/charging-stations")
public AjaxResult getChargingStations() {
return AjaxResult.success(List.of()).put("timestamp", System.currentTimeMillis());
}
private List<MissionContextDTO.WaypointDTO> convertRawPathToWaypoints(JsonNode rawPath) {
if (rawPath == null || !rawPath.isArray()) {
return List.of();
}
List<MissionContextDTO.WaypointDTO> waypoints = new ArrayList<>();
int index = 1;
for (JsonNode node : rawPath) {
Double x = getDouble(node, "x");
Double y = getDouble(node, "y");
waypoints.add(MissionContextDTO.WaypointDTO.builder()
.waypointId(String.valueOf(index++))
.longitude(x)
.latitude(y)
.status("PENDING")
.build());
}
return waypoints;
}
private Double getDouble(JsonNode node, String field) {
if (node == null || node.isMissingNode()) {
return null;
}
JsonNode value = node.get(field);
if (value == null || value.isNull()) {
return null;
}
if (value.isNumber()) {
return value.asDouble();
}
if (value.isTextual()) {
try {
return Double.parseDouble(value.asText());
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
}

View File

@ -0,0 +1,125 @@
package com.qaup.collision.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.qaup.common.constant.HttpStatus;
import com.qaup.common.core.domain.AjaxResult;
import com.qaup.collision.task.InMemoryTaskStore;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/vehicle-manager/tasks")
@Validated
@RequiredArgsConstructor
public class VehicleTaskController {
private final InMemoryTaskStore taskStore;
@Data
public static class TaskUpsertRequest {
@NotBlank
private String name;
@NotBlank
private String type;
private Integer priority;
private JsonNode payload;
}
@GetMapping
public AjaxResult list(@RequestParam(required = false, defaultValue = "1") int pageNum,
@RequestParam(required = false, defaultValue = "10") int pageSize) {
List<InMemoryTaskStore.Task> all = taskStore.listAll();
int total = all.size();
int fromIndex = Math.max(0, (pageNum - 1) * pageSize);
int toIndex = Math.min(total, fromIndex + pageSize);
List<InMemoryTaskStore.Task> page = fromIndex >= total ? List.of() : all.subList(fromIndex, toIndex);
Map<String, Object> data = new HashMap<>();
data.put("total", total);
data.put("pageNum", pageNum);
data.put("pageSize", pageSize);
data.put("rows", page);
return AjaxResult.success(data).put("timestamp", System.currentTimeMillis());
}
@GetMapping("/{id}")
public AjaxResult get(@PathVariable long id) {
InMemoryTaskStore.Task task = taskStore.get(id);
if (task == null) {
return AjaxResult.error(HttpStatus.NOT_FOUND, "任务不存在").put("timestamp", System.currentTimeMillis());
}
return AjaxResult.success(task).put("timestamp", System.currentTimeMillis());
}
@PostMapping
public AjaxResult create(@Valid @RequestBody TaskUpsertRequest request) {
InMemoryTaskStore.Task task = taskStore.create(
request.getName(),
request.getType(),
request.getPriority(),
request.getPayload()
);
return AjaxResult.success(task).put("timestamp", System.currentTimeMillis());
}
@PutMapping("/{id}")
public AjaxResult update(@PathVariable long id, @Valid @RequestBody TaskUpsertRequest request) {
InMemoryTaskStore.Task task = taskStore.update(
id,
request.getName(),
request.getType(),
request.getPriority(),
request.getPayload()
);
if (task == null) {
return AjaxResult.error(HttpStatus.NOT_FOUND, "任务不存在").put("timestamp", System.currentTimeMillis());
}
return AjaxResult.success(task).put("timestamp", System.currentTimeMillis());
}
@DeleteMapping("/{ids}")
public AjaxResult delete(@PathVariable String ids) {
List<Long> idList = parseIds(ids);
int removed = taskStore.deleteMany(idList);
Map<String, Object> data = new HashMap<>();
data.put("removed", removed);
data.put("requested", idList.size());
return AjaxResult.success(data).put("timestamp", System.currentTimeMillis());
}
private List<Long> parseIds(String ids) {
if (ids == null || ids.isBlank()) {
return List.of();
}
return List.of(ids.split(","))
.stream()
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(s -> {
try {
return Long.parseLong(s);
} catch (NumberFormatException e) {
return null;
}
})
.filter(v -> v != null)
.collect(Collectors.toList());
}
}

View File

@ -0,0 +1,190 @@
package com.qaup.collision.datacollector.service;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Builder;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class VehicleManagerQueryService {
private final VehicleManagerCacheService cacheService;
@Data
@Builder
public static class VehicleSummary {
private int totalCount;
private int onlineCount;
private int offlineCount;
private int emergencyCount;
private int faultCount;
private int unknownCount;
private long staleMillis;
private long timestamp;
}
public VehicleSummary getVehicleSummary(long staleMillis) {
long now = System.currentTimeMillis();
Set<String> vehicleIds = cacheService.getKnownVehicleIds();
int online = 0;
int offline = 0;
int emergency = 0;
int fault = 0;
int unknown = 0;
for (String vehicleId : vehicleIds) {
VehicleManagerCacheService.CacheEntry loginEntry = cacheService.getVehicleLoginStatus(vehicleId);
boolean loginStale = isStale(loginEntry, now, staleMillis);
if (loginEntry == null) {
long lastSeenAt = getLastSeenAt(vehicleId);
if (now - lastSeenAt > staleMillis) {
offline++;
} else {
unknown++;
}
} else {
if (loginStale) {
offline++;
} else {
String loginStatus = getText(loginEntry.getData(), "loginStatus");
if ("login".equalsIgnoreCase(loginStatus)) {
online++;
} else {
offline++;
}
}
}
VehicleManagerCacheService.CacheEntry suspendEntry = cacheService.getVehicleSuspend(vehicleId);
if (!isStale(suspendEntry, now, staleMillis)) {
Integer suspendStatus = getInt(suspendEntry != null ? suspendEntry.getData() : null, "suspendStatus");
if (suspendStatus != null && suspendStatus != 0) {
emergency++;
}
}
VehicleManagerCacheService.CacheEntry fmsEntry = cacheService.getVehicleFmsMessage(vehicleId);
if (!isStale(fmsEntry, now, staleMillis)) {
if (isFault(fmsEntry != null ? fmsEntry.getData() : null)) {
fault++;
}
}
}
return VehicleSummary.builder()
.totalCount(vehicleIds.size())
.onlineCount(online)
.offlineCount(offline)
.emergencyCount(emergency)
.faultCount(fault)
.unknownCount(unknown)
.staleMillis(staleMillis)
.timestamp(now)
.build();
}
public Map<String, Object> getVehicleSnapshot(String vehicleId) {
Map<String, Object> data = new HashMap<>();
data.put("vehicleId", vehicleId);
VehicleManagerCacheService.CacheEntry details = cacheService.getVehicleDetails(vehicleId);
VehicleManagerCacheService.CacheEntry login = cacheService.getVehicleLoginStatus(vehicleId);
VehicleManagerCacheService.CacheEntry chassis = cacheService.getVehicleChassis(vehicleId);
VehicleManagerCacheService.CacheEntry order = cacheService.getVehicleOrder(vehicleId);
VehicleManagerCacheService.CacheEntry suspend = cacheService.getVehicleSuspend(vehicleId);
VehicleManagerCacheService.CacheEntry tailer = cacheService.getVehicleTailer(vehicleId);
VehicleManagerCacheService.CacheEntry position = cacheService.getVehiclePosition(vehicleId);
VehicleManagerCacheService.CacheEntry path = cacheService.getVehiclePath(vehicleId);
VehicleManagerCacheService.CacheEntry fms = cacheService.getVehicleFmsMessage(vehicleId);
data.put("details", details != null ? details.getData() : null);
data.put("loginStatus", login != null ? login.getData() : null);
data.put("chassis", chassis != null ? chassis.getData() : null);
data.put("order", order != null ? order.getData() : null);
data.put("suspend", suspend != null ? suspend.getData() : null);
data.put("tailer", tailer != null ? tailer.getData() : null);
data.put("position", position != null ? position.getData() : null);
data.put("path", path != null ? path.getData() : null);
data.put("fmsMessage", fms != null ? fms.getData() : null);
data.put("lastSeenAt", getLastSeenAt(vehicleId));
return data;
}
private long getLastSeenAt(String vehicleId) {
long last = 0L;
last = Math.max(last, getTimestamp(cacheService.getVehicleDetails(vehicleId)));
last = Math.max(last, getTimestamp(cacheService.getVehicleLoginStatus(vehicleId)));
last = Math.max(last, getTimestamp(cacheService.getVehicleChassis(vehicleId)));
last = Math.max(last, getTimestamp(cacheService.getVehicleOrder(vehicleId)));
last = Math.max(last, getTimestamp(cacheService.getVehicleSuspend(vehicleId)));
last = Math.max(last, getTimestamp(cacheService.getVehicleTailer(vehicleId)));
last = Math.max(last, getTimestamp(cacheService.getVehiclePosition(vehicleId)));
last = Math.max(last, getTimestamp(cacheService.getVehiclePath(vehicleId)));
last = Math.max(last, getTimestamp(cacheService.getVehicleFmsMessage(vehicleId)));
return last;
}
private long getTimestamp(VehicleManagerCacheService.CacheEntry entry) {
return entry == null ? 0L : entry.getTimestamp();
}
private boolean isStale(VehicleManagerCacheService.CacheEntry entry, long now, long staleMillis) {
if (entry == null) {
return true;
}
return now - entry.getTimestamp() > staleMillis;
}
private boolean isFault(JsonNode node) {
if (node == null || node.isNull()) {
return false;
}
Integer isActive = getInt(node, "isActive");
if (isActive != null && isActive == 1) {
return true;
}
Integer level = getInt(node, "level");
return level != null && level >= 4;
}
private Integer getInt(JsonNode node, String field) {
if (node == null || node.isMissingNode()) {
return null;
}
JsonNode value = node.get(field);
if (value == null || value.isNull()) {
return null;
}
if (value.isInt() || value.isLong()) {
return value.asInt();
}
if (value.isTextual()) {
try {
return Integer.parseInt(value.asText());
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private String getText(JsonNode node, String field) {
if (node == null || node.isMissingNode()) {
return null;
}
JsonNode value = node.get(field);
if (value == null || value.isNull()) {
return null;
}
return value.asText();
}
}

View File

@ -0,0 +1,89 @@
package com.qaup.collision.task;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Builder;
import lombok.Data;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
@Component
public class InMemoryTaskStore {
private final AtomicLong idGenerator = new AtomicLong(1);
private final Map<Long, Task> tasks = new ConcurrentHashMap<>();
@Data
@Builder
public static class Task {
private Long id;
private String name;
private String type;
private Integer priority;
private JsonNode payload;
private long createdAt;
private long updatedAt;
}
public Task create(String name, String type, Integer priority, JsonNode payload) {
long now = System.currentTimeMillis();
long id = idGenerator.getAndIncrement();
Task task = Task.builder()
.id(id)
.name(name)
.type(type)
.priority(priority)
.payload(payload)
.createdAt(now)
.updatedAt(now)
.build();
tasks.put(id, task);
return task;
}
public Task update(long id, String name, String type, Integer priority, JsonNode payload) {
Task existing = tasks.get(id);
if (existing == null) {
return null;
}
long now = System.currentTimeMillis();
if (name != null) {
existing.setName(name);
}
if (type != null) {
existing.setType(type);
}
existing.setPriority(priority);
existing.setPayload(payload);
existing.setUpdatedAt(now);
return existing;
}
public Task get(long id) {
return tasks.get(id);
}
public List<Task> listAll() {
List<Task> list = new ArrayList<>(tasks.values());
list.sort(Comparator.comparing(Task::getId).reversed());
return list;
}
public int deleteMany(List<Long> ids) {
int removed = 0;
for (Long id : ids) {
if (id == null) {
continue;
}
if (tasks.remove(id) != null) {
removed++;
}
}
return removed;
}
}

View File

@ -119,6 +119,7 @@ public class SecurityConfig
// 前端 API 接口允许匿名访问测试阶段用
.requestMatchers("/system/**").permitAll()
.requestMatchers("/api/v1/vehicles/**").permitAll()
.requestMatchers("/api/vehicle-manager/**").permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
})

View File

@ -0,0 +1,222 @@
# 前端开发文档:无人车 VehicleManager HTTP 接口
本文档描述本次新增的前端 HTTP 接口基于“接口文档1/2”无人车数据整合
## 1. 基本信息
- **服务地址**`http://localhost:8080`
- **接口前缀**`/api/vehicle-manager`
- **鉴权**:当前已在后端放开 `permitAll`(无需 token 即可访问该前缀)。后续如恢复鉴权,前端需要按系统登录流程携带 `Authorization`
- **返回格式**RuoYi 风格 `AjaxResult`
- `code`: 200 成功
- `msg`: 提示信息
- `data`: 业务数据
- `timestamp`: 后端附加的时间戳(毫秒)
## 2. 接口列表
### 2.1 车辆统计汇总
**GET** `/api/vehicle-manager/vehicles/summary`
#### Query
- `staleMillis`(可选,默认 `30000`):缓存过期阈值(毫秒)。超过该阈值会被视为离线/不可用。
#### Response.data
```json
{
"totalCount": 10,
"onlineCount": 8,
"offlineCount": 2,
"emergencyCount": 1,
"faultCount": 1,
"unknownCount": 0,
"staleMillis": 30000,
"timestamp": 1730000000000
}
```
#### 说明
- online 判定:`VehicleLoginStatus.loginStatus == "login"` 且未过期
- emergency 判定:`VehicleSuspendReport.suspendStatus != 0` 且未过期
- fault故障临时规则
- `GetFmsMessage.isActive == 1`(字段存在时)或
- `GetFmsMessage.level >= 4`
---
### 2.2 车辆详情快照(缓存聚合)
**GET** `/api/vehicle-manager/vehicles/{vehicleId}`
#### Path
- `vehicleId`:车号(例如 `AET01`
#### Response.data
```json
{
"vehicleId": "AET01",
"details": { "messageName": "VehicleDetails", "vehicleId": "AET01", "vehicleType": "QTRUCK" },
"loginStatus": { "messageName": "VehicleLoginStatus", "vehicleId": "AET01", "loginStatus": "login" },
"position": { "messageName": "VehiclePositionInfo", "vehicleId": "AET01", "x": 6.12, "y": 101.70 },
"chassis": { "messageName": "VehicleChassisInfo", "vehicleId": "AET01", "sys_info": { "state_info": { "d_speed_kmph": 0, "d_battery_available": "0.0" } } },
"suspend": { "messageName": "VehicleSuspendReport", "vehicleId": "AET01", "suspendStatus": 0 },
"tailer": { "messageName": "VehicleTailerNum", "vehicleId": "AET01", "tailerNum": "0" },
"order": { "messageName": "VehicleOrderInfo", "vehicleId": "AET01", "businessKey": "1767..." },
"path": { "messageName": "NaviShortPathReport", "vehicleId": "AET01", "path": [ {"x": 1, "y": 2, "theta": 0} ] },
"fmsMessage": { "messageName": "GetFmsMessage", "vehicleID": "AET01", "level": 4, "description": "..." },
"lastSeenAt": 1730000000000
}
```
#### 说明
- 该接口只读后端内存缓存,不会额外打上游 HTTP。
- 字段可能为 `null`(例如 WS 尚未推送/缓存未命中)。
---
### 2.3 电池信息通过接口文档2上游 HTTP 获取后转发)
**GET** `/api/vehicle-manager/vehicles/{vehicleId}/battery`
#### 行为
- 后端会调用上游接口文档2`POST /api/vehicle_manager/v1/vehicles/{vehicleId}/status`
- 并把其中的 `batteryStatus` 转发给前端
#### Response.data
```json
{
"vehicleId": "AET01",
"batteryStatus": {
"mainBattery": {
"chargeLevel": 0.0,
"voltage": 0.0,
"current": 0.0,
"temperature": 0.0,
"chargingStatus": "DISCHARGING"
}
},
"source": "vehicle_manager_http",
"upstreamTimestamp": 1767838334301.7153
}
```
#### 说明
- 上游不可达或无数据时后端会返回默认结构voltage/current/temperature=0chargingStatus=DISCHARGING
---
### 2.4 车辆路径
**GET** `/api/vehicle-manager/vehicles/{vehicleId}/path`
#### Response.data
```json
{
"vehicleId": "AET01",
"rawPath": [
{ "x": 6.12613, "y": 101.70456, "theta": -1.52245 }
],
"waypoints": [
{ "waypointId": "1", "longitude": 6.12613, "latitude": 101.70456, "status": "PENDING" }
],
"source": "ws_cache"
}
```
#### 说明
- `source=ws_cache`来自文档1 `NaviShortPathReport.path`
- `source=http_fallback`WS 缓存没有路径时,后端会走聚合服务兜底(从 `missionContext.waypoints` 返回)
---
### 2.5 充电桩(占位接口)
**GET** `/api/vehicle-manager/charging-stations`
#### Response.data
```json
[]
```
---
## 3. 任务 CRUD内存不落库
> 注意:这是“前端任务管理/任务库”的 CRUD数据仅存在于后端内存中服务重启会清空。
### 3.1 列表
**GET** `/api/vehicle-manager/tasks?pageNum=1&pageSize=10`
#### Response.data
```json
{
"total": 1,
"pageNum": 1,
"pageSize": 10,
"rows": [
{
"id": 1,
"name": "测试任务1",
"type": "CARGO_TRANSPORT",
"priority": 3,
"payload": { "note": "from frontend" },
"createdAt": 1730000000000,
"updatedAt": 1730000000000
}
]
}
```
### 3.2 创建
**POST** `/api/vehicle-manager/tasks`
Body:
```json
{
"name": "测试任务1",
"type": "CARGO_TRANSPORT",
"priority": 3,
"payload": {
"note": "from frontend"
}
}
```
### 3.3 详情
**GET** `/api/vehicle-manager/tasks/{id}`
### 3.4 更新
**PUT** `/api/vehicle-manager/tasks/{id}`
Body同创建结构
```json
{
"name": "测试任务1-已更新",
"type": "CARGO_TRANSPORT",
"priority": 5,
"payload": { "note": "updated" }
}
```
### 3.5 删除(支持批量)
**DELETE** `/api/vehicle-manager/tasks/{ids}`
示例:
- 删除单个:`/api/vehicle-manager/tasks/1`
- 删除多个:`/api/vehicle-manager/tasks/1,2,3`
---
## 4. 前端接入建议
1) 页面初始化先调:`/vehicles/summary` 拿到统计。
2) 点击某辆车再调:`/vehicles/{vehicleId}` 拿快照。
3) 电池面板单独调:`/vehicles/{vehicleId}/battery`(该接口会请求上游,避免高频轮询)。
4) 路径面板调:`/vehicles/{vehicleId}/path`。
5) 任务管理页面直接使用 `/tasks` CRUD。