feat:指挥中心联调
Co-Authored-By: '刘文鑫' <p-liuwx1@xiaopeng.com>
| | |
| | | VITE_SSO_HOST = |
| | | VITE_SSO_APP_ID = RoboTaxi-admin-predeploy |
| | | VITE_SSO_REDIRECT_URL = |
| | | |
| | | # 指挥中心 Socket.IO 地址(为空时使用当前域名) |
| | | VITE_COMMAND_CENTER_SOCKET_URL = |
| New file |
| | |
| | | # 指挥中心接口文档(commandCenter)
|
| | |
|
| | | > 基于 `feat-dashboard-lianjd` 分支对比 `develop` 的新增接口整理,模块 `xp-robotaxi-admin-boot`。
|
| | | > 给前端的接口分两部分:**REST**(4 个 GET 接口) + **Socket.IO**(实时推送)。
|
| | |
|
| | | ---
|
| | |
|
| | | ## 一、基础信息
|
| | |
|
| | | | 项 | 值 |
|
| | | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
| | | | 后端模块 | `xp-robotaxi-admin-boot` |
|
| | | | 网关前缀 | 管理后台域名 + `/api/v1/commandCenter`(与 cockpit 同套 SSO 鉴权) |
|
| | | | 鉴权方式 | 管理后台 SSO Token(`userType=system`),由网关 `AuthFilter` 校验后注入 Header;REST 通过 `@RequiresPermissions("commandCenter:view")` 二次校验 |
|
| | | | 统一响应包装 | `R<T> = { code: number, msg: string, data: T }`,`code=200` 表示成功 |
|
| | |
|
| | | ---
|
| | |
|
| | | ## 二、REST 接口
|
| | |
|
| | | ### 2.1 查询单车详情
|
| | |
|
| | | - **路径**:`GET /api/v1/commandCenter/vehicle/detail`
|
| | | - **权限**:`commandCenter:view`
|
| | | - **入参**(Query):
|
| | |
|
| | | | 字段 | 类型 | 必填 | 说明 |
|
| | | | ----------- | ------ | ---- | ------- |
|
| | | | `vehicleId` | `Long` | 是 | 车辆 ID |
|
| | |
|
| | | - **返回**:`R<CommandCenterVehicleDetailVo>` — 见 [4.1](#41-commandcentervehicledetailvo)
|
| | | - **示例**:
|
| | |
|
| | | ```http
|
| | | GET /api/v1/commandCenter/vehicle/detail?vehicleId=10086
|
| | | ```
|
| | |
|
| | | ```json
|
| | | {
|
| | | "code": 200,
|
| | | "msg": "操作成功",
|
| | | "data": {
|
| | | "vehicleId": 10086,
|
| | | "vin": "LXP****",
|
| | | "plateNumber": "粤B-XXXXX",
|
| | | "monitorGroup": "HAS_ORDER",
|
| | | "monitorStatus": "接驾中",
|
| | | "stateCode": "PICK_UP",
|
| | | "currentElectricity": 76,
|
| | | "todayCompletedOrders": 12,
|
| | | "routeInfo": {
|
| | | "startLon": 113.93,
|
| | | "startLat": 22.53,
|
| | | "endLon": 113.95,
|
| | | "endLat": 22.55,
|
| | | "startAddress": "XX 大厦",
|
| | | "endAddress": "XX 公园",
|
| | | "eta": 480
|
| | | },
|
| | | "currentLon": 113.94,
|
| | | "currentLat": 22.54
|
| | | }
|
| | | }
|
| | | ```
|
| | |
|
| | | ---
|
| | |
|
| | | ### 2.2 查询城市电子围栏列表
|
| | |
|
| | | - **路径**:`GET /api/v1/commandCenter/fences`
|
| | | - **权限**:`commandCenter:view`
|
| | | - **入参**(Query):
|
| | |
|
| | | | 字段 | 类型 | 必填 | 说明 |
|
| | | | ---------- | -------- | ---- | -------------------------------------------- |
|
| | | | `cityCode` | `String` | 是 | 城市编码(与 `xp_open_city.city_code` 对应) |
|
| | |
|
| | | - **返回**:`R<List<CommandCenterFenceVo>>` — 见 [4.2](#42-commandcenterfencevo)
|
| | |
|
| | | ```http
|
| | | GET /api/v1/commandCenter/fences?cityCode=440300
|
| | | ```
|
| | |
|
| | | ---
|
| | |
|
| | | ### 2.3 查询城市场站列表
|
| | |
|
| | | - **路径**:`GET /api/v1/commandCenter/stations`
|
| | | - **权限**:`commandCenter:view`
|
| | | - **入参**(Query):
|
| | |
|
| | | | 字段 | 类型 | 必填 | 说明 |
|
| | | | ---------- | -------- | ---- | -------- |
|
| | | | `cityCode` | `String` | 是 | 城市编码 |
|
| | |
|
| | | - **返回**:`R<List<XpStation>>` — 见 [4.3](#43-xpstation场站精简)
|
| | |
|
| | | ---
|
| | |
|
| | | ### 2.4 查询已开通城市列表
|
| | |
|
| | | - **路径**:`GET /api/v1/commandCenter/regions`
|
| | | - **权限**:`commandCenter:view`
|
| | | - **入参**:无
|
| | | - **返回**:`R<List<XpOpenCity>>` — 见 [4.4](#44-xpopencity开通城市)
|
| | |
|
| | | ```http
|
| | | GET /api/v1/commandCenter/regions
|
| | | ```
|
| | |
|
| | | ---
|
| | |
|
| | | ## 三、Socket.IO 接口
|
| | |
|
| | | ### 3.1 连接信息
|
| | |
|
| | | | 项 | 值 |
|
| | | | ------------ | ----------------------------------------------- |
|
| | | | URL | `wss://<管理后台域名>/ws`(或 `ws://` for dev) |
|
| | | | Namespace | `/commandCenter` |
|
| | | | 协议 | Socket.IO v4(netty-socketio 2.0.11 服务端) |
|
| | | | 默认命名空间 | **显式拒连**,必须使用 `/commandCenter` |
|
| | | | 心跳 | 服务端默认 `pingInterval=25s, pingTimeout=60s` |
|
| | |
|
| | | 前端示例(socket.io-client v4):
|
| | |
|
| | | ```js
|
| | | import { io } from "socket.io-client";
|
| | |
|
| | | const socket = io("/commandCenter", {
|
| | | path: "/ws",
|
| | | transports: ["websocket"],
|
| | | });
|
| | |
|
| | | socket.on("connect", () => {
|
| | | socket.emit("auth", { token: getSsoToken() });
|
| | | });
|
| | | ```
|
| | |
|
| | | ---
|
| | |
|
| | | ### 3.2 客户端 → 服务端事件
|
| | |
|
| | | #### `auth` — 鉴权(连接后**必须**第一时间发送)
|
| | |
|
| | | 入参:`CommandCenterAuthDto`
|
| | |
|
| | | | 字段 | 类型 | 必填 | 说明 |
|
| | | | ------- | -------- | ---- | -------------------------------- |
|
| | | | `token` | `String` | 是 | 管理后台 SSO Token(同 REST 用) |
|
| | |
|
| | | 服务端会用 token 解析出运维登录用户,要求权限里包含 `commandCenter:view`,否则下发 `auth_failed` 并断开。
|
| | |
|
| | | ---
|
| | |
|
| | | #### `subscribe` — 订阅城市/围栏
|
| | |
|
| | | 入参:`CommandCenterSubscribeDto`
|
| | |
|
| | | | 字段 | 类型 | 必填 | 说明 |
|
| | | | -------------- | -------- | ---- | -------------------------------------------- |
|
| | | | `cityCode` | `String` | 是 | 城市编码,加入房间 `city_{cityCode}` |
|
| | | | `electronicId` | `Long` | 否 | 围栏 ID,传则同时加入 `fence_{electronicId}` |
|
| | |
|
| | | **前置条件**:必须先收到 `auth_success` 后再调用,否则服务端回 `auth_failed`。
|
| | |
|
| | | ---
|
| | |
|
| | | #### `unsubscribe` — 取消订阅
|
| | |
|
| | | 入参同 `subscribe`。**注意**:切换城市时前端必须先 `unsubscribe` 旧城市,再 `subscribe` 新城市,否则会收到多城市数据。
|
| | |
|
| | | ---
|
| | |
|
| | | ### 3.3 服务端 → 客户端事件
|
| | |
|
| | | | 事件名 | 触发时机 | 推送频率 | 负载类型 |
|
| | | | ----------------- | ------------------------------------------ | ------------------------------- | ------------------------------------------------------- |
|
| | | | `auth_success` | `auth` 通过后立即推送一次 | 一次 | `{ userId: string }` |
|
| | | | `auth_failed` | token 为空 / 鉴权失败 / 未鉴权就 subscribe | 一次(随后断开) | `{ message: string }` |
|
| | | | `vehicles` | 已订阅城市的车辆位置/状态聚合 | **每 2 秒** | `VehicleMonitorVo[]` — 见 [4.5](#45-vehiclemonitorvo) |
|
| | | | `kpi` | 城市维度 KPI 指标 | **每 5 秒**(复用 2s 聚合结果) | `CommandCenterKpiVo` — 见 [4.6](#46-commandcenterkpivo) |
|
| | | | `passenger_calls` | 待接单乘客呼单列表 | **每 5 秒** | `PassengerCallVo[]` — 见 [4.7](#47-passengercallvo) |
|
| | |
|
| | | 推送范围:以 Socket.IO Room 维度广播,仅推到加入了 `city_{cityCode}` 房间的客户端;前端可同时订阅多个城市但要自行去重。
|
| | |
|
| | | ---
|
| | |
|
| | | ## 四、数据结构
|
| | |
|
| | | > 字段类型一栏使用 Java 类型,TS 对应:`Long`→`number`(如担心大数溢出可改为 `string`,由前端 JSON 解析时统一处理)、`Double/Float/BigDecimal`→`number`、`LocalDateTime`→ ISO 字符串 `yyyy-MM-dd HH:mm:ss`(+8)。
|
| | |
|
| | | ### 4.1 `CommandCenterVehicleDetailVo`
|
| | |
|
| | | | 字段 | 类型 | 说明 |
|
| | | | ---------------------- | -------------------------- | ------------------------------------------------------------------------ |
|
| | | | `vehicleId` | `Long` | 车辆 ID |
|
| | | | `vin` | `String` | 车架号 |
|
| | | | `plateNumber` | `String` | 车牌号 |
|
| | | | `monitorGroup` | `String` | 监控分组,见 [MonitorGroupEnum](#48-monitorgroupenum) |
|
| | | | `monitorStatus` | `String` | 监控状态文案(如"接驾中") |
|
| | | | `stateCode` | `String` | 状态枚举 code,见 [VehicleMonitorStateEnum](#49-vehiclemonitorstateenum) |
|
| | | | `currentElectricity` | `Integer` | 当前电量 0-100 |
|
| | | | `todayCompletedOrders` | `Integer` | 今日已完成订单数 |
|
| | | | `routeInfo` | `CommandCenterRouteInfoVo` | 当前订单路线信息,无进行中订单时为 `null` |
|
| | | | `currentLon` | `Double` | 当前经度 |
|
| | | | `currentLat` | `Double` | 当前纬度 |
|
| | |
|
| | | ### 4.2 `CommandCenterFenceVo`
|
| | |
|
| | | | 字段 | 类型 | 说明 |
|
| | | | ---------------- | ----------------------------- | --------------------------------------------------- |
|
| | | | `electronicId` | `Long` | 围栏 ID |
|
| | | | `electronicName` | `String` | 围栏名称 |
|
| | | | `cityCode` | `String` | 城市编码 |
|
| | | | `status` | `Integer` | 围栏状态 |
|
| | | | `areas` | `List<XpElectronicFenceArea>` | 围栏区域,含 `fencePoints`("经度*纬度,经度*纬度") |
|
| | |
|
| | | `XpElectronicFenceArea` 关键字段:`id` `electronicId` `areaName` `areaType`(1=运营区 2=禁行区)`areaStatus`(1=启用 2=禁用)`fencePoints`。
|
| | |
|
| | | ### 4.3 `XpStation`(场站,精简)
|
| | |
|
| | | 前端用得到的字段:`id` `stationName` `stationNumber` `status`(0=草稿 1=待启用 2=启用中 3=已停用 4=失效)`cityCode` `cityName` `electronicId` `centerLon` `centerLat` `fencePoints` `stationArea` `sortNo`。完整字段见 [XpStation.java](file:///d:/projects/xp-robotaxi/xp-robotaxi-api/src/main/java/com/xiaopeng/api/model/operation/XpStation.java)。
|
| | |
|
| | | ### 4.4 `XpOpenCity`(开通城市)
|
| | |
|
| | | | 字段 | 类型 | 说明 |
|
| | | | ------------------------------- | --------- | ------------------------------- |
|
| | | | `id` | `Long` | 主键 |
|
| | | | `provinceName` / `provinceCode` | `String` | 省 |
|
| | | | `cityName` / `cityCode` | `String` | 市(前端订阅时使用 `cityCode`) |
|
| | | | `districtName` / `districtCode` | `String` | 区县 |
|
| | | | `status` | `Integer` | 1=启用 0=禁用(接口只返回启用) |
|
| | |
|
| | | ### 4.5 `VehicleMonitorVo`
|
| | |
|
| | | | 字段 | 类型 | 说明 |
|
| | | | -------------------- | --------- | ------------------------------ |
|
| | | | `vehicleId` | `Long` | 车辆 ID |
|
| | | | `vin` | `String` | 车架号 |
|
| | | | `plateNumber` | `String` | 车牌号 |
|
| | | | `monitorGroup` | `String` | 监控分组 code,见 4.8 |
|
| | | | `monitorStatus` | `String` | 监控状态文案 |
|
| | | | `stateCode` | `String` | 状态 code,见 4.9 |
|
| | | | `lon` | `Double` | 经度 |
|
| | | | `lat` | `Double` | 纬度 |
|
| | | | `course` | `Double` | 航向角 |
|
| | | | `currentElectricity` | `Integer` | 当前电量 |
|
| | | | `speed` | `Float` | 当前速度 km/h |
|
| | | | `orderId` | `Long` | 进行中订单 ID(无单为 `null`) |
|
| | | | `charging` | `Boolean` | 是否充电中 |
|
| | | | `positionUpdateTime` | `Long` | 位置上报时间戳(毫秒) |
|
| | |
|
| | | ### 4.6 `CommandCenterKpiVo`
|
| | |
|
| | | | 字段 | 类型 | 说明 |
|
| | | | ---------------------- | ------------ | ------------------------ |
|
| | | | `cruisingCount` | `Integer` | 巡游中车辆数 |
|
| | | | `hasOrderCount` | `Integer` | 有单车辆数(接驾+送驾) |
|
| | | | `pickUpCount` | `Integer` | 接驾中 |
|
| | | | `dropOffCount` | `Integer` | 送驾中 |
|
| | | | `occupiedCount` | `Integer` | 占用中(充电+整备+回场) |
|
| | | | `chargingCount` | `Integer` | 充电中 |
|
| | | | `preparingCount` | `Integer` | 整备中 |
|
| | | | `returningCount` | `Integer` | 回场中 |
|
| | | | `abnormalCount` | `Integer` | 异常(含 ODD 越界) |
|
| | | | `todayCompletedOrders` | `Integer` | 今日完单数 |
|
| | | | `avgOrdersPerVehicle` | `BigDecimal` | 单车平均完单数 |
|
| | |
|
| | | ### 4.7 `PassengerCallVo`
|
| | |
|
| | | | 字段 | 类型 | 说明 |
|
| | | | ----------------------- | -------- | ------------------------------------- |
|
| | | | `orderId` | `Long` | 订单 ID |
|
| | | | `orderNumber` | `String` | 订单号 |
|
| | | | `startLon` / `startLat` | `Double` | 起点 |
|
| | | | `startAddress` | `String` | 起点地址 |
|
| | | | `endLon` / `endLat` | `Double` | 终点 |
|
| | | | `endAddress` | `String` | 终点地址 |
|
| | | | `createTime` | `String` | 下单时间,`yyyy-MM-dd HH:mm:ss`(+8) |
|
| | | | `waitingSeconds` | `Long` | 已等待秒数(用于呼单超时高亮) |
|
| | |
|
| | | ### 4.8 `MonitorGroupEnum`
|
| | |
|
| | | | code | desc |
|
| | | | ----------- | ------ |
|
| | | | `CRUISING` | 巡游中 |
|
| | | | `HAS_ORDER` | 有单 |
|
| | | | `OCCUPIED` | 占用中 |
|
| | | | `ABNORMAL` | 异常 |
|
| | |
|
| | | ### 4.9 `VehicleMonitorStateEnum`
|
| | |
|
| | | | code | desc | 所属分组 |
|
| | | | ----------- | -------- | ----------- |
|
| | | | `CRUISING` | 巡游中 | `CRUISING` |
|
| | | | `PICK_UP` | 接驾中 | `HAS_ORDER` |
|
| | | | `DROP_OFF` | 送驾中 | `HAS_ORDER` |
|
| | | | `CHARGING` | 充电中 | `OCCUPIED` |
|
| | | | `PREPARING` | 整备中 | `OCCUPIED` |
|
| | | | `RETURNING` | 回场中 | `OCCUPIED` |
|
| | | | `ABNORMAL` | 车辆异常 | `ABNORMAL` |
|
| | | | `ODD_OUT` | ODD 越界 | `ABNORMAL` |
|
| | |
|
| | | ---
|
| | |
|
| | | ## 五、前端接入要点(备忘)
|
| | |
|
| | | 1. **登录态**:复用现有管理后台 SSO,无需单独登录。
|
| | | 2. **路由前缀**:所有 REST 接口都在 `/api/v1/commandCenter` 下,与 cockpit 的 `/cockpit/*` 不冲突。
|
| | | 3. **Socket.IO 时序**:`connect` → `emit('auth', { token })` → `on('auth_success')` → `emit('subscribe', { cityCode })` → `on('vehicles' | 'kpi' | 'passenger_calls')`。
|
| | | 4. **切换城市**:先 `unsubscribe` 再 `subscribe`,避免多城市数据叠加。
|
| | | 5. **断线重连**:socket.io-client 自带重连;重连后需重新 `auth` 与 `subscribe`(服务端不保存订阅状态)。
|
| | | 6. **大屏刷新频率**:车辆 2s、KPI/呼单 5s;前端不要再做客户端拉取,纯被动接收推送。
|
| | |
|
| | |
| | | import { local } from '@/utils' |
| | | import { useCommandCenterStore } from '@/store/commandCenter' |
| | | import { createCommandCenterSocket, destroyCommandCenterSocket } from '@/utils/socket' |
| | | import { ONLINE_TIMEOUT_MS, SOCKET_EMIT_EVENT, SOCKET_ON_EVENT } from '@/views/businessOperations/commandCenter/command-center.constants' |
| | | import type { CommandCenterKpiVo, PassengerCallVo, VehicleMonitorVo } from '@/views/businessOperations/commandCenter/command-center.types' |
| | | |
| | | /** |
| | |
| | | |
| | | /** 判断是否在线(已连接且 5 秒内有推送) */ |
| | | const isOnline = computed(() => { |
| | | return store.socketStatus.connected && (Date.now() - store.socketStatus.lastPushAt < 5000) |
| | | return store.socketStatus.connected && (Date.now() - store.socketStatus.lastPushAt < ONLINE_TIMEOUT_MS) |
| | | }) |
| | | |
| | | /** 建立 WebSocket 连接并监听事件 */ |
| | |
| | | socket.on('connect', () => { |
| | | store.socketStatus.connected = true |
| | | const token = local.get('accessToken') |
| | | socket.emit('auth', { token }) |
| | | socket.emit(SOCKET_EMIT_EVENT.AUTH, { token }) |
| | | }) |
| | | |
| | | socket.on('disconnect', () => { |
| | | store.socketStatus.connected = false |
| | | }) |
| | | |
| | | socket.on('auth_success', () => { |
| | | socket.emit('subscribe', { cityCode: store.cityCode }) |
| | | socket.on(SOCKET_ON_EVENT.AUTH_SUCCESS, () => { |
| | | socket.emit(SOCKET_EMIT_EVENT.SUBSCRIBE, { cityCode: store.cityCode }) |
| | | }) |
| | | |
| | | socket.on('auth_failed', (payload: { message: string }) => { |
| | | socket.on(SOCKET_ON_EVENT.AUTH_FAILED, (payload: { message: string }) => { |
| | | window.$message?.error(`鉴权失败:${payload.message}`) |
| | | socket.disconnect() |
| | | }) |
| | | |
| | | socket.on('vehicles', (list: VehicleMonitorVo[]) => { |
| | | socket.on(SOCKET_ON_EVENT.VEHICLES, (list: VehicleMonitorVo[]) => { |
| | | store.vehicles = list |
| | | store.socketStatus.lastPushAt = Date.now() |
| | | }) |
| | | |
| | | socket.on('kpi', (data: CommandCenterKpiVo) => { |
| | | socket.on(SOCKET_ON_EVENT.KPI, (data: CommandCenterKpiVo) => { |
| | | store.kpi = data |
| | | store.socketStatus.lastPushAt = Date.now() |
| | | }) |
| | | |
| | | socket.on('passenger_calls', (list: PassengerCallVo[]) => { |
| | | socket.on(SOCKET_ON_EVENT.PASSENGER_CALLS, (list: PassengerCallVo[]) => { |
| | | store.passengerCalls = list |
| | | store.socketStatus.lastPushAt = Date.now() |
| | | }) |
| | |
| | | * @param cityCode - 城市编码 |
| | | */ |
| | | const subscribeCity = (cityCode: string) => { |
| | | socket.emit('subscribe', { cityCode }) |
| | | socket.emit(SOCKET_EMIT_EVENT.SUBSCRIBE, { cityCode }) |
| | | } |
| | | |
| | | /** |
| | |
| | | * @param cityCode - 城市编码 |
| | | */ |
| | | const unsubscribeCity = (cityCode: string) => { |
| | | socket.emit('unsubscribe', { cityCode }) |
| | | socket.emit(SOCKET_EMIT_EVENT.UNSUBSCRIBE, { cityCode }) |
| | | } |
| | | |
| | | return { |
| | |
| | | import { request } from '@/service/http' |
| | | import type { FenceVo, Region, StationVo, VehicleDetailVo } from '@/views/businessOperations/commandCenter/command-center.types' |
| | | |
| | | /** 指挥中心 API 前缀 */ |
| | | const PREFIX = '/api/v1/commandCenter' |
| | | import type { FenceVo, StationVo, VehicleDetailVo, XpOpenCity } from '@/views/businessOperations/commandCenter/command-center.types' |
| | | import { API_PREFIX } from '@/views/businessOperations/commandCenter/command-center.constants' |
| | | |
| | | /** |
| | | * 获取指挥中心区域列表 |
| | | * @returns 区域列表 |
| | | * 获取已开通城市列表 |
| | | */ |
| | | export const fetchCommandCenterRegions = () => { |
| | | return request.Get<Service.ResponseResult<Region[]>>(`${PREFIX}/regions`) |
| | | return request.Get<Service.ResponseResult<XpOpenCity[]>>(`${API_PREFIX}/regions`) |
| | | } |
| | | |
| | | /** |
| | | * 获取指定城市的围栏列表 |
| | | * @param cityCode - 城市编码 |
| | | * @returns 围栏列表 |
| | | */ |
| | | export const fetchCommandCenterFences = (cityCode: string) => { |
| | | return request.Get<Service.ResponseResult<FenceVo[]>>(`${PREFIX}/fences`, { params: { cityCode } }) |
| | | return request.Get<Service.ResponseResult<FenceVo[]>>(`${API_PREFIX}/fences`, { params: { cityCode } }) |
| | | } |
| | | |
| | | /** |
| | | * 获取指定城市的站点列表 |
| | | * @param cityCode - 城市编码 |
| | | * @returns 站点列表 |
| | | */ |
| | | export const fetchCommandCenterStations = (cityCode: string) => { |
| | | return request.Get<Service.ResponseResult<StationVo[]>>(`${PREFIX}/stations`, { params: { cityCode } }) |
| | | return request.Get<Service.ResponseResult<StationVo[]>>(`${API_PREFIX}/stations`, { params: { cityCode } }) |
| | | } |
| | | |
| | | /** |
| | | * 获取车辆详情 |
| | | * @param vehicleId - 车辆 ID |
| | | * @returns 车辆详情 |
| | | */ |
| | | export const fetchVehicleDetail = (vehicleId: string) => { |
| | | return request.Get<Service.ResponseResult<VehicleDetailVo>>(`${PREFIX}/vehicle/detail`, { params: { vehicleId } }) |
| | | export const fetchVehicleDetail = (vehicleId: number) => { |
| | | return request.Get<Service.ResponseResult<VehicleDetailVo>>(`${API_PREFIX}/vehicle/detail`, { params: { vehicleId } }) |
| | | } |
| | |
| | | import { DEFAULT_CITY_CODE } from '@/views/businessOperations/commandCenter/command-center.constants' |
| | | import { DEFAULT_CITY_CODE, STATIONS_CACHE_TTL } from '@/views/businessOperations/commandCenter/command-center.constants' |
| | | import type { |
| | | CommandCenterKpiVo, |
| | | FenceVo, |
| | | LayerVisibility, |
| | | PassengerCallVo, |
| | | Region, |
| | | SocketStatus, |
| | | StationVo, |
| | | VehicleDetailVo, |
| | | VehicleMonitorVo, |
| | | XpOpenCity, |
| | | } from '@/views/businessOperations/commandCenter/command-center.types' |
| | | import { fetchCommandCenterFences, fetchCommandCenterRegions, fetchCommandCenterStations, fetchVehicleDetail } from '@/service/api/commandCenter' |
| | | |
| | |
| | | /** 指挥中心 Store 状态 */ |
| | | interface CommandCenterState { |
| | | cityCode: string |
| | | regions: Region[] |
| | | regions: XpOpenCity[] |
| | | vehicles: VehicleMonitorVo[] |
| | | kpi: CommandCenterKpiVo | null |
| | | passengerCalls: PassengerCallVo[] |
| | | fences: FenceVo[] |
| | | stations: StationVo[] |
| | | stationsCache: Record<string, StationsCacheEntry> |
| | | selectedVehicleId: string | null |
| | | selectedVehicleId: number | null |
| | | vehicleDetail: VehicleDetailVo | null |
| | | layerVisibility: LayerVisibility |
| | | socketStatus: SocketStatus |
| | | } |
| | | |
| | | /** 站点缓存有效期:10 分钟 */ |
| | | const STATIONS_CACHE_TTL = 10 * 60 * 1000 |
| | | |
| | | /** |
| | | * 指挥中心全局状态管理 |
| | |
| | | * 加载车辆详情 |
| | | * @param vehicleId - 车辆 ID |
| | | */ |
| | | async fetchVehicleDetail(vehicleId: string) { |
| | | async fetchVehicleDetail(vehicleId: number) { |
| | | const { data } = await fetchVehicleDetail(vehicleId) |
| | | if (data) |
| | | this.vehicleDetail = data |
| | |
| | | * 打开车辆详情面板 |
| | | * @param vehicleId - 车辆 ID |
| | | */ |
| | | openVehicleDetail(vehicleId: string) { |
| | | openVehicleDetail(vehicleId: number) { |
| | | this.selectedVehicleId = vehicleId |
| | | }, |
| | | |
| | |
| | | readonly VITE_SSO_REDIRECT_URL: string |
| | | /** 后端服务的环境类型 */ |
| | | readonly MODE: ServiceEnvType |
| | | /** 指挥中心 Socket.IO 地址 */ |
| | | readonly VITE_COMMAND_CENTER_SOCKET_URL?: string |
| | | } |
| | | |
| | | interface ImportMeta { |
| | |
| | | import { io } from 'socket.io-client' |
| | | import type { Socket } from 'socket.io-client' |
| | | import { SOCKET_CONFIG } from '@/views/businessOperations/commandCenter/command-center.constants' |
| | | |
| | | /** 单例 Socket 实例 */ |
| | | let instance: Socket | null = null |
| | |
| | | |
| | | const url = import.meta.env.VITE_COMMAND_CENTER_SOCKET_URL || '' |
| | | |
| | | instance = io(`${url}/commandCenter`, { |
| | | transports: ['websocket', 'polling'], |
| | | instance = io(`${url}${SOCKET_CONFIG.NAMESPACE}`, { |
| | | path: SOCKET_CONFIG.PATH, |
| | | transports: [...SOCKET_CONFIG.TRANSPORTS], |
| | | reconnection: true, |
| | | reconnectionDelay: 2000, |
| | | reconnectionDelayMax: 8000, |
| | | reconnectionDelay: SOCKET_CONFIG.RECONNECTION_DELAY, |
| | | reconnectionDelayMax: SOCKET_CONFIG.RECONNECTION_DELAY_MAX, |
| | | autoConnect: false, |
| | | }) |
| | | |
| | |
| | | ABNORMAL: { color: '#FF4D4F', label: '异常' }, |
| | | } |
| | | |
| | | export const CHARGING_COLOR = '#FAAD14' |
| | | |
| | | export const DEFAULT_CITY_CODE = '440100' |
| | | |
| | | /** 指挥中心 API 路径前缀 */ |
| | | export const API_PREFIX = '/api/v1/commandCenter' |
| | | |
| | | /** Socket.IO 配置 */ |
| | | export const SOCKET_CONFIG = { |
| | | NAMESPACE: '/commandCenter', |
| | | PATH: '/ws', |
| | | TRANSPORTS: ['websocket'] as const, |
| | | RECONNECTION_DELAY: 2000, |
| | | RECONNECTION_DELAY_MAX: 8000, |
| | | } as const |
| | | |
| | | /** Socket.IO 客户端 → 服务端事件名 */ |
| | | export const SOCKET_EMIT_EVENT = { |
| | | AUTH: 'auth', |
| | | SUBSCRIBE: 'subscribe', |
| | | UNSUBSCRIBE: 'unsubscribe', |
| | | } as const |
| | | |
| | | /** Socket.IO 服务端 → 客户端事件名 */ |
| | | export const SOCKET_ON_EVENT = { |
| | | AUTH_SUCCESS: 'auth_success', |
| | | AUTH_FAILED: 'auth_failed', |
| | | VEHICLES: 'vehicles', |
| | | KPI: 'kpi', |
| | | PASSENGER_CALLS: 'passenger_calls', |
| | | } as const |
| | | |
| | | /** 在线判断阈值(毫秒),超过此时间无推送视为离线 */ |
| | | export const ONLINE_TIMEOUT_MS = 5000 |
| | | |
| | | /** 车辆监控状态枚举 code */ |
| | | export const VEHICLE_STATE = { |
| | | CRUISING: 'CRUISING', |
| | | PICK_UP: 'PICK_UP', |
| | | DROP_OFF: 'DROP_OFF', |
| | | CHARGING: 'CHARGING', |
| | | PREPARING: 'PREPARING', |
| | | RETURNING: 'RETURNING', |
| | | ABNORMAL: 'ABNORMAL', |
| | | ODD_OUT: 'ODD_OUT', |
| | | } as const |
| | | |
| | | /** 站点缓存有效期(毫秒) */ |
| | | export const STATIONS_CACHE_TTL = 10 * 60 * 1000 |
| | | |
| | | /** 地图样式 */ |
| | | export const MAP_STYLE = 'amap://styles/dark' |
| | | |
| | | /** 图层 z-index */ |
| | | export const LAYER_Z_INDEX = { |
| | | FENCE: 10, |
| | | STATION: 20, |
| | | PASSENGER_CALL: 40, |
| | | VEHICLE: 50, |
| | | ROUTE: 60, |
| | | } as const |
| | | |
| | | /** 围栏颜色配置 */ |
| | | export const FENCE_STYLE = { |
| | | RESTRICTED: { stroke: '#FF4D4F', fill: '#FF4D4F', strokeStyle: 'solid' as const }, |
| | | OPERATIONAL: { stroke: '#1890FF', fill: '#1890FF', strokeStyle: 'dashed' as const }, |
| | | STROKE_WEIGHT: 2, |
| | | STROKE_OPACITY: 0.8, |
| | | FILL_OPACITY: 0.1, |
| | | } as const |
| | | |
| | | /** 路线颜色 */ |
| | | export const ROUTE_COLOR = { |
| | | PICKUP: '#1890FF', |
| | | DROPOFF: '#52C41A', |
| | | } as const |
| | | |
| | | export const LEGEND_ITEMS = [ |
| | | { color: MONITOR_COLOR_MAP.CRUISING.color, label: '巡游中' }, |
| | | { color: MONITOR_COLOR_MAP.HAS_ORDER.color, label: '有单' }, |
| | | { color: MONITOR_COLOR_MAP.OCCUPIED.color, label: '占用中' }, |
| | | { color: MONITOR_COLOR_MAP.ABNORMAL.color, label: '异常' }, |
| | | { color: '#FAAD14', label: '⚡ 充电中', icon: 'charging' }, |
| | | { color: CHARGING_COLOR, label: '⚡ 充电中', icon: 'charging' }, |
| | | ] |
| | |
| | | export type MonitorGroup = 'CRUISING' | 'HAS_ORDER' | 'OCCUPIED' | 'ABNORMAL' |
| | | |
| | | export type MonitorStatus = |
| | | | '巡游中' |
| | | | '接驾中' |
| | | | '送驾中' |
| | | | '充电中' |
| | | | '整备中' |
| | | | '回场中' |
| | | | 'ODD越界' |
| | | | '离线' |
| | | export type VehicleMonitorStateCode = |
| | | | 'CRUISING' |
| | | | 'PICK_UP' |
| | | | 'DROP_OFF' |
| | | | 'CHARGING' |
| | | | 'PREPARING' |
| | | | 'RETURNING' |
| | | | 'ABNORMAL' |
| | | | 'ODD_OUT' |
| | | |
| | | export interface VehicleMonitorVo { |
| | | vehicleId: string |
| | | vehicleId: number |
| | | vin: string |
| | | plateNumber: string |
| | | monitorGroup: MonitorGroup |
| | | monitorStatus: string |
| | | stateCode: VehicleMonitorStateCode |
| | | lon: number |
| | | lat: number |
| | | monitorGroup: MonitorGroup |
| | | monitorStatus: MonitorStatus |
| | | charging: boolean |
| | | course: number |
| | | currentElectricity: number |
| | | todayCompletedOrders: number |
| | | lastHeartbeat: string |
| | | speed: number |
| | | orderId: number | null |
| | | charging: boolean |
| | | positionUpdateTime: number |
| | | } |
| | | |
| | | export interface CommandCenterKpiVo { |
| | | cruisingCount: number |
| | | hasOrderCount: number |
| | | hasOrderPickupCount: number |
| | | hasOrderDropoffCount: number |
| | | pickUpCount: number |
| | | dropOffCount: number |
| | | occupiedCount: number |
| | | occupiedChargingCount: number |
| | | occupiedMaintenanceCount: number |
| | | occupiedReturningCount: number |
| | | chargingCount: number |
| | | preparingCount: number |
| | | returningCount: number |
| | | abnormalCount: number |
| | | todayCompletedOrders: number |
| | | averageOrdersPerVehicle: number |
| | | avgOrdersPerVehicle: number |
| | | } |
| | | |
| | | export interface PassengerCallVo { |
| | | callId: string |
| | | lon: number |
| | | lat: number |
| | | orderId: number |
| | | orderNumber: string |
| | | startLon: number |
| | | startLat: number |
| | | startAddress: string |
| | | endLon: number |
| | | endLat: number |
| | | endAddress: string |
| | | createTime: string |
| | | waitingSeconds: number |
| | | address: string |
| | | status: 'waiting' | 'dispatched' | 'cancelled' |
| | | } |
| | | |
| | | export interface XpElectronicFenceArea { |
| | | id: number |
| | | electronicId: number |
| | | areaName: string |
| | | areaType: number // 1=运营区 2=禁行区 |
| | | areaStatus: number // 1=启用 2=禁用 |
| | | fencePoints: string // "经度*纬度,经度*纬度" |
| | | } |
| | | |
| | | export interface FenceVo { |
| | | fenceId: string |
| | | name: string |
| | | type: 'ODD' | 'RESTRICTED' |
| | | points: Array<{ lon: number, lat: number }> |
| | | electronicId: number |
| | | electronicName: string |
| | | cityCode: string |
| | | status: number |
| | | areas: XpElectronicFenceArea[] |
| | | } |
| | | |
| | | export interface StationVo { |
| | | stationId: string |
| | | name: string |
| | | id: number |
| | | stationName: string |
| | | stationNumber: string |
| | | status: number |
| | | cityCode: string |
| | | cityName: string |
| | | electronicId: number |
| | | centerLon: number |
| | | centerLat: number |
| | | type: string |
| | | fencePoints: string |
| | | stationArea: number |
| | | sortNo: number |
| | | } |
| | | |
| | | export interface Region { |
| | | regionCode: string |
| | | regionName: string |
| | | children?: Region[] |
| | | export interface XpOpenCity { |
| | | id: number |
| | | provinceName: string |
| | | provinceCode: string |
| | | cityName: string |
| | | cityCode: string |
| | | districtName: string |
| | | districtCode: string |
| | | status: number |
| | | } |
| | | |
| | | export interface RouteInfo { |
| | | startAddress: string |
| | | export interface CommandCenterRouteInfoVo { |
| | | startLon: number |
| | | startLat: number |
| | | endAddress: string |
| | | endLon: number |
| | | endLat: number |
| | | eta: string |
| | | startAddress: string |
| | | endAddress: string |
| | | eta: number // seconds |
| | | } |
| | | |
| | | export interface VehicleDetailVo { |
| | | vehicleId: string |
| | | vehicleId: number |
| | | vin: string |
| | | plateNumber: string |
| | | monitorGroup: MonitorGroup |
| | | monitorStatus: MonitorStatus |
| | | charging: boolean |
| | | monitorStatus: string |
| | | stateCode: VehicleMonitorStateCode |
| | | currentElectricity: number |
| | | todayCompletedOrders: number |
| | | lon: number |
| | | lat: number |
| | | routeInfo: RouteInfo | null |
| | | routeInfo: CommandCenterRouteInfoVo | null |
| | | currentLon: number |
| | | currentLat: number |
| | | } |
| | | |
| | | export interface LayerVisibility { |
| | |
| | | <script setup lang="ts"> |
| | | import { useAMapLoader } from '@/composables/useAMapLoader' |
| | | import { cityCenterMap, INITIAL_ZOOM } from '@/views/businessOperations/operationWorkbench/workbench.constants' |
| | | import { DEFAULT_CITY_CODE, MAP_STYLE } from '@/views/businessOperations/commandCenter/command-center.constants' |
| | | import { useCommandCenterStore } from '@/store/commandCenter' |
| | | import MapErrorFallback from './MapErrorFallback.vue' |
| | | |
| | |
| | | loadError.value = false |
| | | await load() |
| | | const AMap = (window as any).AMap |
| | | const cityConfig = cityCenterMap[store.cityCode] || cityCenterMap['440100'] |
| | | const cityConfig = cityCenterMap[store.cityCode] || cityCenterMap[DEFAULT_CITY_CODE] |
| | | mapInstance.value = new AMap.Map(mapContainer.value, { |
| | | zoom: cityConfig.zoom || INITIAL_ZOOM, |
| | | center: cityConfig.center, |
| | | mapStyle: 'amap://styles/dark', |
| | | mapStyle: MAP_STYLE, |
| | | resizeEnable: true, |
| | | }) |
| | | } |
| | |
| | | title="有单" |
| | | :value="kpi?.hasOrderCount ?? 0" |
| | | :color="MONITOR_COLOR_MAP.HAS_ORDER.color" |
| | | :subtitle="`接驾 ${kpi?.hasOrderPickupCount ?? 0} · 送驾 ${kpi?.hasOrderDropoffCount ?? 0}`" |
| | | :subtitle="`接驾 ${kpi?.pickUpCount ?? 0} · 送驾 ${kpi?.dropOffCount ?? 0}`" |
| | | /> |
| | | <KpiCard |
| | | title="占用中" |
| | | :value="kpi?.occupiedCount ?? 0" |
| | | :color="MONITOR_COLOR_MAP.OCCUPIED.color" |
| | | :subtitle="`充电 ${kpi?.occupiedChargingCount ?? 0} · 整备 ${kpi?.occupiedMaintenanceCount ?? 0} · 回场 ${kpi?.occupiedReturningCount ?? 0}`" |
| | | :subtitle="`充电 ${kpi?.chargingCount ?? 0} · 整备 ${kpi?.preparingCount ?? 0} · 回场 ${kpi?.returningCount ?? 0}`" |
| | | /> |
| | | <KpiCard |
| | | title="今日完单" |
| | | :value="kpi?.todayCompletedOrders ?? 0" |
| | | color="rgba(0,0,0,0.85)" |
| | | :subtitle="`均单/车 ${kpi?.averageOrdersPerVehicle ?? 0}`" |
| | | :subtitle="`均单/车 ${kpi?.avgOrdersPerVehicle ?? 0}`" |
| | | /> |
| | | </div> |
| | | <div class="status-header__right"> |
| | |
| | | <div class="route-section"> |
| | | <div class="route-section__title">路线信息</div> |
| | | <div class="route-section__content"> |
| | | <template v-if="detail.monitorStatus === '巡游中'"> |
| | | <template v-if="detail.stateCode === VEHICLE_STATE.CRUISING"> |
| | | <p>当前位置 → 巡游路线</p> |
| | | </template> |
| | | <template v-else-if="detail.monitorStatus === '接驾中' && detail.routeInfo"> |
| | | <template v-else-if="detail.stateCode === VEHICLE_STATE.PICK_UP && detail.routeInfo"> |
| | | <p>车辆位置 → {{ detail.routeInfo.startAddress }}</p> |
| | | <p class="route-section__eta">预计到达:{{ detail.routeInfo.eta }}</p> |
| | | <p class="route-section__eta">预计到达:{{ formatEta(detail.routeInfo.eta) }}</p> |
| | | </template> |
| | | <template v-else-if="detail.monitorStatus === '送驾中' && detail.routeInfo"> |
| | | <template v-else-if="detail.stateCode === VEHICLE_STATE.DROP_OFF && detail.routeInfo"> |
| | | <p>{{ detail.routeInfo.startAddress }} → 车辆位置 → {{ detail.routeInfo.endAddress }}</p> |
| | | <p class="route-section__eta">预计到达:{{ detail.routeInfo.eta }}</p> |
| | | <p class="route-section__eta">预计到达:{{ formatEta(detail.routeInfo.eta) }}</p> |
| | | </template> |
| | | <template v-else> |
| | | <p>{{ detail.monitorStatus }}</p> |
| | |
| | | |
| | | <script setup lang="ts"> |
| | | import type { VehicleDetailVo } from '../../command-center.types' |
| | | import { VEHICLE_STATE } from '../../command-center.constants' |
| | | |
| | | defineProps<{ detail: VehicleDetailVo }>() |
| | | |
| | | function formatEta(seconds: number): string { |
| | | const min = Math.ceil(seconds / 60) |
| | | return min >= 60 ? `${Math.floor(min / 60)}小时${min % 60}分钟` : `${min}分钟` |
| | | } |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | |
| | | }) |
| | | |
| | | const currentTodayOrders = computed(() => { |
| | | if (store.vehicleDetail) |
| | | return store.vehicleDetail.todayCompletedOrders |
| | | const v = store.vehicles.find(v => v.vehicleId === store.selectedVehicleId) |
| | | return v?.todayCompletedOrders ?? 0 |
| | | return store.vehicleDetail?.todayCompletedOrders ?? 0 |
| | | }) |
| | | |
| | | function handleClose(show: boolean) { |
| | |
| | | import { useCommandCenterStore } from '@/store/commandCenter' |
| | | import { useVehicleDetail } from '@/composables/useVehicleDetail' |
| | | import { DEFAULT_CITY_OPTIONS } from '@/views/businessOperations/operationWorkbench/workbench.constants' |
| | | import { VEHICLE_STATE } from './command-center.constants' |
| | | import { FenceLayer } from './map/FenceLayer' |
| | | import { StationLayer } from './map/StationLayer' |
| | | import { VehicleLayer } from './map/VehicleLayer' |
| | |
| | | return |
| | | } |
| | | routeLayer?.clear() |
| | | if (detail.monitorStatus === '接驾中' && detail.routeInfo) { |
| | | routeLayer?.drawPickup(detail.lon, detail.lat, detail.routeInfo.startLon, detail.routeInfo.startLat) |
| | | if (detail.stateCode === VEHICLE_STATE.PICK_UP && detail.routeInfo) { |
| | | routeLayer?.drawPickup(detail.currentLon, detail.currentLat, detail.routeInfo.startLon, detail.routeInfo.startLat) |
| | | } |
| | | else if (detail.monitorStatus === '送驾中' && detail.routeInfo) { |
| | | routeLayer?.drawDropoff(detail.lon, detail.lat, detail.routeInfo.endLon, detail.routeInfo.endLat) |
| | | else if (detail.stateCode === VEHICLE_STATE.DROP_OFF && detail.routeInfo) { |
| | | routeLayer?.drawDropoff(detail.currentLon, detail.currentLat, detail.routeInfo.endLon, detail.routeInfo.endLat) |
| | | } |
| | | }) |
| | | |
| | |
| | | import type { FenceVo } from '../command-center.types' |
| | | import { FENCE_STYLE, LAYER_Z_INDEX } from '../command-center.constants' |
| | | |
| | | /** |
| | | * 围栏图层 — 管理地图上 ODD/禁行区域多边形的渲染与交互 |
| | |
| | | } |
| | | |
| | | /** |
| | | * 解析围栏点字符串 "经度*纬度,经度*纬度" → [[lon, lat], ...] |
| | | */ |
| | | private parseFencePoints(fencePoints: string): number[][] { |
| | | if (!fencePoints) return [] |
| | | return fencePoints.split(',').map((pair) => { |
| | | const [lon, lat] = pair.split('*').map(Number) |
| | | return [lon, lat] |
| | | }).filter(([lon, lat]) => !isNaN(lon) && !isNaN(lat)) |
| | | } |
| | | |
| | | /** |
| | | * 全量更新围栏列表(先清除再重绘) |
| | | * @param fences - 围栏数据列表 |
| | | */ |
| | |
| | | return |
| | | |
| | | fences.forEach((fence) => { |
| | | const path = fence.points.map(p => [p.lon, p.lat]) |
| | | const isRestricted = fence.type === 'RESTRICTED' |
| | | if (!fence.areas) return |
| | | |
| | | const polygon = new AMap.Polygon({ |
| | | path, |
| | | strokeColor: isRestricted ? '#FF4D4F' : '#1890FF', |
| | | strokeWeight: 2, |
| | | strokeStyle: isRestricted ? 'solid' : 'dashed', |
| | | strokeOpacity: 0.8, |
| | | fillColor: isRestricted ? '#FF4D4F' : '#1890FF', |
| | | fillOpacity: 0.1, |
| | | zIndex: 10, |
| | | }) |
| | | fence.areas.forEach((area) => { |
| | | if (area.areaStatus !== 1) return // 只渲染启用的区域 |
| | | |
| | | polygon.on('mousemove', (e: any) => { |
| | | const label = isRestricted ? '禁行区' : '运营区域 (ODD边界)' |
| | | const infoWindow = new AMap.InfoWindow({ |
| | | content: `<div style="padding:4px 8px;font-size:12px;">${fence.name || label}</div>`, |
| | | offset: new AMap.Pixel(0, -10), |
| | | closeWhenClickMap: true, |
| | | const path = this.parseFencePoints(area.fencePoints) |
| | | if (path.length < 3) return |
| | | |
| | | const isRestricted = area.areaType === 2 |
| | | |
| | | const style = isRestricted ? FENCE_STYLE.RESTRICTED : FENCE_STYLE.OPERATIONAL |
| | | |
| | | const polygon = new AMap.Polygon({ |
| | | path, |
| | | strokeColor: style.stroke, |
| | | strokeWeight: FENCE_STYLE.STROKE_WEIGHT, |
| | | strokeStyle: style.strokeStyle, |
| | | strokeOpacity: FENCE_STYLE.STROKE_OPACITY, |
| | | fillColor: style.fill, |
| | | fillOpacity: FENCE_STYLE.FILL_OPACITY, |
| | | zIndex: LAYER_Z_INDEX.FENCE, |
| | | }) |
| | | infoWindow.open(this.map, e.lnglat) |
| | | }) |
| | | |
| | | polygon.on('mouseout', () => { |
| | | this.map.clearInfoWindow() |
| | | }) |
| | | polygon.on('mousemove', (e: any) => { |
| | | const label = area.areaName || (isRestricted ? '禁行区' : '运营区域 (ODD边界)') |
| | | const infoWindow = new AMap.InfoWindow({ |
| | | content: `<div style="padding:4px 8px;font-size:12px;">${fence.electronicName} - ${label}</div>`, |
| | | offset: new AMap.Pixel(0, -10), |
| | | closeWhenClickMap: true, |
| | | }) |
| | | infoWindow.open(this.map, e.lnglat) |
| | | }) |
| | | |
| | | polygon.setMap(this.visible ? this.map : null) |
| | | this.polygons.push(polygon) |
| | | polygon.on('mouseout', () => { |
| | | this.map.clearInfoWindow() |
| | | }) |
| | | |
| | | polygon.setMap(this.visible ? this.map : null) |
| | | this.polygons.push(polygon) |
| | | }) |
| | | }) |
| | | } |
| | | |
| | |
| | | import type { PassengerCallVo } from '../command-center.types' |
| | | import { LAYER_Z_INDEX } from '../command-center.constants' |
| | | |
| | | /** |
| | | * 乘客呼单图层 — 管理地图上乘客呼叫标注的渲染与交互 |
| | | */ |
| | | export class PassengerCallLayer { |
| | | private map: any |
| | | private markers = new Map<string, any>() |
| | | private markers = new Map<number, any>() |
| | | private visible = true |
| | | private infoWindow: any = null |
| | | |
| | |
| | | if (!AMap) |
| | | return |
| | | |
| | | const currentIds = new Set(list.map(c => c.callId)) |
| | | const currentIds = new Set(list.map(c => c.orderId)) |
| | | |
| | | this.markers.forEach((marker, id) => { |
| | | if (!currentIds.has(id)) { |
| | |
| | | }) |
| | | |
| | | list.forEach((call) => { |
| | | if (this.markers.has(call.callId)) { |
| | | const marker = this.markers.get(call.callId) |
| | | marker.setPosition([call.lon, call.lat]) |
| | | if (this.markers.has(call.orderId)) { |
| | | const marker = this.markers.get(call.orderId) |
| | | marker.setPosition([call.startLon, call.startLat]) |
| | | marker._callData = call |
| | | return |
| | | } |
| | | |
| | | const marker = new AMap.Marker({ |
| | | position: [call.lon, call.lat], |
| | | position: [call.startLon, call.startLat], |
| | | content: '<div style="width:20px;height:20px;font-size:16px;line-height:20px;text-align:center;">🙋</div>', |
| | | offset: new AMap.Pixel(-10, -10), |
| | | zIndex: 40, |
| | | zIndex: LAYER_Z_INDEX.PASSENGER_CALL, |
| | | }) |
| | | marker._callData = call |
| | | |
| | |
| | | const data = e.target._callData as PassengerCallVo |
| | | const waitMin = Math.round(data.waitingSeconds / 60) |
| | | this.infoWindow.setContent( |
| | | `<div style="padding:4px 8px;font-size:12px;white-space:nowrap;">乘客呼单 · 等待 ${waitMin} 分钟 · ${data.address}</div>`, |
| | | `<div style="padding:4px 8px;font-size:12px;white-space:nowrap;">乘客呼单 · 等待 ${waitMin} 分钟 · ${data.startAddress}</div>`, |
| | | ) |
| | | this.infoWindow.open(this.map, e.target.getPosition()) |
| | | }) |
| | |
| | | }) |
| | | |
| | | marker.setMap(this.visible ? this.map : null) |
| | | this.markers.set(call.callId, marker) |
| | | this.markers.set(call.orderId, marker) |
| | | }) |
| | | } |
| | | |
| | |
| | | import { LAYER_Z_INDEX, ROUTE_COLOR } from '../command-center.constants' |
| | | |
| | | /** |
| | | * 路线图层 — 管理地图上接驾/送驾路线的绘制 |
| | | */ |
| | |
| | | import type { StationVo } from '../command-center.types' |
| | | import { LAYER_Z_INDEX } from '../command-center.constants' |
| | | |
| | | /** |
| | | * 站点图层 — 管理地图上充电站/维修站等站点标注 |
| | |
| | | position: [station.centerLon, station.centerLat], |
| | | content: '<div style="font-size:20px;line-height:1;">🔌</div>', |
| | | offset: new AMap.Pixel(-10, -10), |
| | | zIndex: 20, |
| | | zIndex: LAYER_Z_INDEX.STATION, |
| | | }) |
| | | |
| | | marker.on('mouseover', (e: any) => { |
| | | const infoWindow = new AMap.InfoWindow({ |
| | | content: `<div style="padding:4px 8px;font-size:12px;">${station.name}</div>`, |
| | | content: `<div style="padding:4px 8px;font-size:12px;">${station.stationName}</div>`, |
| | | offset: new AMap.Pixel(0, -20), |
| | | }) |
| | | infoWindow.open(this.map, e.target.getPosition()) |
| | |
| | | import type { VehicleMonitorVo } from '../command-center.types' |
| | | import { LAYER_Z_INDEX } from '../command-center.constants' |
| | | import { createVehicleMarkerContent, createVehicleTooltipContent } from './vehicle-marker' |
| | | |
| | | /** |
| | |
| | | */ |
| | | export class VehicleLayer { |
| | | private map: any |
| | | private markers = new Map<string, any>() |
| | | private markers = new Map<number, any>() |
| | | private visible = true |
| | | private onSelect: ((vehicleId: string) => void) | null = null |
| | | private onSelect: ((vehicleId: number) => void) | null = null |
| | | private infoWindow: any = null |
| | | |
| | | /** |
| | | * @param map - 高德地图实例 |
| | | * @param onSelect - 点击车辆时的回调 |
| | | */ |
| | | constructor(map: any, onSelect?: (vehicleId: string) => void) { |
| | | constructor(map: any, onSelect?: (vehicleId: number) => void) { |
| | | this.map = map |
| | | this.onSelect = onSelect || null |
| | | const AMap = (window as any).AMap |
| | |
| | | position: [vehicle.lon, vehicle.lat], |
| | | content: createVehicleMarkerContent(vehicle), |
| | | offset: new AMap.Pixel(-12, -12), |
| | | zIndex: 50, |
| | | zIndex: LAYER_Z_INDEX.VEHICLE, |
| | | }) |
| | | marker._vehicleData = vehicle |
| | | |
| | |
| | | * 高亮选中车辆,其他车辆降低透明度 |
| | | * @param selectedId - 选中车辆 ID,null 时恢复全部 |
| | | */ |
| | | setHighlight(selectedId: string | null) { |
| | | setHighlight(selectedId: number | null) { |
| | | this.markers.forEach((marker, id) => { |
| | | if (selectedId === null) { |
| | | marker.setExtData({ ...marker.getExtData(), opacity: 1 }) |
| | |
| | | import type { VehicleMonitorVo } from '../command-center.types' |
| | | import { MONITOR_COLOR_MAP } from '../command-center.constants' |
| | | import { MONITOR_COLOR_MAP, VEHICLE_STATE } from '../command-center.constants' |
| | | |
| | | /** |
| | | * 创建车辆地图标注的 HTML 内容 |
| | |
| | | */ |
| | | export const createVehicleMarkerContent = (vehicle: VehicleMonitorVo): string => { |
| | | const colorConfig = MONITOR_COLOR_MAP[vehicle.monitorGroup] || MONITOR_COLOR_MAP.ABNORMAL |
| | | const isPulse = vehicle.monitorStatus === 'ODD越界' |
| | | const isPulse = vehicle.stateCode === VEHICLE_STATE.ODD_OUT |
| | | const isCharging = vehicle.charging |
| | | |
| | | return ` |