第三阶段核心业务开发完成

This commit is contained in:
2025-05-18 15:58:16 +08:00
parent 53f7fee73a
commit bab3f719e2
120 changed files with 4114 additions and 387 deletions

View File

@@ -1,109 +0,0 @@
# 项目变更日志 - 第二阶段MQTT 集成
## 2023-12-02: 第二阶段启动 - MQTT 集成
- **状态**: 第一阶段开发已完成,相关日志已存档至 `LogBook_Phase1.md`
- **当前任务**: 开始第二阶段开发,重点是 MQTT 的集成。
- **依据文档**: `springboot-init-main/doc/development_stages/stage_2_mqtt_integration.md`
- **已完成**:
1. **数据库初始化**: 在 `mqtt_power` 数据库中成功创建了 `robot_task` 表。
2. **MQTT Broker 确定与配置**:
- 确认使用公共 MQTT Broker: `broker.emqx.io:1883`
- 更新 `springboot-init-main/src/main/resources/application.yml` 添加了 Broker 连接信息,并为 `client-id-prefix``command-topic-base``status-topic-base` 添加了项目唯一前缀 (如 `yupi_mqtt_power_project/`) 以确保在公共环境中的唯一性。
- 创建了 `springboot-init-main/src/main/java/com/yupi/project/config/properties/MqttProperties.java` 来映射 MQTT 配置。
3. **更新开发文档**:
- 修改了 `springboot-init-main/doc/development_stages/stage_2_mqtt_integration.md`,反映了公共 Broker 的使用、Topic 唯一性策略以及应用层鉴权的重要性。
4. **实现 MQTT 客户端核心配置 (`MqttConfig.java`, `MqttCallbackHandler.java`, `MqttConnectionManager.java`)**:
- 创建了 `com.yupi.project.mqtt.MqttCallbackHandler` 类,实现 `MqttCallbackExtended`接口,用于处理连接事件和初步的消息接收(日志记录)。在 `connectComplete` 中实现订阅状态主题 `yupi_mqtt_power_project/robot/status/+` 的逻辑。
- 创建了 `com.yupi.project.config.MqttConfig` 配置类,定义 `MqttConnectOptions``MqttClient` Beans。
- 创建了 `com.yupi.project.mqtt.MqttConnectionManager` 类,实现 `ApplicationListener<ContextRefreshedEvent>``DisposableBean`,在应用启动完成后连接 MQTT并在应用关闭前断开连接。解决了 MqttClient 初始化和连接时序问题。
5. **创建 `RobotTask` 管理基础结构**:
-`com.yupi.project.model.enums` 包下创建了 `CommandTypeEnum.java``TaskStatusEnum.java`
-`com.yupi.project.model.entity` 包下创建了 `RobotTask.java` 实体类,包含 MyBatis-Plus 注解。
-`com.yupi.project.mapper` 包下创建了 `RobotTaskMapper.java` 接口。
-`com.yupi.project.service` 包下创建了 `RobotTaskService.java` 接口,定义了任务管理的核心方法。
-`com.yupi.project.service.impl` 包下创建了 `RobotTaskServiceImpl.java` 类,并为接口方法提供了最小化占位实现。
6. **详细实现 `RobotTaskServiceImpl` 中的核心业务方法**:
- `createTask(String robotId, CommandTypeEnum commandType, String payloadJson, Long sessionId)`: 创建新的机器人任务,初始状态为 PENDING。
- `hasPendingOrSentTask(String robotId)`: 检查机器人是否有 PENDING 或 SENT 状态的任务。
- `markTaskAsSent(Long taskId, Date sentTime)`: 将任务状态从 PENDING 更新为 SENT并记录发送时间。
- `findLatestSentTaskByRobotId(String robotId)`: 查找指定机器人最近一个 SENT 状态的任务。
- `markTaskAsAcknowledged(Long taskId, boolean success, String errorMessage, Date ackTime)`: 根据机器人响应更新任务状态为 ACKNOWLEDGED_SUCCESS 或 ACKNOWLEDGED_FAILURE并记录确认时间和错误信息。
- `findAndMarkTimedOutTasks(int timeoutSeconds)`: 查找并标记已发送但超时的任务为 TIMED_OUT。
- **下一步计划 (依据 `stage_2_mqtt_integration.md`)**:
1. **实现消息发布 (`MqttService`)**:
- 创建 `MqttService` 接口和 `MqttServiceImpl` 实现类。
- 实现 `sendCommand(...)` 方法,该方法会调用 `RobotTaskService.hasPendingOrSentTask` 进行检查,调用 `RobotTaskService.createTask` 创建任务,然后通过 `MqttClient` 发布指令,最后调用 `RobotTaskService.markTaskAsSent` 更新任务状态。
2. **实现消息处理 (`MqttMessageHandler`)**:
- 创建 `MqttMessageHandler` 接口和 `MqttMessageHandlerImpl` 实现类 (之前 `MqttCallbackHandler` 中有占位,现在需要具体实现)。
- 实现 `handleStatusUpdate(String topic, String payload)` 方法,解析机器人状态,查找关联的 `RobotTask`,并调用 `RobotTaskService.markTaskAsAcknowledged` 更新任务。
- 根据机器人状态执行后续业务逻辑 (此阶段可留空或简单日志记录)。
3. **实现任务超时处理 (`TaskTimeoutHandler`)**:
- 创建 `TaskTimeoutHandler` 类,使用 `@Scheduled` 定时调用 `RobotTaskService.findAndMarkTimedOutTasks`
- (可选)根据超时任务更新关联的业务实体状态 (如 `ChargingRobot`, `ChargingSession`)。
- 更正了 `springboot-init-main/src/main/java/com/yupi/project/service/impl/MqttServiceImpl.java` 的实现,确保 `sendCommand` 方法的逻辑完整和正确。
- 实现 `TaskTimeoutHandler.java`,使用 `@Scheduled` 定时调用 `RobotTaskService.findAndMarkTimedOutTasks` 处理任务超时。
-`MyApplication.java` 中添加 `@EnableScheduling` 以启用定时任务。
-`application.yml` 中添加了 `mqtt.task.timeoutSeconds``mqtt.task.timeoutCheckRateMs` 配置项。
---
**第二阶段 (MQTT 集成) 已于 YYYY-MM-DD 完成。**
所有核心功能点包括MQTT连接、消息发布/订阅、RobotTask状态跟踪和基础超时处理已实现。
依赖于 ChargingSession 的超时后联动处理已明确推至第三阶段。
---
## YYYY-MM-DD (请替换为当前日期) - 第三阶段后端开发
- **核心业务实体与服务实现**:
- 创建了枚举类: `RobotStatusEnum`, `ParkingSpotStatusEnum`, `ChargingSessionStatusEnum`, `PaymentStatusEnum`.
- 创建了数据库实体: `ChargingRobot`, `ParkingSpot`, `ChargingSession`.
- 创建了对应的Mapper接口: `ChargingRobotMapper`, `ParkingSpotMapper`, `ChargingSessionMapper`.
- 创建了Service接口: `ChargingRobotService`, `ParkingSpotService`, `ChargingSessionService`.
- 创建了Service实现类: `ChargingRobotServiceImpl`, `ParkingSpotServiceImpl`, `ChargingSessionServiceImpl`.
- `ChargingSessionServiceImpl` 中实现了充电请求、机器人分配、状态流转 (到达、开始/结束充电)、费用计算、支付、取消、超时处理等核心逻辑。
- **API 控制器实现**:
- 创建了 `ChargingRobotAdminController` 用于管理员管理充电机器人 (CRUD, 列表查询, 状态类型)。
- 创建了 `ParkingSpotAdminController` 用于管理员管理车位 (CRUD, 列表查询, 状态类型)。
- 创建了 `ChargingSessionController` 用于用户发起充电请求、查询历史会话、支付、取消会话。
- 创建了相关的DTOs (如 `ChargingRobotAddRequest`, `ParkingSpotQueryRequest`, `ChargingRequest`, `PaymentRequest`) 和 VO (`ChargingSessionVO`).
- **MQTT与任务处理联动**:
- 更新了 `MqttMessageHandlerImpl`使其在收到机器人状态ACK后能调用 `ChargingSessionService` 更新相关充电会话的状态。
- 更新了 `TaskTimeoutHandler`,使其在检测到与会话关联的任务超时后,能调用 `ChargingSessionService` 处理会话超时逻辑。
-`ChargingSessionService` 中补充了 `getQueryWrapper` 方法用于支持分页和条件查询。
- **主要实现功能点**:
- 管理员可以增删改查充电机器人和车位。
- 用户可以请求在特定车位充电。
- 系统能够尝试分配空闲机器人,并向其发送移动指令 (通过MQTT并记录RobotTask)。
- 系统能够根据机器人通过MQTT反馈的状态到达、开始充电、结束充电更新充电会话的生命周期。
- 充电结束后,系统能计算费用,并允许用户支付。
- 用户可以在特定阶段取消充电会话。
- 机器人任务超时会影响关联的充电会话状态。
- **补充后端功能 (根据阶段计划调整)**:
-`ChargingSessionAdminController.java` 中添加了管理员分页查询所有充电会话的接口 (`POST /admin/session/list/page`)。
-`ChargingSessionController.java` 中添加了用户"优雅停止充电"的接口 (`POST /session/stop`)。
- 此接口会向机器人发送 `STOP_CHARGE` 指令,并通过 `ChargingSessionServiceImpl.stopChargingByUser` 方法创建相应的 `RobotTask`
- 会话的最终完成和计费依赖 `MqttMessageHandlerImpl` 收到机器人对 `STOP_CHARGE` 指令的成功ACK后调用 `chargingSessionService.handleChargingEnd` 处理。
- **下一步**:
- 进行详细的单元测试和集成测试。
- 完善错误处理、日志记录和边界条件。
- 更新API文档。
- **开始第三阶段前端开发**。
- 根据 `stage_3_core_charging_logic.md` 检查业务流程覆盖情况。
## 后端开发 - 第三阶段核心充电逻辑完成
* **状态**: 后端核心业务逻辑、服务实现、MQTT集成及主要API Controller已完成开发并通过多轮编译错误修复。
* 充电全流程 (请求、分配、移动、到达、开始、结束、计费、支付、取消、超时) 已实现。
* 机器人和车位的状态管理服务已实现并集成到主流程。
* 用户余额扣减实现原子性操作。
* MQTT消息处理机制已建立可处理任务ACK和常规状态上报。
* **决策**: 经过讨论,充电过程中的实时时长更新 (`currentChargingDurationSeconds`) 功能在本阶段不实现,最终计费依赖充电结束时上报的 `totalDurationSeconds`
* **后续**: 后端已为前端开发提供基础。建议在前端大规模开发前后端进行核心API的冒烟测试并完善API文档如使用Swagger

473
LogBook_Phase2-3.md Normal file
View File

@@ -0,0 +1,473 @@
# 项目变更日志 - 第二阶段MQTT 集成
## 2023-12-02: 第二阶段启动 - MQTT 集成
- **状态**: 第一阶段开发已完成,相关日志已存档至 `LogBook_Phase1.md`
- **当前任务**: 开始第二阶段开发,重点是 MQTT 的集成。
- **依据文档**: `springboot-init-main/doc/development_stages/stage_2_mqtt_integration.md`
- **已完成**:
1. **数据库初始化**: 在 `mqtt_power` 数据库中成功创建了 `robot_task` 表。
2. **MQTT Broker 确定与配置**:
- 确认使用公共 MQTT Broker: `broker.emqx.io:1883`
- 更新 `springboot-init-main/src/main/resources/application.yml` 添加了 Broker 连接信息,并为 `client-id-prefix``command-topic-base``status-topic-base` 添加了项目唯一前缀 (如 `yupi_mqtt_power_project/`) 以确保在公共环境中的唯一性。
- 创建了 `springboot-init-main/src/main/java/com/yupi/project/config/properties/MqttProperties.java` 来映射 MQTT 配置。
3. **更新开发文档**:
- 修改了 `springboot-init-main/doc/development_stages/stage_2_mqtt_integration.md`,反映了公共 Broker 的使用、Topic 唯一性策略以及应用层鉴权的重要性。
4. **实现 MQTT 客户端核心配置 (`MqttConfig.java`, `MqttCallbackHandler.java`, `MqttConnectionManager.java`)**:
- 创建了 `com.yupi.project.mqtt.MqttCallbackHandler` 类,实现 `MqttCallbackExtended`接口,用于处理连接事件和初步的消息接收(日志记录)。在 `connectComplete` 中实现订阅状态主题 `yupi_mqtt_power_project/robot/status/+` 的逻辑。
- 创建了 `com.yupi.project.config.MqttConfig` 配置类,定义 `MqttConnectOptions``MqttClient` Beans。
- 创建了 `com.yupi.project.mqtt.MqttConnectionManager` 类,实现 `ApplicationListener<ContextRefreshedEvent>``DisposableBean`,在应用启动完成后连接 MQTT并在应用关闭前断开连接。解决了 MqttClient 初始化和连接时序问题。
5. **创建 `RobotTask` 管理基础结构**:
-`com.yupi.project.model.enums` 包下创建了 `CommandTypeEnum.java``TaskStatusEnum.java`
-`com.yupi.project.model.entity` 包下创建了 `RobotTask.java` 实体类,包含 MyBatis-Plus 注解。
-`com.yupi.project.mapper` 包下创建了 `RobotTaskMapper.java` 接口。
-`com.yupi.project.service` 包下创建了 `RobotTaskService.java` 接口,定义了任务管理的核心方法。
-`com.yupi.project.service.impl` 包下创建了 `RobotTaskServiceImpl.java` 类,并为接口方法提供了最小化占位实现。
6. **详细实现 `RobotTaskServiceImpl` 中的核心业务方法**:
- `createTask(String robotId, CommandTypeEnum commandType, String payloadJson, Long sessionId)`: 创建新的机器人任务,初始状态为 PENDING。
- `hasPendingOrSentTask(String robotId)`: 检查机器人是否有 PENDING 或 SENT 状态的任务。
- `markTaskAsSent(Long taskId, Date sentTime)`: 将任务状态从 PENDING 更新为 SENT并记录发送时间。
- `findLatestSentTaskByRobotId(String robotId)`: 查找指定机器人最近一个 SENT 状态的任务。
- `markTaskAsAcknowledged(Long taskId, boolean success, String errorMessage, Date ackTime)`: 根据机器人响应更新任务状态为 ACKNOWLEDGED_SUCCESS 或 ACKNOWLEDGED_FAILURE并记录确认时间和错误信息。
- `findAndMarkTimedOutTasks(int timeoutSeconds)`: 查找并标记已发送但超时的任务为 TIMED_OUT。
- **下一步计划 (依据 `stage_2_mqtt_integration.md`)**:
1. **实现消息发布 (`MqttService`)**:
- 创建 `MqttService` 接口和 `MqttServiceImpl` 实现类。
- 实现 `sendCommand(...)` 方法,该方法会调用 `RobotTaskService.hasPendingOrSentTask` 进行检查,调用 `RobotTaskService.createTask` 创建任务,然后通过 `MqttClient` 发布指令,最后调用 `RobotTaskService.markTaskAsSent` 更新任务状态。
2. **实现消息处理 (`MqttMessageHandler`)**:
- 创建 `MqttMessageHandler` 接口和 `MqttMessageHandlerImpl` 实现类 (之前 `MqttCallbackHandler` 中有占位,现在需要具体实现)。
- 实现 `handleStatusUpdate(String topic, String payload)` 方法,解析机器人状态,查找关联的 `RobotTask`,并调用 `RobotTaskService.markTaskAsAcknowledged` 更新任务。
- 根据机器人状态执行后续业务逻辑 (此阶段可留空或简单日志记录)。
3. **实现任务超时处理 (`TaskTimeoutHandler`)**:
- 创建 `TaskTimeoutHandler` 类,使用 `@Scheduled` 定时调用 `RobotTaskService.findAndMarkTimedOutTasks`
- (可选)根据超时任务更新关联的业务实体状态 (如 `ChargingRobot`, `ChargingSession`)。
- 更正了 `springboot-init-main/src/main/java/com/yupi/project/service/impl/MqttServiceImpl.java` 的实现,确保 `sendCommand` 方法的逻辑完整和正确。
- 实现 `TaskTimeoutHandler.java`,使用 `@Scheduled` 定时调用 `RobotTaskService.findAndMarkTimedOutTasks` 处理任务超时。
-`MyApplication.java` 中添加 `@EnableScheduling` 以启用定时任务。
-`application.yml` 中添加了 `mqtt.task.timeoutSeconds``mqtt.task.timeoutCheckRateMs` 配置项。
---
**第二阶段 (MQTT 集成) 已于 YYYY-MM-DD 完成。**
所有核心功能点包括MQTT连接、消息发布/订阅、RobotTask状态跟踪和基础超时处理已实现。
依赖于 ChargingSession 的超时后联动处理已明确推至第三阶段。
---
## YYYY-MM-DD (请替换为当前日期) - 第三阶段后端开发
- **核心业务实体与服务实现**:
- 创建了枚举类: `RobotStatusEnum`, `ParkingSpotStatusEnum`, `ChargingSessionStatusEnum`, `PaymentStatusEnum`.
- 创建了数据库实体: `ChargingRobot`, `ParkingSpot`, `ChargingSession`.
- 创建了对应的Mapper接口: `ChargingRobotMapper`, `ParkingSpotMapper`, `ChargingSessionMapper`.
- 创建了Service接口: `ChargingRobotService`, `ParkingSpotService`, `ChargingSessionService`.
- 创建了Service实现类: `ChargingRobotServiceImpl`, `ParkingSpotServiceImpl`, `ChargingSessionServiceImpl`.
- `ChargingSessionServiceImpl` 中实现了充电请求、机器人分配、状态流转 (到达、开始/结束充电)、费用计算、支付、取消、超时处理等核心逻辑。
- **API 控制器实现**:
- 创建了 `ChargingRobotAdminController` 用于管理员管理充电机器人 (CRUD, 列表查询, 状态类型)。
- 创建了 `ParkingSpotAdminController` 用于管理员管理车位 (CRUD, 列表查询, 状态类型)。
- 创建了 `ChargingSessionController` 用于用户发起充电请求、查询历史会话、支付、取消会话。
- 创建了相关的DTOs (如 `ChargingRobotAddRequest`, `ParkingSpotQueryRequest`, `ChargingRequest`, `PaymentRequest`) 和 VO (`ChargingSessionVO`).
- **MQTT与任务处理联动**:
- 更新了 `MqttMessageHandlerImpl`使其在收到机器人状态ACK后能调用 `ChargingSessionService` 更新相关充电会话的状态。
- 更新了 `TaskTimeoutHandler`,使其在检测到与会话关联的任务超时后,能调用 `ChargingSessionService` 处理会话超时逻辑。
-`ChargingSessionService` 中补充了 `getQueryWrapper` 方法用于支持分页和条件查询。
- **主要实现功能点**:
- 管理员可以增删改查充电机器人和车位。
- 用户可以请求在特定车位充电。
- 系统能够尝试分配空闲机器人,并向其发送移动指令 (通过MQTT并记录RobotTask)。
- 系统能够根据机器人通过MQTT反馈的状态到达、开始充电、结束充电更新充电会话的生命周期。
- 充电结束后,系统能计算费用,并允许用户支付。
- 用户可以在特定阶段取消充电会话。
- 机器人任务超时会影响关联的充电会话状态。
- **补充后端功能 (根据阶段计划调整)**:
-`ChargingSessionAdminController.java` 中添加了管理员分页查询所有充电会话的接口 (`POST /admin/session/list/page`)。
-`ChargingSessionController.java` 中添加了用户"优雅停止充电"的接口 (`POST /session/stop`)。
- 此接口会向机器人发送 `STOP_CHARGE` 指令,并通过 `ChargingSessionServiceImpl.stopChargingByUser` 方法创建相应的 `RobotTask`
- 会话的最终完成和计费依赖 `MqttMessageHandlerImpl` 收到机器人对 `STOP_CHARGE` 指令的成功ACK后调用 `chargingSessionService.handleChargingEnd` 处理。
- **下一步**:
- 进行详细的单元测试和集成测试。
- 完善错误处理、日志记录和边界条件。
- 更新API文档。
- **开始第三阶段前端开发**。
- 根据 `stage_3_core_charging_logic.md` 检查业务流程覆盖情况。
## 后端开发 - 第三阶段核心充电逻辑完成
* **状态**: 后端核心业务逻辑、服务实现、MQTT集成及主要API Controller已完成开发并通过多轮编译错误修复。
* 充电全流程 (请求、分配、移动、到达、开始、结束、计费、支付、取消、超时) 已实现。
* 机器人和车位的状态管理服务已实现并集成到主流程。
* 用户余额扣减实现原子性操作。
* MQTT消息处理机制已建立可处理任务ACK和常规状态上报。
* **决策**: 经过讨论,充电过程中的实时时长更新 (`currentChargingDurationSeconds`) 功能在本阶段不实现,最终计费依赖充电结束时上报的 `totalDurationSeconds`
* **后续**: 后端已为前端开发提供基础。建议在前端大规模开发前后端进行核心API的冒烟测试并完善API文档如使用Swagger
## 前端开发 (`charging_web_app`) - 阶段3 (续):管理员界面优先
* **开发方向调整**: 由于普通用户端的"选择车位"流程因缺乏车位数据而受阻,现将开发优先级调整为**首先实现管理员前端界面中的"车位管理"功能**。
* **目标**: 使管理员能够通过前端界面方便地添加、查看、编辑和删除充电车位,以便为系统提供测试和运营所需的基础数据。
* **涉及后端接口**: 主要对接 `ParkingSpotAdminController` (`/api/admin/spot/*`)。
* **起始任务**: 创建管理员车位管理页面的基本骨架 (`/admin/parking-spots/page.tsx`),包括列表展示和"新建车位"的入口。
## 2024-07-29
### 后端
- **完成** `ChargingSessionController` 中添加 `GET /api/session/my/active` 接口,用于获取当前登录用户的活动充电会话。
- 依赖 `ChargingSessionService.getActiveSessionByUserId()`
- **完成** `ChargingSessionService` 接口添加 `getActiveSessionByUserId(Long userId)` 方法定义。
- **完成** `ChargingSessionServiceImpl` 实现 `getActiveSessionByUserId(Long userId)` 方法,查询状态为 `CHARGING_IN_PROGRESS` 的最新会话。
- **完成** `UserController` 中添加 `GET /api/user/stats/mine` 接口,用于获取当前登录用户的仪表盘统计信息。
- 依赖 `UserService.getUserDashboardStats()`
- **完成** `UserService` 接口添加 `getUserDashboardStats(Long userId)` 方法定义。
- **完成** `UserServiceImpl` 注入 `ChargingSessionService`
- **完成** `UserServiceImpl` 实现 `getUserDashboardStats(Long userId)` 方法,计算用户当月已完成的充电次数和总消费。
### 前端
- **完成** 修改 `charging_web_app/src/app/(authenticated)/dashboard/page.tsx`
- 移除模拟数据。
- 定义 `ActiveChargingSession``UserDashboardStats` 接口。
- 实现对后端 `/api/session/my/active``/api/user/stats/mine` 接口的调用。
- 更新UI以显示从API获取的真实数据包括加载状态和错误处理。
## 2024-07-30 (继续29号的工作)
### 后端
- **解决** `UserServiceImpl``ChargingSessionServiceImpl` 之间的循环依赖问题:
- 确认 `ChargingSessionServiceImpl` 中对 `UserService` 的注入为冗余,并将其移除。
- 移除 `UserServiceImpl` 中对 `ChargingSessionService` 注入的 `@Lazy` 注解。
- **修正** `ChargingSessionServiceImpl``UserServiceImpl` 中对 `ChargingSessionStatusEnum` 枚举常量的错误引用 (例如 `COMPLETED` -> `PAID`, `CHARGING_IN_PROGRESS` -> `CHARGING_STARTED`)。
- **修正** `ChargingSessionServiceImpl``ThrowUtils` 未导入的问题。
### 前端
- **修正** `charging_web_app/src/utils/axios.ts` 配置:
- 取消 `withCredentials: true` 的注释以允许发送会话Cookie。
-`baseURL` 的默认端口更新为后端实际运行的 `7529`
- 暂时注释了添加 `Authorization: Bearer token` 的拦截器逻辑以专注于Session认证。
- **修正** `charging_web_app/src/app/(authenticated)/dashboard/page.tsx`
- 移除 `<Link>` 组件的 `legacyBehavior` 属性以符合新的Next.js用法。
- 增加对 `userStats?.monthlySpending` 的空值检查,并使用辅助函数安全调用 `toFixed(2)`,解决了相关运行时错误。
### 状态
- 用户仪表盘页面 (`/dashboard`) 目前可以正确加载,并在数据库无数据时显示占位符 ("--")。
- 前后端关于仪表盘核心数据(活动会话、用户月度统计)的对接已基本完成。
## 2024-07-31
### 后端
- **新增** `AdminStatsController.java`,用于提供管理员仪表盘的系统概览统计信息。
- 定义接口 `GET /api/admin/stats/summary`
- **更新** `ChargingRobotService``ChargingRobotServiceImpl`:
- 添加 `countOnlineRobots()`, `countChargingRobots()`, `countIdleRobots()` 方法用于统计不同状态的机器人数量。
- **更新** `ChargingSessionService``ChargingSessionServiceImpl`:
- 添加 `countTodaySessions()` 方法用于统计今日充电会话总数。
- 添加 `sumTodayRevenue()` 方法用于统计今日总收入 (基于已支付的会话,假设存在 `payment_time` 字段)。
- **更新** `ParkingSpotService``ParkingSpotServiceImpl`:
- 添加 `countAvailableSpots()` 方法用于统计可用车位数量。
- `UserService` 中的 `count()` 方法可直接用于获取总用户数。
### 前端
- **下一步**: 修改 `/admin/dashboard/page.tsx` 以调用新的 `GET /api/admin/stats/summary` 接口并显示数据。
## 2024-07-31 (续)
* **修复**: 修正 `ChargingSessionServiceImpl.java` 中的 `countActiveSessions` 方法,将无法识别的枚举 `ChargingSessionStatusEnum.CHARGING_ENDED` 替换为正确的 `ChargingSessionStatusEnum.PAYMENT_PENDING`,以解决编译错误,并确保正确统计等待支付的活动会话。
* **功能增强**: 更新管理员仪表盘统计接口 (`/api/admin/stats/summary`) 以提供更详细的机器人状态统计。
*`AdminDashboardStatsVO.java` 中添加了 `onlineRobots`, `chargingRobots`, `idleRobots` 字段。
* 确认 `ChargingRobotService` 接口及其实现 `ChargingRobotServiceImpl` 中已存在获取这些细分状态机器人数量的方法 (`countOnlineRobots`, `countChargingRobots`, `countIdleRobots`)。
* 修改 `AdminStatsController.java` 中的 `getAdminDashboardStats` 方法,使其调用上述服务层方法,并将统计结果填充到 `AdminDashboardStatsVO` 中。
* **前端修复**: 修正 `charging_web_app/src/app/(authenticated)/admin/dashboard/page.tsx` 页面:
* 调整了从 `/api/admin/stats/summary` 接口获取数据后的处理逻辑,确保正确从 `response.data.data` 提取实际的统计对象。
* 更新了前端 `AdminStats` 接口定义,使其字段与后端 `AdminDashboardStatsVO` 返回的字段(如 `activeSessions`, `totalRevenue`)保持一致。
* 修改了JSX部分对统计数据的引用以正确显示机器人状态、活动会话和总收入。车位统计数据因后端VO暂未提供而显示为占位符。
* **功能增强**: 为管理员仪表盘添加车位统计功能:
* **后端**:
*`AdminDashboardStatsVO.java` 中添加了 `totalParkingSpots``availableParkingSpots` 字段。
* 确认 `ParkingSpotService` 已有 `countAvailableSpots()` 方法,并可使用 `count()` 获取总车位数。
*`AdminStatsController.java` 中注入 `ParkingSpotService`,调用相应方法获取车位统计数据,并填充到 `AdminDashboardStatsVO`
* **前端** (`charging_web_app/src/app/(authenticated)/admin/dashboard/page.tsx`):
*`AdminStats` 接口中添加了 `totalParkingSpots``availableParkingSpots` 字段。
* 更新了"车位统计"卡片的JSX以显示从API获取的实际车位总数和可用数量。
* **测试数据**: 为 `userId = 5` 的用户生成了SQL假数据用于测试用户中心仪表盘的功能包括本月充电次数、本月总消费和当前正在进行的充电状态。这些数据涉及 `charging_session` 表的插入,以及对 `charging_robot``parking_spot` 表状态的相应更新。
* **前端修复**: 修正用户中心仪表盘 (`charging_web_app/src/app/(authenticated)/dashboard/page.tsx`)
* 调整了API调用逻辑以正确处理后端返回的 `BaseResponse` 结构,从 `response.data.data` 提取实际的用户统计和活动会话数据。
* 将前端 `UserDashboardStats` 接口中的 `monthlyCharges` 字段重命名为 `monthlySessions`,以匹配后端服务的返回字段。
* **待办**: 用户需要在 `charging_web_app/src/utils/axios.ts` 文件中手动添加并导出 `BaseResponse` 泛型接口定义以解决Linter错误并使代码正确编译。
* **前端修复**: 修正 `charging_web_app/src/app/(authenticated)/admin/robots/page.tsx` 中的类型比较错误,通过在比较前将 `batteryLevel` 显式转换为数字。
## 2024-08-01
### 前端修复
- **修复** 用户中心仪表盘 (`dashboard/page.tsx`) 中的"本月充电次数"显示为 "--" 的问题:
-`charging_web_app/src/utils/axios.ts` 文件中添加并导出 `BaseResponse<T>` 接口,解决了编译错误。
-`dashboard/page.tsx` 中添加字段映射逻辑,处理后端返回的 `monthlyCharges` 字段与前端 `monthlySessions` 字段的不匹配问题。
- 添加了控制台日志输出便于调试API响应的数据结构。
- 此修复确保了无论后端返回哪种字段名,前端都能正确展示用户的月度充电次数。
- **修复** "我的充电记录" (`my-sessions/page.tsx`) 页面显示"暂无充电记录"的问题:
- 更新了 API 调用,使用正确的 `BaseResponse<Page<ChargingSession>>` 泛型类型来解析后端分页数据。
- 修改了数据提取逻辑,从 `response.data.data` 中获取分页记录,而不是直接从 `response.data` 中获取。
- 添加了控制台日志输出便于调试API响应的数据结构。
- 更新了 Link 组件使用,移除了废弃的 legacyBehavior 属性,符合 Next.js 最新用法。
- **下一步**:
- 删除前端项目中的 `.next` 文件夹,清除构建缓存。
- 重启开发服务器并验证用户仪表盘和充电记录页面数据正确显示。
## 2024-08-02第三阶段开发状态总结
### 已完成的内容
- **后端核心功能**:
- 充电机器人和车位管理实体类、Mapper、Service、Controller等完整实现
- 充电会话管理:创建、状态更新、计费、支付等完整实现
- MQTT集成与机器人通信的消息处理和任务管理
- 后端API管理员和用户所需的全部核心接口
- **前端页面与功能**:
- 用户认证:登录、注册和权限管理
- 用户仪表盘:显示月度充电次数、消费金额和当前充电状态
- "我的充电记录"页面:展示用户的历史充电会话
- 管理员仪表盘:展示系统概览统计信息
- 管理员功能:用户管理、机器人管理、车位管理页面
- 充电请求页面:允许用户选择可用车位并发起充电请求
### 待完成内容
- **前端充电状态页面**
- 显示当前充电状态和详细信息
- 实时更新充电状态和时长通过轮询或WebSocket
- 提供"停止充电"功能
- 显示进度条或动画效果展示充电进度
- **主页充电卡片**
- 在用户仪表盘中增强充电状态卡片的功能
- 加入"停止充电"按钮
- 提供充电实时进度和消费预估
- **管理员监控页面**
- 增强实时监控功能,提供更详细的系统状态视图
- 优化数据展示方式,如使用图表展示统计信息
- **其他优化**
- 前端界面美化和统一风格
- 移动端适配和响应式设计优化
- 增加用户友好的错误处理和提示
- 添加页面间的导航优化
### 优先级任务
1. 实现充电状态实时更新页面,完成用户充电功能的完整闭环
2. 优化用户仪表盘的充电状态展示
3. 增强管理员监控功能
4. 全面UI/UX优化和移动端适配
计划在下一周内完成高优先级任务,确保系统可用于实际充电场景测试。
## 2024-08-03
### 后端
- **修复** `charging_web_app/src/app/(authenticated)/charging-status/page.tsx` 文件创建时由于代码字符串中特殊字符(尤其是反斜杠 `\`)转义不当导致的 "Invalid character" 和 "Unterminated string literal" 错误。
- **重新成功创建** `charging_web_app/src/app/(authenticated)/charging-status/page.tsx` 文件。该文件包含充电状态页面的完整框架,包括:
- 必要的导入和接口定义 (`ActiveChargingSession`)。
- 完善的状态管理 (session 数据, 加载状态, 错误处理, 停止充电状态, 实时充电时长)。
- API 调用逻辑:
- `fetchActiveSession` 用于获取活动会话,包含错误处理。
- 基于 `useEffect` 的轮询机制,用于定期刷新会话状态。
- 会话结束时自动跳转到仪表盘的逻辑。
- `calculateDuration` 辅助函数和 `useEffect` 钩子,用于实时计算和显示充电时长。
- `handleStopCharging` 函数,用于处理停止充电请求,包含加载状态和错误处理。
- UI 渲染逻辑:
- 认证状态检查和加载状态显示。
- 错误信息提示。
- 无活动会话时的提示和操作引导。
- 活动会话详情展示 (ID, 状态, 车位, 机器人, 开始时间, **实时充电时长**)。
- 基于时间的充电进度条。
- 条件显示的"停止充电"按钮。
- 充电完成/待支付状态的提示信息。
### 后续步骤
1. **测试与完善充电状态页面 (`charging_web_app/src/app/(authenticated)/charging-status/page.tsx`)**:
* 验证页面是否能正确加载并显示活动的充电会话信息。
* **关键**:确保"停止充电"按钮在正确的会话状态 (`ROBOT_ASSIGNED`, `ROBOT_ARRIVED`, `CHARGING_STARTED`, `CHARGING_IN_PROGRESS`) 下正确显示。
* 测试实时充电时长的更新是否准确。
* 测试轮询机制是否按预期工作。
* 测试"停止充电"功能是否能成功发送请求,后端是否正确生成 `STOP_CHARGE` 类型的 `RobotTask` 并通过 MQTT 发送给机器人,以及前端是否能正确更新状态。
* 验证错误处理和各种边界条件如无活动会话、API 请求失败、机器人响应超时或错误等)下的 UI 显示。
* 检查会话结束后是否正确跳转或显示完成/待支付信息。
2. **用户仪表盘 (`charging_web_app/src/app/(authenticated)/dashboard/page.tsx`) 增强**:
* 优化仪表盘上活动充电会话卡的显示内容,使其更信息丰富。
* 考虑在活动会话卡上添加快捷操作,例如:
* "查看详情"按钮/链接,直接跳转到对应的 `charging-status/page.tsx` 页面。
* 如果会话处于可停止状态,可考虑直接在仪表盘提供"停止充电"按钮并确保其行为与充电状态页一致调用后端API后端同步给机器人
3. **"我的充电记录" (`charging_web_app/src/app/(authenticated)/my-sessions/page.tsx`) 页面**:
* 继续跟踪并解决之前可能存在的数据显示问题(例如,后端返回 `null` 值导致前端显示 `--` 的情况),确保所有字段正确展示。
4. **UI/UX 整体优化**:
* 对已完成和新开发的页面(用户仪表盘、充电请求、充电状态、我的充电记录、管理员机器人/车位管理等)进行全面的用户界面 (UI) 和用户体验 (UX) 审查。
* 改进视觉一致性、交互流程的顺畅性以及信息展示的清晰度。
5. **移动端适配**:
* 系统性地测试所有核心用户页面在不同尺寸移动设备上的显示效果和可操作性。
* 修复响应式布局问题,确保移动端用户体验良好。
### 注意事项
* 在进行任何前端功能更改或测试前,务必确保后端服务运行正常,并且 MQTT 通信链路畅通,以便正确模拟和测试与机器人相关的操作。
* 对于涉及与机器人交互的功能(如停止充电),需要密切关注后端日志和(如果可能)机器人端的日志,以确认指令传递和执行的完整流程。
* 开发过程中,继续保持 `LogBook.md` 的更新,记录重要的决策、变更和已完成的任务。
### 后续开发任务 (2024-08-03)
1. **测试与完善充电状态页面 (`charging_web_app/src/app/(authenticated)/charging-status/page.tsx`)**:
* 验证页面是否能正确加载并显示活动的充电会话信息。
* **关键**:确保"停止充电"按钮在正确的会话状态 (`ROBOT_ASSIGNED`, `ROBOT_ARRIVED`, `CHARGING_STARTED`, `CHARGING_IN_PROGRESS`) 下正确显示。
* 测试实时充电时长的更新是否准确。
* 测试轮询机制是否按预期工作。
* 测试"停止充电"功能是否能成功发送请求,后端是否正确生成 `STOP_CHARGE` 类型的 `RobotTask` 并通过 MQTT 发送给机器人,以及前端是否能正确更新状态。
* 验证错误处理和各种边界条件如无活动会话、API 请求失败、机器人响应超时或错误等)下的 UI 显示。
* 检查会话结束后是否正确跳转或显示完成/待支付信息。
2. **用户仪表盘 (`charging_web_app/src/app/(authenticated)/dashboard/page.tsx`) 增强**:
* 优化仪表盘上活动充电会话卡的显示内容,使其更信息丰富。
* 考虑在活动会话卡上添加快捷操作,例如:
* "查看详情"按钮/链接,直接跳转到对应的 `charging-status/page.tsx` 页面。
* 如果会话处于可停止状态,可考虑直接在仪表盘提供"停止充电"按钮并确保其行为与充电状态页一致调用后端API后端同步给机器人
3. **"我的充电记录" (`charging_web_app/src/app/(authenticated)/my-sessions/page.tsx`) 页面**:
* 继续跟踪并解决之前可能存在的数据显示问题(例如,后端返回 `null` 值导致前端显示 `--` 的情况),确保所有字段正确展示。
4. **UI/UX 整体优化**:
* 对已完成和新开发的页面(用户仪表盘、充电请求、充电状态、我的充电记录、管理员机器人/车位管理等)进行全面的用户界面 (UI) 和用户体验 (UX) 审查。
* 改进视觉一致性、交互流程的顺畅性以及信息展示的清晰度。
5. **移动端适配**:
* 系统性地测试所有核心用户页面在不同尺寸移动设备上的显示效果和可操作性。
* 修复响应式布局问题,确保移动端用户体验良好。
### 注意事项
* 在进行任何前端功能更改或测试前,务必确保后端服务运行正常,并且 MQTT 通信链路畅通,以便正确模拟和测试与机器人相关的操作。
* 对于涉及与机器人交互的功能(如停止充电),需要密切关注后端日志和(如果可能)机器人端的日志,以确认指令传递和执行的完整流程。
* 开发过程中,继续保持 `LogBook.md` 的更新,记录重要的决策、变更和已完成的任务。
## 2024-08-03 (紧急修复)
### 后端更新
1. **MQTT服务优先级机制**
* 增强了 `MqttServiceImpl.java` 中的 `sendCommand` 方法,为 `STOP_CHARGE` 命令添加优先级处理
* 修复了用户无法停止充电的问题:以前当机器人有未完成任务(`PENDING``SENT` 状态)时,停止充电命令会被拒绝
* 现在 `STOP_CHARGE` 命令被视为高优先级指令,即使机器人有其他待处理任务也允许发送
* 主要改动:
```java
// 原始逻辑
if (robotTaskService.hasPendingOrSentTask(robotId)) {
log.warn("Robot {} is busy (has PENDING or SENT tasks). Command {} aborted.", robotId, commandType);
return false;
}
// 改进后的逻辑
if (robotTaskService.hasPendingOrSentTask(robotId)) {
// 添加优先级处理STOP_CHARGE 命令应该可以覆盖其他任务
if (CommandTypeEnum.STOP_CHARGE.equals(commandType)) {
log.info("Robot {} has pending tasks, but STOP_CHARGE is a priority command, proceeding anyway.", robotId);
} else {
log.warn("Robot {} is busy (has PENDING or SENT tasks). Command {} aborted.", robotId, commandType);
return false;
}
}
```
### 解决的问题
1. **停止充电指令失败问题**
* 修复了用户在充电状态页面点击"停止充电"按钮,但系统报错 `"发送MQTT停止指令失败"` 的问题
* 错误日志显示:`Robot RBT-001 is busy (has PENDING or SENT tasks). Command STOP_CHARGE aborted.`
* 根本原因:系统设计中,机器人任务是线性处理的,每次只能有一个活动任务。但对于停止充电这类关键安全操作,应该具有更高优先级
2. **用户体验改进**
* 提高了系统的可靠性,确保用户能够在任何情况下停止充电过程
* 特别是在紧急情况下,用户可以立即停止充电,而不受机器人当前任务状态的限制
### 后续观察
1. **监控与验证**
* 需要测试修改后的代码在实际场景中的表现
* 重点关注机器人接收 `STOP_CHARGE` 指令时的行为,确保它能正确处理优先级命令
* 监控日志中是否还有其他相关的错误或异常
## 2024-08-03 (后续更新)
### 前端更新
1. **用户仪表盘优化 (`dashboard/page.tsx`)**
* 为活动充电会话区域添加了一个"查看详情和控制"按钮,该按钮直接链接到充电状态详情页面。
* 添加了简短的提示文本,引导用户点击按钮查看详情和执行操作。
2. **充电状态页面改进 (`charging-status/page.tsx`)**
* 添加了详细的控制台日志输出,便于调试和监控会话状态变化。
* 改进了会话状态的显示逻辑:
* 对于不显示"停止充电"按钮的状态,现在会显示一个黄色提示框,明确指出当前状态及不能执行停止操作的原因。
* 将终止状态定义为全局常量 `TERMINAL_STATUSES`,使代码更易维护。
* 修复了充电进度动画的显示问题:
* 在 `globals.css` 中添加了自定义的 `animate-pulse-fast` 动画定义。
### 存在的问题
1. **可能的数据问题**
* 尽管用户仪表盘上能够显示 `CHARGING_STARTED` 状态的会话,但在充电状态页面上可能无法正确接收相同的数据。
* 需要检查 `/session/my/active` API 在不同路径(仪表盘和充电状态页面)上返回的数据是否一致。
2. **浏览器缓存问题**
* 建议用户清除浏览器缓存以确保最新修改生效,尤其是针对 CSS 动画的更改。
### 下一步操作
1. **测试**
* 使用开发者工具监控网络请求和控制台输出,确保 API 调用正常且返回预期的数据。
* 验证"停止充电"功能是否正确发送请求并与后端/机器人同步。
2. **用户反馈收集**
* 收集用户对改进后的充电状态页面的反馈,特别是关于用户体验和功能可用性方面。
3. **继续完善**
* 根据收集到的反馈和实际使用情况,进一步优化用户界面和功能。
我将等待您对充电状态页面测试结果的反馈。
## 2024-08-04
### 前端更新
1. **用户仪表盘优化 (`dashboard/page.tsx`)**
* 增强了当前充电状态卡片的功能,添加了直接从仪表盘停止充电的能力:
* 原先的"查看详情和控制"按钮改名为"查看详细状态",保留页面跳转功能。
* 为处于活动状态 (`ROBOT_ASSIGNED`, `ROBOT_ARRIVED`, `CHARGING_STARTED`, `CHARGING_IN_PROGRESS`) 的充电会话添加了"停止充电"按钮。
* 实现了 `handleStopCharging` 函数,调用 `/api/session/stop` API 停止充电。
* 添加了停止充电的加载状态、错误显示等用户体验优化。
* 优化了按钮布局,在桌面端显示为水平排列,在移动端显示为垂直排列,提升了响应式体验。
* 增加了错误提示区域,当停止充电操作失败时显示具体错误信息。
2. **用户体验改进**
* 重要的操作(如停止充电)现在可以直接从用户仪表盘执行,无需导航到详细页面。
* 用户能够在系统的任何状态下停止充电,这得益于后端 `MqttServiceImpl` 中实现的优先级处理机制。
* 用户界面提供了清晰的视觉反馈,指示操作状态(加载中、成功、失败)。
### 后续计划
1. **跟踪停止充电功能的使用情况**
* 监控用户从仪表盘和充电状态详情页执行停止充电操作的情况。
* 收集用户反馈,了解这种双入口设计的实际使用体验。
2. **考虑更进一步的用户体验优化**
* 根据用户反馈考虑添加确认对话框,避免误操作。
* 研究是否需要添加会话状态的图形化指示器(如状态标签的颜色编码、图标等)。
3. **继续完成其他待办任务**
* 主要工作集中在 UI/UX 整体优化和移动端适配任务4和5

View File

@@ -12,7 +12,8 @@
"axios": "^1.9.0", "axios": "^1.9.0",
"next": "15.3.2", "next": "15.3.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"react-icons": "^5.5.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
@@ -5783,6 +5784,15 @@
"react": "^19.1.0" "react": "^19.1.0"
} }
}, },
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmmirror.com/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",

View File

@@ -13,7 +13,8 @@
"axios": "^1.9.0", "axios": "^1.9.0",
"next": "15.3.2", "next": "15.3.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"react-icons": "^5.5.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",

View File

@@ -1,66 +1,223 @@
'use client'; 'use client';
import React from 'react'; import React, { useState, useEffect } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import LoadingSpinner from '@/components/LoadingSpinner'; import LoadingSpinner from '@/components/LoadingSpinner';
import { FiUsers, FiCpu, FiBarChart2, FiGrid, FiUserCheck, FiList, FiTerminal, FiSettings } from 'react-icons/fi';
import { api } from '@/utils/axios';
import { AxiosError, AxiosResponse } from 'axios';
const AdminDashboardPage: React.FC = () => { // 匹配后端 AdminDashboardStatsVO
const { user, logout, isLoading, isAuthenticated } = useAuth(); interface AdminStats {
totalUsers: number;
totalRobots: number; // 后端有这个字段
onlineRobots: number;
chargingRobots: number;
idleRobots: number;
activeSessions: number; // 后端叫 activeSessions
totalRevenue: number; // 后端叫 totalRevenue
totalParkingSpots?: number; // 新增,设为可选以处理数据可能暂时未返回的情况
availableParkingSpots?: number; // 新增,设为可选
// 下面两个字段在当前的后端 AdminDashboardStatsVO 中不存在,但在前端 AdminStats 接口中存在
// totalChargingSessionsToday: number;
// totalRevenueToday: number;
// 下面这两个字段也需要确认后端VO中是否有对应暂时先用后端有的字段
// totalParkingSpots: number;
// availableParkingSpots: number;
}
// 建议定义一个通用的 BaseResponse 接口
interface BackendBaseResponse<T> {
data: T;
message: string;
code?: number;
}
const AdminDashboardPage = () => {
const { user, isLoading, isAuthenticated } = useAuth();
const router = useRouter(); const router = useRouter();
const [adminStats, setAdminStats] = useState<AdminStats | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const [statsError, setStatsError] = useState<string | null>(null);
if (isLoading) { useEffect(() => {
return ( if (!isLoading && !isAuthenticated) {
<div className="flex items-center justify-center min-h-screen"> router.replace('/login');
<LoadingSpinner /> return;
}
if (isAuthenticated && user?.role !== 'admin') {
router.replace('/dashboard');
}
}, [isLoading, isAuthenticated, user, router]);
useEffect(() => {
if (isAuthenticated && user?.role === 'admin') {
setStatsLoading(true);
setStatsError(null);
api.get<BackendBaseResponse<AdminStats>>('/admin/stats/summary') // 使用 BackendBaseResponse
.then((response: AxiosResponse<BackendBaseResponse<AdminStats>>) => {
if (response.data && response.data.data) {
// 后端返回的 totalRevenue 对应前端接口的 totalRevenueToday
// 后端返回的 activeSessions 对应前端接口的 totalChargingSessionsToday
// 但为了保持一致性我们最好让前端AdminStats接口的字段名与后端VO的字段名一致
// 这里暂时直接使用后端返回的字段名,前端 JSX 部分也需要对应调整
setAdminStats(response.data.data);
} else {
console.error("Failed to fetch admin stats or data is null/malformed:", response);
setStatsError("加载统计数据失败: 数据为空或格式错误。");
setAdminStats(null);
}
})
.catch((error: AxiosError) => {
console.error("Failed to fetch admin stats:", error);
if (error.response) {
setStatsError(`加载统计数据失败: ${error.response.status} ${error.response.statusText}`);
} else if (error.request) {
setStatsError("加载统计数据失败: 未收到服务器响应。");
} else {
setStatsError("加载统计数据失败: 请求设置时出错。");
}
setAdminStats(null);
})
.finally(() => {
setStatsLoading(false);
});
}
}, [isAuthenticated, user]);
if (isLoading || (!isLoading && !isAuthenticated)) {
return <LoadingSpinner />;
}
if (isAuthenticated && user?.role !== 'admin') {
return <LoadingSpinner />;
}
if (!user) {
return <LoadingSpinner />;
}
const StatCard = ({ title, value, icon, subValue, unit }: { title: string; value: string | number; icon: React.ReactNode; subValue?: string, unit?: string }) => (
<div className="bg-white p-5 rounded-xl shadow-lg flex items-start space-x-3 transform transition-all hover:shadow-xl hover:-translate-y-1 min-h-[100px]">
<div className="p-3 bg-indigo-100 text-indigo-600 rounded-full">
{icon}
</div>
<div>
<p className="text-sm text-gray-500 font-medium">{title}</p>
<p className="text-2xl font-bold text-gray-800">
{value}
{unit && <span className="text-lg ml-1">{unit}</span>}
</p>
{subValue && <p className="text-xs text-gray-400 mt-1">{subValue}</p>}
</div>
</div> </div>
); );
}
if (!isAuthenticated || !user) { const NavCard = ({ title, description, href, icon, disabled }: { title: string; description: string; href: string; icon: React.ReactNode; disabled?: boolean }) => (
// AuthenticatedLayout 应该已经处理了重定向 <Link
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>; href={disabled ? '#' : href}
} legacyBehavior={false}
className={`block p-6 rounded-lg shadow-lg transform transition-all hover:shadow-xl hover:scale-105
// 如果用户不是管理员则重定向到普通用户dashboard ${disabled ? 'bg-gray-200 cursor-not-allowed' : 'bg-gradient-to-br from-indigo-600 to-blue-500 hover:from-indigo-700 hover:to-blue-600 text-white'}
if (user.role !== 'admin') { `}
router.replace('/dashboard'); onClick={(e) => {
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>; // 显示加载直到重定向完成 if (disabled) {
e.preventDefault();
} }
}}
aria-disabled={disabled}
>
<div className="flex items-center mb-2">
<span className={disabled ? 'text-gray-500' : 'text-indigo-100'}>{icon}</span>
<h3 className={`ml-3 text-xl font-semibold ${disabled ? 'text-gray-700' : 'text-white'}`}>{title}</h3>
</div>
<p className={`text-sm ${disabled ? 'text-gray-600' : 'opacity-90 text-indigo-50'}`}>{description}</p>
</Link>
);
return ( return (
<div className="container mx-auto p-4 pt-20"> <div className="min-h-screen bg-gray-100 py-8 px-4 md:px-6 lg:px-8">
<div className="bg-white shadow-md rounded-lg p-8 max-w-2xl mx-auto"> <div className="max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-6 text-center text-gray-800">
</h1>
<p className="text-xl mb-4 text-gray-700">
, <span className="font-semibold">{user.username}</span>!
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8"> <h1 className="text-3xl font-bold text-gray-800 mb-8"></h1>
<Link href="/admin/user-management" legacyBehavior>
<a className="block p-6 bg-blue-500 hover:bg-blue-600 text-white rounded-lg shadow-md transition duration-150 ease-in-out text-center"> <div className="mb-10">
<h2 className="text-xl font-semibold"></h2> <h2 className="text-2xl font-semibold text-gray-700 mb-4"></h2>
<p></p> {statsLoading ? (
</a> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
</Link> {[...Array(4)].map((_, i) => (
{/* 可以添加更多管理员功能模块链接 */} <div key={i} className="bg-white p-5 rounded-xl shadow-lg h-28 animate-pulse">
<div className="block p-6 bg-gray-200 text-gray-500 rounded-lg shadow-md text-center"> <div className="h-6 bg-gray-200 rounded w-1/3 mb-2"></div>
<h2 className="text-xl font-semibold"> ()</h2> <div className="h-8 bg-gray-300 rounded w-1/2"></div>
<p></p> </div>
))}
</div>
) : statsError ? (
<p className="text-red-600 bg-red-100 p-4 rounded-md">{statsError}</p>
) : adminStats ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
<StatCard title="总用户数" value={adminStats.totalUsers ?? '--'} icon={<FiUsers size={24} />} />
<StatCard
title="机器人状态 (在线/充电/空闲)"
value={`${adminStats.onlineRobots ?? '--'} / ${adminStats.chargingRobots ?? '--'} / ${adminStats.idleRobots ?? '--'}`}
icon={<FiCpu size={24} />}
/>
<StatCard
title="车位统计 (总数/可用)"
value={`${adminStats.totalParkingSpots ?? '--'} / ${adminStats.availableParkingSpots ?? '--'}`}
icon={<FiGrid size={24} />}
/>
<StatCard
title="概览 (活动会话/总收入)"
value={`${adminStats.activeSessions ?? '--'} / ¥${(adminStats.totalRevenue ?? 0).toFixed(2)}`}
icon={<FiBarChart2 size={24} />}
/>
</div>
) : (
<p className="text-gray-600"></p>
)}
</div>
<div>
<h2 className="text-2xl font-semibold text-gray-700 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<NavCard
title="车位管理"
description="管理充电车位信息、状态等"
href="/admin/parking-spots"
icon={<FiGrid size={28}/>}
/>
<NavCard
title="机器人管理"
description="管理充电机器人、状态、任务分配"
href="/admin/robots"
icon={<FiTerminal size={28}/>}
/>
<NavCard
title="会话管理"
description="查看所有充电会话记录、详情"
href="/admin/sessions"
icon={<FiList size={28}/>}
disabled={true}
/>
<NavCard
title="用户管理"
description="查看和管理平台用户列表"
href="/admin/user-management"
icon={<FiUserCheck size={28}/>}
/>
<NavCard
title="系统设置"
description="配置系统参数、费率等"
href="/admin/settings"
icon={<FiSettings size={28}/>}
disabled={true}
/>
</div> </div>
</div> </div>
<button
onClick={async () => {
await logout();
}}
className="w-full bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-4 rounded-lg transition duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75"
>
</button>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,803 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import { api } from '@/services/api';
import LoadingSpinner from '@/components/LoadingSpinner';
import Modal from '@/components/Modal';
import Link from 'next/link';
// AuthenticatedLayout is likely applied via the directory structure (app/(authenticated)/layout.tsx)
// So, no explicit import should be needed here for wrapping the page content.
// Define interfaces for ParkingSpot and query params based on backend DTOs
interface ParkingSpot {
id: number;
spotUid: string;
locationDesc: string | null;
status: string; // e.g., AVAILABLE, OCCUPIED, MAINTENANCE, RESERVED
robotAssignable: boolean;
currentSessionId: number | null;
createTime: string;
updateTime: string;
}
interface ParkingSpotQueryRequest {
spotUid?: string;
status?: string;
robotAssignable?: boolean | string; // Allow string for 'ALL' option in select
current?: number;
pageSize?: number;
sortField?: string;
sortOrder?: string; // asc or desc
}
// Define interface for Add Parking Spot Request based on backend DTO
interface ParkingSpotAddRequest {
spotUid: string;
locationDesc?: string;
status: string; // Default to AVAILABLE
robotAssignable: boolean; // Default to true
}
// Define interface for Update Parking Spot Request based on backend DTO
interface ParkingSpotUpdateRequest {
id: number; // Crucial for identifying the spot to update
spotUid?: string; // Usually not updatable, but depends on backend logic
locationDesc?: string;
status?: string;
robotAssignable?: boolean;
}
const AdminParkingSpotsPage = () => {
const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
const [isAdmin, setIsAdmin] = useState(false);
const [parkingSpots, setParkingSpots] = useState<ParkingSpot[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [totalSpots, setTotalSpots] = useState(0);
// State for query params and pagination
const [queryParams, setQueryParams] = useState<ParkingSpotQueryRequest>({
current: 1,
pageSize: 10,
sortField: 'create_time',
sortOrder: 'desc',
});
// --- Notification State ---
const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const showNotification = (message: string, type: 'success' | 'error') => {
setNotification({ message, type });
setTimeout(() => {
setNotification(null);
}, 3000); // Notification disappears after 3 seconds
};
// CRUD Modal States (Add, Edit, Delete)
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [newSpotData, setNewSpotData] = useState<ParkingSpotAddRequest>({
spotUid: '',
locationDesc: '',
status: 'AVAILABLE',
robotAssignable: true,
});
const [addSpotLoading, setAddSpotLoading] = useState(false);
const [addSpotError, setAddSpotError] = useState<string | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingSpot, setEditingSpot] = useState<ParkingSpotUpdateRequest | null>(null);
const [editSpotLoading, setEditSpotLoading] = useState(false);
const [editSpotError, setEditSpotError] = useState<string | null>(null);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
const [deletingSpotInfo, setDeletingSpotInfo] = useState<{ id: number; spotUid: string } | null>(null);
const [deleteSpotLoading, setDeleteSpotLoading] = useState(false);
const [deleteSpotError, setDeleteSpotError] = useState<string | null>(null);
// Search/Filter states
const [searchSpotUid, setSearchSpotUid] = useState('');
const [searchStatus, setSearchStatus] = useState('');
const [searchRobotAssignable, setSearchRobotAssignable] = useState('');
useEffect(() => {
if (!authLoading) {
if (!user) {
router.push('/login');
} else {
// Updated admin check based on user.role
const isAdminUser = user?.role === 'admin';
setIsAdmin(isAdminUser);
if (!isAdminUser) {
router.push('/dashboard'); // Redirect non-admins
}
}
}
}, [user, authLoading, router]); // Removed checkAdmin from dependencies
const fetchParkingSpots = useCallback(async (overrideParams?: Partial<ParkingSpotQueryRequest>) => {
if (!isAdmin) return; // Don't fetch if not admin
setLoading(true);
setError(null);
const currentSearchParams: ParkingSpotQueryRequest = {
...queryParams,
spotUid: searchSpotUid.trim() || undefined,
status: searchStatus || undefined,
robotAssignable: searchRobotAssignable === '' ? undefined : searchRobotAssignable === 'true',
...overrideParams, // Apply overrides, e.g., for page changes or new searches
};
// Remove undefined keys to keep the request clean
Object.keys(currentSearchParams).forEach(key =>
(currentSearchParams as any)[key] === undefined && delete (currentSearchParams as any)[key]
);
try {
const response = await api.post('/admin/spot/list/page', currentSearchParams);
if (response.data && response.data.code === 0) {
setParkingSpots(response.data.data.records || []);
setTotalSpots(response.data.data.total || 0);
// Update queryParams state if overrideParams were passed (e.g. page change)
// but not for initial search filter application as that's handled by search button
if (overrideParams && Object.keys(overrideParams).length > 0 && !overrideParams.spotUid && !overrideParams.status && !overrideParams.robotAssignable ){
setQueryParams(prev => ({...prev, ...overrideParams}));
}
} else {
setError(response.data.message || '获取车位列表失败');
setParkingSpots([]);
setTotalSpots(0);
}
} catch (err: any) {
console.error('获取车位列表错误:', err);
setError(err.message || '发生未知错误。');
setParkingSpots([]);
setTotalSpots(0);
}
setLoading(false);
}, [isAdmin, queryParams, searchSpotUid, searchStatus, searchRobotAssignable]); // Added search states to dependencies
useEffect(() => {
if (isAdmin) { // Only fetch if confirmed admin
fetchParkingSpots();
}
}, [fetchParkingSpots, isAdmin]);
const handlePageChange = (newPage: number) => {
fetchParkingSpots({ current: newPage });
setQueryParams(prev => ({ ...prev, current: newPage })); // Keep queryParams state in sync for display/logic
};
const handlePageSizeChange = (newSize: number) => {
fetchParkingSpots({ pageSize: newSize, current: 1 });
setQueryParams(prev => ({ ...prev, pageSize: newSize, current: 1 })); // Reset to first page
};
// --- Table Sorting ---
const handleSort = (fieldKey: keyof ParkingSpot) => {
// Map frontend display key (camelCase) to backend sortField (snake_case)
let sortFieldForBackend = '';
if (fieldKey === 'createTime') {
sortFieldForBackend = 'create_time';
} else if (fieldKey === 'updateTime') {
sortFieldForBackend = 'update_time';
} else if (fieldKey === 'spotUid') {
sortFieldForBackend = 'spot_uid'; // example if spot_uid in DB
} else {
sortFieldForBackend = fieldKey; // For other fields like status, robotAssignable if they are directly mapped
}
const newSortOrder = queryParams.sortField === sortFieldForBackend && queryParams.sortOrder === 'asc' ? 'desc' : 'asc';
const newQueryParams = { ...queryParams, sortField: sortFieldForBackend, sortOrder: newSortOrder, current: 1 };
setQueryParams(newQueryParams);
fetchParkingSpots(newQueryParams);
};
// --- Search/Filter Logic ---
const handleSearch = () => {
const newQueryParams = {
...queryParams, // Keep current sort and page size
current: 1,
spotUid: searchSpotUid.trim() || undefined,
status: searchStatus || undefined,
robotAssignable: searchRobotAssignable === '' ? undefined : searchRobotAssignable === 'true',
};
setQueryParams(newQueryParams);
fetchParkingSpots(newQueryParams);
};
const handleResetSearch = () => {
setSearchSpotUid('');
setSearchStatus('');
setSearchRobotAssignable('');
const baseParams = {
current: 1,
pageSize: queryParams.pageSize, // Retain current page size
sortField: 'create_time', // Default sort
sortOrder: 'desc', // Default sort order
spotUid: undefined,
status: undefined,
robotAssignable: undefined,
}
setQueryParams(baseParams);
fetchParkingSpots(baseParams);
};
// --- Add New Spot Modal Logic ---
const openAddModal = () => {
setNewSpotData({ // Reset form data
spotUid: '',
locationDesc: '',
status: 'AVAILABLE',
robotAssignable: true,
});
setAddSpotError(null);
setIsAddModalOpen(true);
};
const closeAddModal = () => {
setIsAddModalOpen(false);
};
const handleNewSpotDataChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
const checked = (e.target as HTMLInputElement).checked; // For checkbox
setNewSpotData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
const handleAddNewSpot = async () => {
if (!newSpotData.spotUid.trim()) {
setAddSpotError('车位UID是必填项。');
return;
}
const spotUidRegex = /^[a-zA-Z0-9]{3,20}$/;
if (!spotUidRegex.test(newSpotData.spotUid.trim())) {
setAddSpotError('车位UID必须是3-20位字母或数字。');
return;
}
if (newSpotData.locationDesc && newSpotData.locationDesc.length > 200) {
setAddSpotError('位置描述不能超过200个字符。');
return;
}
setAddSpotLoading(true);
setAddSpotError(null);
try {
const response = await api.post('/admin/spot/add', newSpotData);
if (response.data && response.data.code === 0) {
closeAddModal();
fetchParkingSpots({current: queryParams.current});
showNotification('车位添加成功!', 'success');
} else {
setAddSpotError(response.data.message || '添加车位失败。');
showNotification(response.data.message || '添加车位失败。', 'error');
}
} catch (err: any) {
console.error('添加车位错误:', err);
setAddSpotError(err.message || '添加车位时发生未知错误。');
showNotification(err.message || '添加车位时发生未知错误。', 'error');
}
setAddSpotLoading(false);
};
// --- Edit Spot Modal Logic ---
const openEditModal = (spot: ParkingSpot) => {
setEditingSpot({
id: spot.id,
spotUid: spot.spotUid,
locationDesc: spot.locationDesc || '',
status: spot.status,
robotAssignable: spot.robotAssignable,
});
setEditSpotError(null);
setIsEditModalOpen(true);
};
const closeEditModal = () => {
setIsEditModalOpen(false);
setEditingSpot(null);
};
const handleEditingSpotDataChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
const checked = (e.target as HTMLInputElement).checked;
setEditingSpot(prev => prev ? { ...prev, [name]: type === 'checkbox' ? checked : value } : null);
};
const handleUpdateSpot = async () => {
if (!editingSpot) return;
if (editingSpot.locationDesc && editingSpot.locationDesc.length > 200) {
setEditSpotError('位置描述不能超过200个字符。');
return;
}
setEditSpotLoading(true);
setEditSpotError(null);
try {
const response = await api.post('/admin/spot/update', editingSpot);
if (response.data && response.data.code === 0) {
closeEditModal();
fetchParkingSpots({current: queryParams.current});
showNotification('车位更新成功!', 'success');
} else {
setEditSpotError(response.data.message || '更新车位失败。');
showNotification(response.data.message || '更新车位失败。', 'error');
}
} catch (err: any) {
console.error('更新车位错误:', err);
setEditSpotError(err.message || '更新车位时发生未知错误。');
showNotification(err.message || '更新车位时发生未知错误。', 'error');
}
setEditSpotLoading(false);
};
// --- Delete Spot Confirmation Logic ---
const openDeleteConfirm = (spot: ParkingSpot) => {
setDeletingSpotInfo({ id: spot.id, spotUid: spot.spotUid });
setDeleteSpotError(null);
setIsDeleteConfirmOpen(true);
};
const closeDeleteConfirm = () => {
setIsDeleteConfirmOpen(false);
setDeletingSpotInfo(null);
};
const handleDeleteSpot = async () => {
if (!deletingSpotInfo) return;
setDeleteSpotLoading(true);
setDeleteSpotError(null);
try {
const response = await api.post('/admin/spot/delete', { id: deletingSpotInfo.id });
if (response.data && response.data.code === 0) {
closeDeleteConfirm();
// After deletion, decide which page to fetch. If current page becomes empty, fetch previous page or page 1.
// For simplicity, refetching all or current page based on remaining items.
const newTotalSpots = totalSpots - 1;
const newCurrentPage = Math.max(1, Math.ceil(newTotalSpots / (queryParams.pageSize || 10)));
if (queryParams.current && queryParams.current > newCurrentPage && newCurrentPage > 0) {
fetchParkingSpots({ current: newCurrentPage });
} else {
fetchParkingSpots({current: queryParams.current}); // Or just fetch the current page
}
showNotification(`车位 ${deletingSpotInfo.spotUid} 删除成功!`, 'success');
} else {
setDeleteSpotError(response.data.message || '删除车位失败。');
showNotification(response.data.message || '删除车位失败。', 'error');
}
} catch (err: any) {
console.error('删除车位错误:', err);
setDeleteSpotError(err.message || '删除车位时发生未知错误。');
showNotification(err.message || '删除车位时发生未知错误。', 'error');
}
setDeleteSpotLoading(false);
};
const SortIndicator: React.FC<{ field: keyof ParkingSpot }> = ({ field }) => {
// Compare with the backend's expected snake_case field name for active sort column
let backendSortField = queryParams.sortField;
if (field === 'createTime' && queryParams.sortField === 'create_time') {
// Correctly show indicator if frontend 'createTime' maps to backend 'create_time'
} else if (field === 'updateTime' && queryParams.sortField === 'update_time') {
// Correctly show indicator if frontend 'updateTime' maps to backend 'update_time'
} else if (field === 'spotUid' && queryParams.sortField === 'spot_uid') {
// Correctly show indicator
} else if (queryParams.sortField !== field) {
return null; // Not the active sort column (after mapping)
}
if (queryParams.sortField === (field === 'createTime' ? 'create_time' : field === 'updateTime' ? 'update_time' : field === 'spotUid' ? 'spot_uid' : field)) {
return queryParams.sortOrder === 'asc' ? <span className="ml-1"></span> : <span className="ml-1"></span>;
}
return null;
};
if (authLoading || (!isAdmin && user)) {
return <LoadingSpinner />;
}
if (!user) {
return <p>...</p>;
}
if (!isAdmin) {
return <p>访...</p>;
}
// Main content for Admin
return (
<div className="container mx-auto px-4 py-8 relative">
{/* Notification Area */}
{notification && (
<div className={`fixed top-5 right-5 p-4 rounded-md shadow-lg text-white z-[100]
${notification.type === 'success' ? 'bg-green-500' : 'bg-red-500'}`}>
{notification.message}
</div>
)}
<div className="flex justify-between items-center mb-6">
<div className="flex items-center space-x-4">
<h1 className="text-3xl font-semibold text-gray-800"></h1>
<Link
href="/admin/dashboard"
className="bg-gray-500 hover:bg-gray-600 text-white text-sm font-semibold py-2 px-4 rounded-lg shadow-md transition duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-opacity-75"
>
</Link>
</div>
<button
onClick={openAddModal} // Open the modal
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-lg shadow-md transition duration-150 ease-in-out"
>
+
</button>
</div>
{/* Search and Filter Bar */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg shadow">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 items-end">
<div>
<label htmlFor="searchSpotUid" className="block text-sm font-medium text-gray-700">UID</label>
<input
type="text"
name="searchSpotUid"
id="searchSpotUid"
value={searchSpotUid}
onChange={(e) => setSearchSpotUid(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="searchStatus" className="block text-sm font-medium text-gray-700"></label>
<select
name="searchStatus"
id="searchStatus"
value={searchStatus}
onChange={(e) => setSearchStatus(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
>
<option value=""></option>
<option value="AVAILABLE"></option>
<option value="OCCUPIED"></option>
<option value="MAINTENANCE"></option>
<option value="RESERVED"></option>
</select>
</div>
<div>
<label htmlFor="searchRobotAssignable" className="block text-sm font-medium text-gray-700"></label>
<select
name="searchRobotAssignable"
id="searchRobotAssignable"
value={searchRobotAssignable}
onChange={(e) => setSearchRobotAssignable(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
>
<option value=""></option>
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div className="flex space-x-2">
<button
onClick={handleSearch}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
</button>
<button
onClick={handleResetSearch}
className="w-full bg-gray-300 hover:bg-gray-400 text-gray-800 font-semibold py-2 px-4 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
</button>
</div>
</div>
</div>
{/* Add New Spot Modal */}
<Modal
isOpen={isAddModalOpen}
onClose={closeAddModal}
title="添加新车位"
footer={
<div className="flex justify-end space-x-2">
<button
onClick={closeAddModal}
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
</button>
<button
onClick={handleAddNewSpot}
disabled={addSpotLoading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{addSpotLoading ? '添加中...' : '添加车位'}
</button>
</div>
}
>
<form onSubmit={(e) => { e.preventDefault(); handleAddNewSpot(); }}>
{addSpotError && <p className="text-red-500 text-sm mb-3 bg-red-100 p-2 rounded">{addSpotError}</p>}
<div className="mb-4">
<label htmlFor="spotUid" className="block text-sm font-medium text-gray-700 mb-1">UID <span className="text-red-500">*</span></label>
<input
type="text"
name="spotUid"
id="spotUid"
value={newSpotData.spotUid}
onChange={handleNewSpotDataChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
required
/>
</div>
<div className="mb-4">
<label htmlFor="locationDesc" className="block text-sm font-medium text-gray-700 mb-1"> (200)</label>
<textarea
name="locationDesc"
id="locationDesc"
value={newSpotData.locationDesc || ''}
onChange={handleNewSpotDataChange}
rows={3}
maxLength={200}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
<div className="mb-4">
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1"></label>
<select
name="status"
id="status"
value={newSpotData.status}
onChange={handleNewSpotDataChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
>
<option value="AVAILABLE"></option>
<option value="OCCUPIED"></option>
<option value="MAINTENANCE"></option>
<option value="RESERVED"></option>
</select>
</div>
<div className="flex items-center mb-4">
<input
type="checkbox"
name="robotAssignable"
id="robotAssignable"
checked={newSpotData.robotAssignable}
onChange={handleNewSpotDataChange}
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="robotAssignable" className="ml-2 block text-sm text-gray-900"></label>
</div>
</form>
</Modal>
{/* Edit Spot Modal */}
{editingSpot && (
<Modal
isOpen={isEditModalOpen}
onClose={closeEditModal}
title={`编辑车位: ${editingSpot.spotUid}`}
footer={
<div className="flex justify-end space-x-2">
<button
onClick={closeEditModal}
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
</button>
<button
onClick={handleUpdateSpot}
disabled={editSpotLoading}
className="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
>
{editSpotLoading ? '保存中...' : '保存更改'}
</button>
</div>
}
>
<form onSubmit={(e) => { e.preventDefault(); handleUpdateSpot(); }}>
{editSpotError && <p className="text-red-500 text-sm mb-3 bg-red-100 p-2 rounded">{editSpotError}</p>}
<div className="mb-4">
<label htmlFor="editSpotUid" className="block text-sm font-medium text-gray-700 mb-1">UID</label>
<input
type="text"
name="spotUid"
id="editSpotUid"
value={editingSpot.spotUid || ''}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 sm:text-sm"
readOnly
/>
</div>
<div className="mb-4">
<label htmlFor="editLocationDesc" className="block text-sm font-medium text-gray-700 mb-1"> (200)</label>
<textarea
name="locationDesc"
id="editLocationDesc"
value={editingSpot.locationDesc || ''}
onChange={handleEditingSpotDataChange}
rows={3}
maxLength={200}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
<div className="mb-4">
<label htmlFor="editStatus" className="block text-sm font-medium text-gray-700 mb-1"></label>
<select
name="status"
id="editStatus"
value={editingSpot.status || 'AVAILABLE'}
onChange={handleEditingSpotDataChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
>
<option value="AVAILABLE"></option>
<option value="OCCUPIED"></option>
<option value="MAINTENANCE"></option>
<option value="RESERVED"></option>
</select>
</div>
<div className="flex items-center mb-4">
<input
type="checkbox"
name="robotAssignable"
id="editRobotAssignable"
checked={editingSpot.robotAssignable || false}
onChange={handleEditingSpotDataChange}
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="editRobotAssignable" className="ml-2 block text-sm text-gray-900"></label>
</div>
</form>
</Modal>
)}
{/* Delete Confirmation Modal */}
{deletingSpotInfo && (
<Modal
isOpen={isDeleteConfirmOpen}
onClose={closeDeleteConfirm}
title="确认删除车位"
footer={
<div className="flex justify-end space-x-2">
<button
onClick={closeDeleteConfirm}
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
</button>
<button
onClick={handleDeleteSpot}
disabled={deleteSpotLoading}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
>
{deleteSpotLoading ? '删除中...' : '删除'}
</button>
</div>
}
>
<p className="text-gray-700">
<span className="font-semibold">{deletingSpotInfo.spotUid}</span>
</p>
{deleteSpotError && <p className="text-red-500 text-sm mt-3 bg-red-100 p-2 rounded">{deleteSpotError}</p>}
</Modal>
)}
{loading && <LoadingSpinner />}
{error && <p className="text-red-500 bg-red-100 p-3 rounded-md">: {error}</p>}
{!loading && !error && (
<>
{/* Parking Spots Table */}
<div className="bg-white shadow-xl rounded-lg overflow-hidden">
<table className="min-w-full leading-normal">
<thead className="bg-gray-800 text-white">
<tr>
<th onClick={() => handleSort('spotUid')} className="px-5 py-3 border-b-2 border-gray-200 text-left text-xs font-semibold uppercase tracking-wider cursor-pointer">UID <SortIndicator field="spotUid" /></th>
<th className="px-5 py-3 border-b-2 border-gray-200 text-left text-xs font-semibold uppercase tracking-wider"></th>
<th onClick={() => handleSort('status')} className="px-5 py-3 border-b-2 border-gray-200 text-left text-xs font-semibold uppercase tracking-wider cursor-pointer"> <SortIndicator field="status" /></th>
<th onClick={() => handleSort('robotAssignable')} className="px-5 py-3 border-b-2 border-gray-200 text-left text-xs font-semibold uppercase tracking-wider cursor-pointer"> <SortIndicator field="robotAssignable" /></th>
<th onClick={() => handleSort('updateTime')} className="px-5 py-3 border-b-2 border-gray-200 text-left text-xs font-semibold uppercase tracking-wider cursor-pointer"> <SortIndicator field="updateTime" /></th>
<th className="px-5 py-3 border-b-2 border-gray-200 text-left text-xs font-semibold uppercase tracking-wider"></th>
</tr>
</thead>
<tbody className="text-gray-700">
{parkingSpots.length > 0 ? (
parkingSpots.map((spot) => (
<tr key={spot.id} className="hover:bg-gray-100 border-b border-gray-200">
<td className="px-5 py-4 text-sm">{spot.spotUid}</td>
<td className="px-5 py-4 text-sm">{spot.locationDesc || 'N/A'}</td>
<td className="px-5 py-4 text-sm">
<span
className={`px-2 py-1 font-semibold leading-tight rounded-full text-xs
${spot.status === 'AVAILABLE' ? 'bg-green-100 text-green-700' :
spot.status === 'OCCUPIED' ? 'bg-yellow-100 text-yellow-700' :
spot.status === 'MAINTENANCE' ? 'bg-red-100 text-red-700' :
spot.status === 'RESERVED' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'}
`}
>
{spot.status === 'AVAILABLE' ? '可用' :
spot.status === 'OCCUPIED' ? '占用' :
spot.status === 'MAINTENANCE' ? '维护中' :
spot.status === 'RESERVED' ? '预留' : spot.status}
</span>
</td>
<td className="px-5 py-4 text-sm">{spot.robotAssignable ? '是' : '否'}</td>
<td className="px-5 py-4 text-sm">{new Date(spot.updateTime).toLocaleString()}</td>
<td className="px-5 py-4 text-sm">
<button
onClick={() => openEditModal(spot)}
className="text-indigo-600 hover:text-indigo-900 mr-3"
>
</button>
<button
onClick={() => openDeleteConfirm(spot)}
className="text-red-600 hover:text-red-900"
>
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="text-center py-10 text-gray-500">
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination Controls */}
{totalSpots > 0 && (
<div className="py-5 flex items-center justify-between">
<div className="flex items-center">
<span className="text-xs xs:text-sm text-gray-700 mr-4">
{((queryParams.current || 1) - 1) * (queryParams.pageSize || 10) + 1} {Math.min((queryParams.current || 1) * (queryParams.pageSize || 10), totalSpots)} {totalSpots}
</span>
<select
value={queryParams.pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
className="text-xs xs:text-sm border border-gray-300 rounded-md p-1 shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value={10}>10 /</option>
<option value={20}>20 /</option>
<option value={50}>50 /</option>
</select>
</div>
<div className="inline-flex mt-2 xs:mt-0">
<button
onClick={() => handlePageChange((queryParams.current || 1) - 1)}
disabled={(queryParams.current || 1) <= 1}
className="text-sm bg-gray-300 hover:bg-gray-400 text-gray-800 font-semibold py-2 px-4 rounded-l disabled:opacity-50"
>
</button>
<button
onClick={() => handlePageChange((queryParams.current || 1) + 1)}
disabled={((queryParams.current || 1) * (queryParams.pageSize || 10)) >= totalSpots}
className="text-sm bg-gray-300 hover:bg-gray-400 text-gray-800 font-semibold py-2 px-4 rounded-r disabled:opacity-50"
>
</button>
</div>
</div>
)}
</>
)}
</div>
);
};
export default AdminParkingSpotsPage;

View File

@@ -0,0 +1,781 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import LoadingSpinner from '@/components/LoadingSpinner';
import {
FiChevronLeft, FiPlus, FiEdit2, FiTrash2, FiSearch,
FiFilter, FiTerminal, FiBatteryCharging, FiTool,
FiAlertCircle, FiPower, FiMapPin, FiX
} from 'react-icons/fi';
import { api } from '@/utils/axios'; // 确保 api 实例已配置
import { AxiosError } from 'axios';
// 与后端实体 ChargingRobot.java 对齐的接口
interface ChargingRobot {
id: number; // 后端是 LongTS 中用 number
robotUid: string;
status: string; // 例如 "IDLE", "CHARGING"
batteryLevel?: number | null;
currentLocation?: string | null;
lastHeartbeatTime?: string | null; // Date 类型在 JSON 中通常是 string
createTime?: string;
updateTime?: string;
currentTaskId?: number | null;
}
// 后端分页响应结构 (简化版,关注核心字段)
interface Page<T> {
records: T[];
total: number;
size: number;
current: number;
pages: number;
}
// 机器人查询请求参数接口 (对应 ChargingRobotQueryRequest.java)
interface ChargingRobotQueryRequest {
current: number;
pageSize: number;
robotUid?: string;
status?: string; // 'ALL' 表示不按状态筛选,实际发送时空字符串或不传此字段
sortField?: string;
sortOrder?: 'ascend' | 'descend';
}
// 在 ChargingRobotQueryRequest 定义之后,添加 BaseResponse 接口定义 (如果它还没有在全局定义的话)
// 这个定义应该与你的后端 common.BaseResponse 一致
interface BaseResponse<T> {
code: number;
data: T;
message?: string;
}
// Backend request for adding a robot (assuming structure)
interface ChargingRobotAddRequest {
robotUid: string;
status: string;
batteryLevel?: number | null;
currentLocation?: string | null;
}
// For Editing Robot - includes ID
interface ChargingRobotUpdateRequest {
id: number;
robotUid?: string; // UID might not be editable, but included based on backend DTO
status?: string;
batteryLevel?: number | null;
currentLocation?: string | null;
}
// Interface for Delete Request (matches backend's DeleteRequest)
interface DeleteRobotRequest {
id: number;
}
const getRobotStatusDisplay = (status: string | undefined | null) => {
if (!status) return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"></span>;
switch (status.toUpperCase()) {
case 'IDLE':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"><FiPower className="mr-1" /> </span>;
case 'CHARGING':
case 'CHARGING_IN_PROGRESS': // 兼容可能的枚举值
case 'CHARGING_STARTED':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"><FiBatteryCharging className="mr-1" /> </span>;
case 'MAINTENANCE':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"><FiTool className="mr-1" /> </span>;
case 'ERROR':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800"><FiAlertCircle className="mr-1" /> </span>;
case 'OFFLINE':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-700"><FiAlertCircle className="mr-1" /> 线</span>; // Adjusted colors for offline
case 'MOVING':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"><FiTerminal className="mr-1" /> </span>;
default:
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">{status}</span>;
}
};
export default function AdminRobotsPage() {
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
const [robots, setRobots] = useState<ChargingRobot[]>([]);
const [robotStatusTypes, setRobotStatusTypes] = useState<string[]>([]);
const [pageInfo, setPageInfo] = useState<Omit<Page<ChargingRobot>, 'records'>>({
current: 1,
size: 10,
total: 0,
pages: 0,
});
const [filters, setFilters] = useState<{
robotUid: string;
status: string; // 'ALL' or actual status value
}>({ robotUid: '', status: 'ALL' });
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// State for Add Robot Modal
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const defaultAddRobotData: ChargingRobotAddRequest = {
robotUid: '',
status: 'IDLE',
batteryLevel: null, // Default to null or a sensible number like 100
currentLocation: ''
};
const [newRobotData, setNewRobotData] = useState<ChargingRobotAddRequest>(defaultAddRobotData);
const [addError, setAddError] = useState<string | null>(null);
// State for Delete Robot Modal
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [robotToDelete, setRobotToDelete] = useState<ChargingRobot | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [isDeletingRobot, setIsDeletingRobot] = useState(false);
// State for Edit Robot Modal
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [robotToEditData, setRobotToEditData] = useState<ChargingRobotUpdateRequest | null>(null);
const [editError, setEditError] = useState<string | null>(null);
const [isEditingRobot, setIsEditingRobot] = useState(false);
// 认证和权限检查
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.replace('/login');
return;
}
if (isAuthenticated && user?.role !== 'admin') {
router.replace('/dashboard');
}
}, [authLoading, isAuthenticated, user, router]);
// 获取机器人状态类型
const fetchRobotStatusTypes = useCallback(async () => {
try {
const response = await api.get<BaseResponse<string[]>>('/admin/robot/status/types');
if (response.data && response.data.code === 0 && Array.isArray(response.data.data)) {
setRobotStatusTypes(response.data.data);
// If newRobotData.status is default and status types are loaded, set to the first available status
if (newRobotData.status === 'IDLE' && response.data.data.length > 0) {
setNewRobotData(prev => ({ ...prev, status: response.data.data[0] }));
} else if (response.data.data.length === 0 && newRobotData.status !== 'IDLE') {
setNewRobotData(prev => ({ ...prev, status: 'IDLE' })); // Fallback if no types
}
} else {
console.error("Failed to fetch robot status types or data format is incorrect:", response.data);
setRobotStatusTypes([]);
setNewRobotData(prev => ({ ...prev, status: 'IDLE' })); // Fallback
}
} catch (err) {
console.error("API error fetching robot status types:", err);
setRobotStatusTypes([]);
setNewRobotData(prev => ({ ...prev, status: 'IDLE' })); // Fallback
}
}, [newRobotData.status]); // Added newRobotData.status to dependencies to re-evaluate default
// 获取机器人列表
const fetchRobots = useCallback(async (currentPage: number, currentPageSize: number, currentFilters: typeof filters) => {
if (!isAuthenticated || user?.role !== 'admin') return;
setIsLoading(true);
setError(null);
const queryParams: ChargingRobotQueryRequest = {
current: currentPage,
pageSize: currentPageSize,
robotUid: currentFilters.robotUid || undefined,
status: currentFilters.status === 'ALL' ? undefined : currentFilters.status,
sortField: 'create_time',
sortOrder: 'descend',
};
try {
// Correctly expect BaseResponse wrapping Page<ChargingRobot>
const response = await api.post<BaseResponse<Page<ChargingRobot>>>('/admin/robot/list/page', queryParams);
if (response.data && response.data.code === 0 && response.data.data) {
const pageData = response.data.data; // This is the Page<ChargingRobot> object
setRobots(pageData.records || []);
setPageInfo({
current: pageData.current,
size: pageData.size,
total: pageData.total,
pages: pageData.pages,
});
} else {
console.error("Failed to fetch robots or data format is incorrect:", response.data);
setError(response.data?.message || "加载机器人列表失败,数据格式不正确。");
setRobots([]);
setPageInfo(prev => ({ ...prev, total: 0, pages: 0, current: 1 }));
}
} catch (err) {
console.error("Failed to fetch robots:", err);
const axiosError = err as AxiosError;
if (axiosError.response) {
setError(`加载机器人列表失败: ${axiosError.response.status} ${(axiosError.response.data as any)?.message || axiosError.message}`);
} else if (axiosError.request) {
setError("加载机器人列表失败: 未收到服务器响应。");
} else {
setError(`加载机器人列表失败: ${axiosError.message}`);
}
setRobots([]);
setPageInfo(prev => ({ ...prev, total:0, pages:0, current: 1}));
}
setIsLoading(false);
}, [isAuthenticated, user?.role]);
// 初始加载和当认证通过时加载
useEffect(() => {
if (isAuthenticated && user?.role === 'admin') {
fetchRobotStatusTypes();
fetchRobots(pageInfo.current, pageInfo.size, filters);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, user?.role]); // 依赖于认证状态,确保只在管理员登录后执行
// fetchRobots 和 fetchRobotStatusTypes 使用 useCallback 固化,不需要加入依赖
// 处理筛选条件或分页变化
const handleFilterChange = () => {
// 重置到第一页进行新的筛选
fetchRobots(1, pageInfo.size, filters);
};
const handlePageChange = (newPage: number) => {
fetchRobots(newPage, pageInfo.size, filters);
};
const handleOpenAddModal = () => {
setNewRobotData({ ...defaultAddRobotData, status: robotStatusTypes.length > 0 ? robotStatusTypes[0] : 'IDLE' });
setAddError(null);
setIsAddModalOpen(true);
};
const handleAddRobotSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setAddError(null);
if (!newRobotData.robotUid) {
setAddError("机器人UID不能为空。");
return;
}
if (!newRobotData.status) {
setAddError("机器人状态不能为空。");
return;
}
// Battery level validation (example)
if (newRobotData.batteryLevel !== null && newRobotData.batteryLevel !== undefined) {
const battery = Number(newRobotData.batteryLevel);
if (isNaN(battery) || battery < 0 || battery > 100) {
setAddError("电池电量必须是0到100之间的数字。");
return;
}
}
setIsLoading(true); // Or a specific loading state like setIsAddingRobot(true)
try {
const response = await api.post<BaseResponse<ChargingRobot>>('/admin/robot/add', newRobotData);
if (response.data && response.data.code === 0) {
setIsAddModalOpen(false);
fetchRobots(pageInfo.current, pageInfo.size, filters); // Refresh list
} else {
setAddError(response.data.message || "添加机器人失败,请重试。");
}
} catch (err) {
const axiosError = err as AxiosError;
console.error("Error adding robot:", axiosError);
if (axiosError.response && axiosError.response.data && typeof (axiosError.response.data as any).message === 'string') {
setAddError((axiosError.response.data as any).message);
} else {
setAddError("添加机器人失败,发生未知错误。");
}
}
setIsLoading(false);
};
// Handlers for Delete Robot Modal
const handleOpenDeleteModal = (robot: ChargingRobot) => {
setRobotToDelete(robot);
setDeleteError(null);
setIsDeleteModalOpen(true);
};
const handleDeleteRobotSubmit = async () => {
if (!robotToDelete) return;
setIsDeletingRobot(true);
setDeleteError(null);
try {
const deletePayload: DeleteRobotRequest = { id: robotToDelete.id };
const response = await api.post<BaseResponse<boolean>>('/admin/robot/delete', deletePayload);
if (response.data && response.data.code === 0 && response.data.data === true) {
setIsDeleteModalOpen(false);
setRobotToDelete(null);
// Refresh list. If current page becomes empty after deletion, try to go to previous page or first page.
const newTotal = pageInfo.total - 1;
const newPages = Math.ceil(newTotal / pageInfo.size);
let newCurrentPage = pageInfo.current;
if (robots.length === 1 && pageInfo.current > 1) { // If it was the last item on a page > 1
newCurrentPage = pageInfo.current - 1;
}
if (newTotal === 0) { // if list becomes empty
newCurrentPage = 1;
}
fetchRobots(newCurrentPage, pageInfo.size, filters);
// Optionally: Show success toast
} else {
setDeleteError(response.data?.message || "删除机器人失败。");
}
} catch (err) {
console.error("Failed to delete robot:", err);
const axiosError = err as AxiosError;
if (axiosError.response) {
setDeleteError(`删除失败: ${(axiosError.response.data as any)?.message || axiosError.message}`);
} else {
setDeleteError("删除机器人失败,请稍后再试。");
}
}
setIsDeletingRobot(false);
};
// Handlers for Edit Robot Modal
const handleOpenEditModal = (robot: ChargingRobot) => {
setRobotToEditData({
id: robot.id,
robotUid: robot.robotUid,
status: robot.status,
batteryLevel: robot.batteryLevel,
currentLocation: robot.currentLocation || '',
});
setEditError(null);
setIsEditModalOpen(true);
};
const handleEditRobotSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!robotToEditData) return;
setEditError(null);
// Validate battery level
if (robotToEditData.batteryLevel !== null && robotToEditData.batteryLevel !== undefined) {
const battery = Number(robotToEditData.batteryLevel);
if (isNaN(battery) || battery < 0 || battery > 100) {
setEditError("电池电量必须是0到100之间的数字。");
return;
}
}
setIsEditingRobot(true);
try {
// Ensure robotToEditData has the ID
const payload: ChargingRobotUpdateRequest = {
id: robotToEditData.id, // ID is crucial
robotUid: robotToEditData.robotUid, // UID might be part of the form or fixed
status: robotToEditData.status,
batteryLevel: robotToEditData.batteryLevel,
currentLocation: robotToEditData.currentLocation,
};
const response = await api.post<BaseResponse<null>>('/admin/robot/update', payload);
if (response.data && response.data.code === 0) {
setIsEditModalOpen(false);
fetchRobots(pageInfo.current, pageInfo.size, filters); // Refresh list
} else {
setEditError(response.data.message || "更新机器人失败,请重试。");
}
} catch (err) {
const axiosError = err as AxiosError;
console.error("Error updating robot:", axiosError);
if (axiosError.response && axiosError.response.data && typeof (axiosError.response.data as any).message === 'string') {
setEditError((axiosError.response.data as any).message);
} else {
setEditError("更新机器人失败,发生未知错误。");
}
}
setIsEditingRobot(false);
};
// 权限未加载或用户非管理员时显示加载
if (authLoading || !isAuthenticated || (isAuthenticated && user?.role !== 'admin')) {
return <LoadingSpinner />;
}
return (
<div className="min-h-screen bg-gray-100 p-4 md:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<header className="mb-6 md:mb-8">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between">
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 flex items-center mb-4 md:mb-0">
<FiTerminal className="mr-3 text-indigo-600" />
</h1>
<Link
href="/admin/dashboard"
legacyBehavior={false}
className="flex items-center text-sm text-indigo-600 hover:text-indigo-800 font-medium"
>
<FiChevronLeft className="mr-1" />
</Link>
</div>
</header>
{/* Filters and Actions */}
<div className="mb-6 p-4 bg-white rounded-xl shadow-md">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<div>
<label htmlFor="searchRobotUid" className="block text-sm font-medium text-gray-700 mb-1">
<FiSearch className="inline mr-1" /> UID
</label>
<input
type="text"
id="searchRobotUid"
placeholder="RBT-xxxx"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
value={filters.robotUid}
onChange={(e) => setFilters(prev => ({ ...prev, robotUid: e.target.value }))}
onKeyDown={(e) => e.key === 'Enter' && handleFilterChange()} // 支持回车搜索
/>
</div>
<div>
<label htmlFor="statusFilter" className="block text-sm font-medium text-gray-700 mb-1">
<FiFilter className="inline mr-1" />
</label>
<select
id="statusFilter"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
value={filters.status}
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value }))}
>
<option value="ALL"></option>
{robotStatusTypes.map(statusType => (
<option key={statusType} value={statusType}>{getRobotStatusDisplay(statusType)}</option>
))}
</select>
</div>
<div className="flex space-x-2 items-end">
<button
onClick={handleFilterChange}
className="w-full md:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
</button>
<button
onClick={handleOpenAddModal} // Changed to open modal
className="w-full md:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<FiPlus className="mr-2 -ml-1 h-5 w-5" />
</button>
</div>
</div>
</div>
{isLoading && !robots.length ? (
<LoadingSpinner />
) : error ? (
<div className="text-center py-12 bg-white rounded-xl shadow-lg">
<FiAlertCircle size={48} className="mx-auto text-red-500 mb-4" />
<h3 className="text-xl font-medium text-red-700"></h3>
<p className="text-sm text-gray-600 mt-1">{error}</p>
</div>
) : robots.length > 0 ? (
<div className="bg-white rounded-xl shadow-lg overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">UID</th>
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{robots.map((robot) => (
<tr key={robot.id}>
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">{robot.robotUid}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">{getRobotStatusDisplay(robot.status)}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{robot.batteryLevel != null ? (
<div className="flex items-center">
<div className="w-20 bg-gray-200 rounded-full h-2.5 dark:bg-gray-700 mr-2">
<div
className={`h-2.5 rounded-full ${robot.batteryLevel < 20 ? 'bg-red-500' : robot.batteryLevel < 50 ? 'bg-yellow-400' : 'bg-green-500'}`}
style={{ width: `${robot.batteryLevel}%` }}
></div>
</div>
<span className="text-xs">{robot.batteryLevel}%</span>
</div>
) : 'N/A'}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500"><FiMapPin className="inline mr-1"/>{robot.currentLocation || 'N/A'}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">{robot.lastHeartbeatTime ? new Date(robot.lastHeartbeatTime).toLocaleString() : 'N/A'}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">{robot.currentTaskId || 'N/A'}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-2">
<button onClick={() => handleOpenEditModal(robot)} className="text-indigo-600 hover:text-indigo-900"><FiEdit2 size={16}/></button>
<button onClick={() => handleOpenDeleteModal(robot)} className="text-red-600 hover:text-red-900"><FiTrash2 size={16}/></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 bg-white rounded-xl shadow-lg">
<FiTerminal size={48} className="mx-auto text-gray-400 mb-4" />
<h3 className="text-xl font-medium text-gray-700"></h3>
<p className="text-sm text-gray-500 mt-1">
{filters.robotUid || filters.status !== 'ALL' ? '没有机器人符合当前筛选条件。' : '系统中暂无机器人记录,请先添加。'}
</p>
</div>
)}
{/* Pagination Controls - Server-side */}
{pageInfo.total > 0 && (
<div className="mt-6 flex justify-center items-center space-x-2">
<button
onClick={() => handlePageChange(pageInfo.current - 1)}
disabled={pageInfo.current <= 1 || isLoading}
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
{[...Array(pageInfo.pages).keys()].map(number => (
<button
key={number + 1}
onClick={() => handlePageChange(number + 1)}
disabled={isLoading}
className={`px-3 py-1 text-sm border rounded-md ${pageInfo.current === number + 1 ? 'bg-indigo-600 text-white border-indigo-600' : 'border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed'}`}
>
{number + 1}
</button>
))}
<button
onClick={() => handlePageChange(pageInfo.current + 1)}
disabled={pageInfo.current >= pageInfo.pages || isLoading}
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<span className="text-sm text-gray-600"> {pageInfo.total} </span>
</div>
)}
</div>
{/* Add Robot Modal */}
{isAddModalOpen && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
<div className="relative p-8 bg-white w-full max-w-md m-auto flex-col flex rounded-lg shadow-xl">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-800"></h2>
<button onClick={() => setIsAddModalOpen(false)} className="text-gray-400 hover:text-gray-600">
<FiX size={24} />
</button>
</div>
<form onSubmit={handleAddRobotSubmit}>
<div className="mb-4">
<label htmlFor="addRobotUid" className="block text-sm font-medium text-gray-700 mb-1">UID</label>
<input
type="text"
id="addRobotUid"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
value={newRobotData.robotUid}
onChange={(e) => setNewRobotData({ ...newRobotData, robotUid: e.target.value })}
/>
</div>
<div className="mb-4">
<label htmlFor="addRobotStatus" className="block text-sm font-medium text-gray-700 mb-1"></label>
<select
id="addRobotStatus"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
value={newRobotData.status}
onChange={(e) => setNewRobotData({ ...newRobotData, status: e.target.value })}
>
{robotStatusTypes.length > 0 ? robotStatusTypes.map(status => (
<option key={`add-${status}`} value={status}>{getRobotStatusDisplay(status)}</option>
)) : (
<option value="IDLE">{getRobotStatusDisplay('IDLE')}</option>
)}
{!robotStatusTypes.includes('IDLE') && <option value="IDLE">{getRobotStatusDisplay('IDLE')}</option>}
{!robotStatusTypes.includes('MAINTENANCE') && <option value="MAINTENANCE">{getRobotStatusDisplay('MAINTENANCE')}</option>}
</select>
</div>
<div className="mb-4">
<label htmlFor="addBatteryLevel" className="block text-sm font-medium text-gray-700 mb-1"> (%)</label>
<input
type="number"
id="addBatteryLevel"
min="0" max="100"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
value={newRobotData.batteryLevel == null ? '' : newRobotData.batteryLevel}
onChange={(e) => setNewRobotData({ ...newRobotData, batteryLevel: e.target.value === '' ? null : parseInt(e.target.value, 10) })}
/>
</div>
<div className="mb-6">
<label htmlFor="addCurrentLocation" className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
id="addCurrentLocation"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
value={newRobotData.currentLocation || ''}
onChange={(e) => setNewRobotData({ ...newRobotData, currentLocation: e.target.value })}
/>
</div>
{addError && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md text-sm">
{addError}
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => setIsAddModalOpen(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Robot Modal */}
{isEditModalOpen && robotToEditData && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
<div className="relative p-8 bg-white w-full max-w-md m-auto flex-col flex rounded-lg shadow-xl">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-800"></h2>
<button onClick={() => { setIsEditModalOpen(false); setRobotToEditData(null); }} className="text-gray-400 hover:text-gray-600">
<FiX size={24} />
</button>
</div>
<form onSubmit={handleEditRobotSubmit}>
<div className="mb-4">
<label htmlFor="editRobotUid" className="block text-sm font-medium text-gray-700 mb-1">UID ()</label>
<input
type="text"
id="editRobotUid"
readOnly
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 cursor-not-allowed"
value={robotToEditData.robotUid || ''}
/>
</div>
<div className="mb-4">
<label htmlFor="editRobotStatus" className="block text-sm font-medium text-gray-700 mb-1"></label>
<select
id="editRobotStatus"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
value={robotToEditData.status}
onChange={(e) => setRobotToEditData({ ...robotToEditData, status: e.target.value })}
>
{robotStatusTypes.map(status => (
<option key={`edit-${status}`} value={status}>{getRobotStatusDisplay(status)}</option>
))}
{!robotStatusTypes.includes('IDLE') && <option value="IDLE">{getRobotStatusDisplay('IDLE')}</option>}
{!robotStatusTypes.includes('MAINTENANCE') && <option value="MAINTENANCE">{getRobotStatusDisplay('MAINTENANCE')}</option>}
</select>
</div>
<div className="mb-4">
<label htmlFor="editBatteryLevel" className="block text-sm font-medium text-gray-700 mb-1"> (%)</label>
<input
type="number"
id="editBatteryLevel"
min="0" max="100"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
value={robotToEditData.batteryLevel == null ? '' : robotToEditData.batteryLevel}
onChange={(e) => setRobotToEditData({ ...robotToEditData, batteryLevel: e.target.value === '' ? null : parseInt(e.target.value, 10) })}
/>
</div>
<div className="mb-6">
<label htmlFor="editCurrentLocation" className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
id="editCurrentLocation"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
value={robotToEditData.currentLocation || ''}
onChange={(e) => setRobotToEditData({ ...robotToEditData, currentLocation: e.target.value })}
/>
</div>
{editError && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md text-sm">
{editError}
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => { setIsEditModalOpen(false); setRobotToEditData(null); }}
disabled={isEditingRobot}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 disabled:opacity-50"
>
</button>
<button
type="submit"
disabled={isEditingRobot}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{isEditingRobot ? '保存中...' : '保存更改'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Robot Modal */}
{isDeleteModalOpen && robotToDelete && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
<div className="relative p-8 bg-white w-full max-w-md m-auto flex-col flex rounded-lg shadow-xl">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-800"></h2>
<button onClick={() => { setIsDeleteModalOpen(false); setRobotToDelete(null); }} className="text-gray-400 hover:text-gray-600">
<FiX size={24} />
</button>
</div>
<p className="text-gray-600 text-sm mb-6">
<span className="font-semibold">{robotToDelete.robotUid}</span>
</p>
{deleteError && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md text-sm">
{deleteError}
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => { setIsDeleteModalOpen(false); setRobotToDelete(null); }}
disabled={isDeletingRobot}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 disabled:opacity-50"
>
</button>
<button
type="button" // Changed to button, onSubmit is handled by the modal's primary action button
onClick={handleDeleteRobotSubmit}
disabled={isDeletingRobot}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
>
{isDeletingRobot ? '删除中...' : '确认删除'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,257 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import LoadingSpinner from '@/components/LoadingSpinner';
import { api, BaseResponse } from '@/utils/axios';
import { FiZap, FiClock, FiAlertCircle, FiCheckCircle, FiPower, FiArrowLeft, FiInfo } from 'react-icons/fi';
// 与 dashboard/page.tsx 类似的活动会话接口可能需要根据后端VO调整
interface ActiveChargingSession {
id: number;
spotUidSnapshot: string | null;
robotUidSnapshot?: string | null;
chargeStartTime: string | null; // ISO String Date
status: string;
// 根据需要可以从后端 ChargingSessionVO 添加更多字段
// e.g., energyConsumedKwh, cost (though these might be final values)
}
const POLLING_INTERVAL = 5000; // 5 seconds for polling
// 定义终止状态常量,用于组件内多处使用
const TERMINAL_STATUSES = ['COMPLETED', 'PAID', 'CANCELLED_BY_USER', 'CANCELLED_BY_SYSTEM', 'ERROR', 'ROBOT_TASK_TIMEOUT'];
export default function ChargingStatusPage() {
const { isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
const [session, setSession] = useState<ActiveChargingSession | null | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isStopping, setIsStopping] = useState(false);
const [elapsedTime, setElapsedTime] = useState("00:00:00");
const fetchActiveSession = useCallback(async () => {
if (!isAuthenticated) return;
try {
const response = await api.get<BaseResponse<ActiveChargingSession | null>>('/session/my/active');
console.log('Active charging session response:', response.data);
if (response.data && response.data.code === 0) {
const activeSessionData = response.data.data;
setSession(activeSessionData);
setError(null);
if (activeSessionData) {
console.log('Current session status:', activeSessionData.status);
if (TERMINAL_STATUSES.includes(activeSessionData.status)) {
setTimeout(() => router.push('/dashboard'), 3000);
}
} else {
console.log('No active charging session found');
}
} else {
setError(response.data?.message || '获取充电状态失败');
}
} catch (err: any) {
console.error("Error fetching active session:", err);
setError(err.response?.data?.message || err.message || '网络错误,无法获取充电状态');
} finally {
setIsLoading(false);
}
}, [isAuthenticated, router]);
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.replace('/login');
return;
}
if (isAuthenticated) {
setIsLoading(true);
fetchActiveSession();
}
}, [authLoading, isAuthenticated, router, fetchActiveSession]);
useEffect(() => {
let intervalId: NodeJS.Timeout;
if (isAuthenticated && session && !isLoading) {
if (session && !TERMINAL_STATUSES.includes(session.status)) {
intervalId = setInterval(fetchActiveSession, POLLING_INTERVAL);
}
}
return () => clearInterval(intervalId);
}, [isAuthenticated, session, isLoading, fetchActiveSession]);
const calculateDuration = useCallback((startTime: string | null): string => {
if (!startTime) return '00:00:00';
const start = new Date(startTime).getTime();
const now = Date.now();
if (now < start) return '00:00:00';
let diff = Math.floor((now - start) / 1000);
const hours = String(Math.floor(diff / 3600)).padStart(2, '0');
diff %= 3600;
const minutes = String(Math.floor(diff / 60)).padStart(2, '0');
const seconds = String(diff % 60).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}, []);
useEffect(() => {
let timerId: NodeJS.Timeout;
if (session && session.chargeStartTime && (session.status === 'CHARGING_STARTED' || session.status === 'CHARGING_IN_PROGRESS')) {
setElapsedTime(calculateDuration(session.chargeStartTime)); // Initial calculation
timerId = setInterval(() => {
setElapsedTime(calculateDuration(session.chargeStartTime));
}, 1000);
} else if (session && session.chargeStartTime) {
setElapsedTime(calculateDuration(session.chargeStartTime)); // Calculate final duration once if session ended
} else {
setElapsedTime('00:00:00');
}
return () => clearInterval(timerId);
}, [session, calculateDuration]);
const handleStopCharging = async () => {
if (!session || !session.id) {
setError("无法停止充电:无效的会话信息。");
return;
}
setIsStopping(true);
setError(null);
try {
const response = await api.post<BaseResponse<any>>(`/session/stop`, { sessionId: session.id });
if (response.data && response.data.code === 0) {
// Rely on polling to update the session state
// Optionally, trigger an immediate refresh:
fetchActiveSession();
} else {
setError(response.data?.message || "停止充电请求失败");
}
} catch (err: any) {
console.error("Error stopping charging session:", err);
setError(err.response?.data?.message || err.message || "停止充电时发生错误");
}
setIsStopping(false);
};
if (authLoading || (isLoading && session === undefined)) {
return <LoadingSpinner />;
}
if (!isAuthenticated) {
return <div className="p-4 text-center"></div>;
}
return (
<div className="min-h-screen bg-gray-100 p-4 md:p-8 flex flex-col items-center">
<div className="w-full max-w-2xl">
<header className="mb-8 flex items-center">
<Link href="/dashboard" className="text-indigo-600 hover:text-indigo-700 mr-4">
<FiArrowLeft size={24} />
</Link>
<h1 className="text-3xl font-bold text-gray-800"></h1>
</header>
{error && (
<div className="mb-6 p-4 bg-red-100 text-red-700 border border-red-300 rounded-md shadow-sm" role="alert">
<div className="flex items-center">
<FiAlertCircle className="mr-2" />
<p>{error}</p>
</div>
</div>
)}
{session === null && !error && (
<div className="bg-white p-8 rounded-xl shadow-xl text-center">
<FiInfo size={48} className="mx-auto text-blue-500 mb-4" />
<h2 className="text-2xl font-semibold text-gray-700 mb-2"></h2>
<p className="text-gray-600 mb-6"></p>
<Link href="/request-charging" className="inline-block bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-6 rounded-lg shadow-md transition duration-150 ease-in-out">
</Link>
</div>
)}
{session && (
<div className="bg-white p-6 md:p-8 rounded-xl shadow-xl">
<div className="mb-6 border-b pb-4 border-gray-200">
<h2 className="text-2xl font-semibold text-gray-700 mb-1">ID: {session.id}</h2>
<p className={`text-lg font-medium ${session.status === 'CHARGING_STARTED' || session.status === 'CHARGING_IN_PROGRESS' ? 'text-green-500' : 'text-gray-600'}`}>
: {session.status}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-lg font-medium text-gray-800">{session.spotUidSnapshot || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-lg font-medium text-gray-800">{session.robotUidSnapshot || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-lg font-medium text-gray-800">
{session.chargeStartTime ? new Date(session.chargeStartTime).toLocaleString() : '等待开始'}
</p>
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-2xl font-bold text-indigo-600 tabular-nums">
<FiClock className="inline mr-2 mb-1" />{elapsedTime}
</p>
</div>
</div>
<div className="mb-8">
<p className="text-sm text-gray-500 mb-1"> ()</p>
<div className="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
<div className={`bg-green-500 h-4 ${session.status === 'CHARGING_STARTED' || session.status === 'CHARGING_IN_PROGRESS' ? 'animate-pulse-fast' : ''}`} style={{ width: '100%' }}></div>
</div>
<p className="text-xs text-gray-500 mt-1"></p>
</div>
{/* 根据会话状态显示不同的内容 */}
{(session.status === 'CHARGING_STARTED' || session.status === 'CHARGING_IN_PROGRESS' || session.status === 'ROBOT_ARRIVED' || session.status === 'ROBOT_ASSIGNED') ? (
// 显示停止充电按钮
<button
onClick={handleStopCharging}
disabled={isStopping}
className="w-full bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-4 rounded-lg shadow-md transition duration-150 ease-in-out disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center"
>
{isStopping ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
...
</>
) : (
<><FiPower className="mr-2" /> </>
)}
</button>
) : (
// 会话状态不适合显示停止按钮,显示原因
<div className="mb-2 p-3 bg-yellow-50 border border-yellow-200 rounded-md text-sm text-yellow-700">
<strong>{session.status}</strong>
{TERMINAL_STATUSES.includes(session.status) && " 会话已结束或处于终止状态。"}
</div>
)}
{(session.status === 'COMPLETED' || session.status === 'PAID' || session.status === 'PAYMENT_PENDING') && (
<div className="mt-6 text-center p-4 bg-green-50 border-green-200 border rounded-md">
<FiCheckCircle size={28} className="text-green-500 mx-auto mb-2" />
<p className="text-green-700 font-semibold"></p>
<p className="text-sm text-gray-600">使</p>
<Link href="/my-sessions" className="mt-3 inline-block text-indigo-600 hover:text-indigo-800 font-medium">
</Link>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,67 +1,291 @@
'use client'; 'use client';
import React from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link';
import LoadingSpinner from '@/components/LoadingSpinner'; import LoadingSpinner from '@/components/LoadingSpinner';
import { api, BaseResponse } from '@/utils/axios'; // Import the api instance
import { FiDollarSign, FiZap, FiClock, FiList, FiChevronRight, FiInfo, FiPower, FiAlertCircle } from 'react-icons/fi';
const DashboardPage: React.FC = () => { // Interface for active charging session (matches ChargingSessionVO fields used)
const { user, logout, isLoading, isAuthenticated } = useAuth(); interface ActiveChargingSession {
id: number;
spotUidSnapshot: string | null;
chargeStartTime: string | null; // 后端是 Date 类型,前端收到的是 string
status: string; // 例如 "CHARGING_STARTED"
// 可以按需添加其他来自后端 ChargingSession 实体的字段
robotUidSnapshot?: string | null;
requestTime?: string | null;
}
// Interface for user dashboard statistics from backend
interface UserDashboardStats {
monthlySessions: number; // 后端返回的可能是 monthlyCharges 字段
monthlySpending: number;
}
export default function DashboardPage() {
const { user, isAuthenticated, isLoading: authLoading, logout } = useAuth();
const router = useRouter(); const router = useRouter();
if (isLoading) { const [activeSession, setActiveSession] = useState<ActiveChargingSession | null | undefined>(undefined);
return ( const [userStats, setUserStats] = useState<UserDashboardStats | null | undefined>(undefined);
<div className="flex items-center justify-center min-h-screen"> const [dataLoading, setDataLoading] = useState(true);
<LoadingSpinner /> const [error, setError] = useState<string | null>(null);
</div> const [isStopping, setIsStopping] = useState(false);
);
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.replace('/login');
return;
}
if (isAuthenticated && user?.role === 'admin') {
router.replace('/admin/dashboard');
}
}, [authLoading, isAuthenticated, user, router]);
const fetchData = useCallback(async () => {
if (!isAuthenticated || user?.role !== 'user') return;
setDataLoading(true);
setError(null);
try {
// 使用 Promise.allSettled 来处理部分请求失败的情况,如果需要
const [sessionResult, statsResult] = await Promise.all([
api.get<BaseResponse<ActiveChargingSession | null>>('/session/my/active'),
api.get<BaseResponse<UserDashboardStats>>('/user/stats/mine')
]);
if (sessionResult.data && sessionResult.data.code === 0) { // 假设 code 0 代表成功
console.log("Active session response:", sessionResult.data);
setActiveSession(sessionResult.data.data); // data 可以是 null
} else {
// setActiveSession(null); // 或者根据后端错误信息设置
console.warn("Failed to fetch active session or no active session:", sessionResult.data?.message);
setActiveSession(null); // 如果没有活动会话后端data.data可能为null是正常情况
}
if (statsResult.data && statsResult.data.code === 0 && statsResult.data.data) {
console.log("User stats response:", statsResult.data.data);
// 检查后端返回的是monthlyCharges还是monthlySessions并进行适当转换
const statsData = statsResult.data.data;
if ('monthlyCharges' in statsData && statsData.monthlyCharges !== undefined) {
// 如果后端返回的是monthlyCharges字段映射到monthlySessions
setUserStats({
monthlySessions: statsData.monthlyCharges as number,
monthlySpending: statsData.monthlySpending
});
} else {
// 否则假定数据结构已匹配接口
setUserStats(statsData);
}
} else {
setUserStats(null);
console.error("Failed to fetch user stats:", statsResult.data?.message);
// setError(prev => prev ? prev + '\n' + (statsResult.data?.message || '获取用户统计失败') : (statsResult.data?.message || '获取用户统计失败'));
}
} catch (err: any) {
console.error("Error fetching dashboard data:", err);
const errorMessage = err.response?.data?.message || err.message || '获取仪表盘数据失败,请稍后再试。';
setError(prevError => prevError ? `${prevError}\n${errorMessage}` : errorMessage);
setActiveSession(null);
setUserStats(null);
}
setDataLoading(false);
}, [isAuthenticated, user]);
useEffect(() => {
if (isAuthenticated && user?.role === 'user') {
fetchData();
} else if (!authLoading && !isAuthenticated) {
// Handle cases where user is not authenticated and not loading
setDataLoading(false); // Stop loading if not fetching
}
}, [fetchData, isAuthenticated, user, authLoading]);
// 直接从仪表盘停止充电
const handleStopCharging = async () => {
if (!activeSession || !activeSession.id) {
setError("无法停止充电:无效的会话信息。");
return;
}
setIsStopping(true);
setError(null);
try {
const response = await api.post<BaseResponse<any>>(`/session/stop`, { sessionId: activeSession.id });
if (response.data && response.data.code === 0) {
// 停止成功后重新获取数据
fetchData();
} else {
setError(response.data?.message || "停止充电请求失败");
}
} catch (err: any) {
console.error("Error stopping charging session:", err);
setError(err.response?.data?.message || err.message || "停止充电时发生错误");
} finally {
setIsStopping(false);
}
};
if (authLoading || (dataLoading && user?.role === 'user')) {
return <LoadingSpinner />;
} }
if (!isAuthenticated || !user) { if (!isAuthenticated || !user) {
// AuthenticatedLayout 应该已经处理了重定向 return <LoadingSpinner />; // Or a specific message, though redirect should handle
// 但作为备用,可以显示加载或 null
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>;
} }
// 如果用户是管理员但意外访问了普通用户dashboard则重定向到管理员dashboard if (user.role !== 'user') {
// 这一步是可选的因为AuthContext中的login已经做了角色判断和重定向 // This case should ideally be handled by the redirect in the first useEffect
// 但作为额外的保护层防止用户通过直接输入URL访问不匹配的dashboard // but as a fallback:
if (user.role === 'admin') { return <div className="p-4 text-center">...</div>;
router.replace('/admin/dashboard');
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>; // 显示加载直到重定向完成
} }
return ( const StatCard = ({ title, value, icon, unit, isLoading }: { title: string; value: string | number; icon: React.ReactNode; unit?: string; isLoading?: boolean }) => (
<div className="container mx-auto p-4 pt-20"> <div className={`bg-white p-6 rounded-xl shadow-lg flex items-center space-x-4 ${isLoading ? 'animate-pulse' : ''}`}>
<div className="bg-white shadow-md rounded-lg p-8 max-w-md mx-auto"> <div className={`p-3 rounded-full ${isLoading ? 'bg-gray-200' : 'bg-indigo-100 text-indigo-600'}`}>
<h1 className="text-3xl font-bold mb-6 text-center text-gray-800"> {!isLoading && icon}
, {user.username}!
</h1>
<div className="mb-6">
<p className="text-lg text-gray-700">
<span className="font-semibold">ID:</span> {user.id}
</p>
<p className="text-lg text-gray-700">
<span className="font-semibold">:</span> {user.role}
</p>
<p className="text-lg text-gray-700">
<span className="font-semibold">:</span> ¥{user.balance.toFixed(2)}
</p>
</div> </div>
<div>
<button <p className="text-sm text-gray-500">{title}</p>
onClick={async () => { {isLoading ? (
await logout(); <div className="h-6 bg-gray-200 rounded w-20 mt-1"></div>
// logout 函数内部会处理路由跳转到 /login ) : (
}} <p className="text-2xl font-semibold text-gray-800">
className="w-full bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-4 rounded-lg transition duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75" {value} {unit && <span className="text-sm text-gray-500">{unit}</span>}
> </p>
)}
</button>
</div> </div>
</div> </div>
); );
const formatMonthlySpending = (spending: number | undefined | null): string => {
if (typeof spending === 'number') {
return `¥${spending.toFixed(2)}`;
}
return '--';
}; };
export default DashboardPage; return (
<div className="min-h-screen bg-gray-100 p-4 md:p-8">
<div className="max-w-4xl mx-auto">
<header className="mb-8 flex justify-between items-center">
<h1 className="text-3xl font-bold text-gray-800"></h1>
{/* Logout button is in global layout, no need here if using that strategy */}
</header>
<div className="bg-white p-6 rounded-xl shadow-lg mb-8">
<h2 className="text-2xl font-semibold text-gray-700 mb-2">, {user.username}!</h2>
<p className="text-gray-600 mb-1"><span className="font-medium">ID:</span> {user.id}</p>
<p className="text-gray-600 mb-1"><span className="font-medium">:</span> {user.role === 'user' ? '普通用户' : user.role}</p>
<p className="text-gray-600"><span className="font-medium">:</span> ¥{user.balance?.toFixed(2) ?? 'N/A'}</p>
</div>
{error && (
<div className="mb-4 p-4 bg-red-100 text-red-700 border border-red-300 rounded-md whitespace-pre-line">
<p>{error}</p>
</div>
)}
{/* Statistics Section */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<StatCard
title="本月充电次数"
value={userStats?.monthlySessions ?? '--'}
icon={<FiZap size={24} />}
unit="次"
isLoading={dataLoading || userStats === undefined}
/>
<StatCard
title="本月总消费"
value={formatMonthlySpending(userStats?.monthlySpending)}
icon={<FiDollarSign size={24} />}
isLoading={dataLoading || userStats === undefined}
/>
</div>
{/* Current Charging Session Status */}
<div className="bg-white p-6 rounded-xl shadow-lg mb-8">
<h3 className="text-xl font-semibold text-gray-700 mb-3 flex items-center">
<span className="mr-2 text-indigo-600"><FiClock size={22} /></span>
</h3>
{dataLoading || activeSession === undefined ? (
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
) : activeSession ? (
<div>
<p className="text-gray-700"><span className="font-medium">:</span> <span className="text-green-500 font-semibold">{activeSession.status || '进行中'}</span></p>
<p className="text-gray-600"><span className="font-medium">:</span> {activeSession.spotUidSnapshot || 'N/A'}</p>
<p className="text-gray-600"><span className="font-medium">:</span> {activeSession.chargeStartTime ? new Date(activeSession.chargeStartTime).toLocaleString() : 'N/A'}</p>
{/* 操作按钮区域 */}
<div className="mt-6 flex flex-col md:flex-row gap-3">
{/* 前往充电状态详情页的按钮 */}
<Link href="/charging-status" className="inline-block bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-md shadow-sm transition duration-150 ease-in-out text-center">
</Link>
{/* 仅在特定状态下显示停止充电按钮 */}
{(activeSession.status === 'CHARGING_STARTED' ||
activeSession.status === 'CHARGING_IN_PROGRESS' ||
activeSession.status === 'ROBOT_ARRIVED' ||
activeSession.status === 'ROBOT_ASSIGNED') && (
<button
onClick={handleStopCharging}
disabled={isStopping}
className="bg-red-500 hover:bg-red-600 text-white font-semibold py-2 px-4 rounded-md shadow-sm transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isStopping ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
...
</>
) : (
<><FiPower className="mr-2" /></>
)}
</button>
)}
</div>
{error && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700 flex items-center">
<FiAlertCircle className="mr-2" />
{error}
</div>
)}
<p className="text-xs text-gray-500 mt-3"></p>
</div>
) : (
<p className="text-gray-600 flex items-center"><FiInfo className="mr-2 text-blue-500" /></p>
)}
</div>
{/* Action Card/Button to Start New Charging Session */}
<div className="bg-gradient-to-r from-blue-500 to-indigo-600 p-6 md:p-8 rounded-xl shadow-xl text-white text-center">
<h3 className="text-2xl font-bold mb-4"></h3>
<p className="mb-6 text-blue-100">
</p>
<Link href="/request-charging" className="inline-block bg-white hover:bg-gray-100 text-indigo-600 font-bold py-3 px-8 rounded-lg shadow-md transform hover:scale-105 transition duration-300 ease-in-out">
</Link>
</div>
{/* Link to view charging history */}
<div className="mt-8 text-center">
<Link href="/my-sessions" className="text-indigo-600 hover:text-indigo-800 font-medium flex items-center justify-center">
<span className="mr-2"><FiList size={20}/></span>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
// import Link from 'next/link'; // Temporarily remove Link to test a plain anchor
import LoadingSpinner from '@/components/LoadingSpinner'; import LoadingSpinner from '@/components/LoadingSpinner';
export default function AuthenticatedLayout({ export default function AuthenticatedLayout({
@@ -10,7 +11,7 @@ export default function AuthenticatedLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { isAuthenticated, isLoading, user } = useAuth(); // user is available if needed const { isAuthenticated, isLoading, user, logout } = useAuth(); // 获取 user 和 logout
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
@@ -24,7 +25,7 @@ export default function AuthenticatedLayout({
// If still loading, show spinner // If still loading, show spinner
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen bg-gray-100">
<LoadingSpinner /> <LoadingSpinner />
</div> </div>
); );
@@ -32,15 +33,54 @@ export default function AuthenticatedLayout({
// If loading is complete but user is not authenticated, // If loading is complete but user is not authenticated,
// useEffect will handle redirection. Render null or spinner to avoid flashing content. // useEffect will handle redirection. Render null or spinner to avoid flashing content.
if (!isAuthenticated) { if (!isAuthenticated || !user) { // Also check if user object is available
// This state should ideally be brief as useEffect redirects.
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen bg-gray-100">
<LoadingSpinner /> {/* Or return null; */} <LoadingSpinner />
</div> </div>
); );
} }
// If authenticated, render children // If authenticated, render children
return <>{children}</>; return (
<div className="min-h-screen flex flex-col bg-gray-50">
<header className="bg-white shadow-md w-full sticky top-0 z-50">
<nav className="container mx-auto px-6 py-3 flex justify-between items-center">
<div>
{/* Temporarily replaced Link with a plain anchor tag for testing */}
<a href={user.role === 'admin' ? '/admin/dashboard' : '/dashboard'} className="text-xl font-bold text-blue-700 hover:text-blue-900 transition-colors">
</a>
</div>
<div className="flex items-center space-x-4">
{user && (
<span className="text-gray-700 text-sm">
, <span className="font-medium">{user.username}</span> ({user.role === 'admin' ? '管理员' : '用户'})
</span>
)}
<button
onClick={async () => {
try {
await logout();
router.push('/login');
} catch (error) {
console.error('注销失败:', error);
// 可以选择性地通知用户注销失败
}
}}
className="bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded-md text-sm font-semibold transition duration-150 ease-in-out shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50"
>
</button>
</div>
</nav>
</header>
<main className="flex-grow container mx-auto p-4 sm:p-6 lg:p-8">
{children}
</main>
<footer className="bg-gray-800 text-white text-center p-4 mt-auto w-full">
<p className="text-sm">&copy; {new Date().getFullYear()} . .</p>
</footer>
</div>
);
} }

View File

@@ -0,0 +1,229 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import LoadingSpinner from '@/components/LoadingSpinner';
import { api, BaseResponse } from '@/utils/axios';
import { FiChevronLeft, FiClock, FiZap, FiDollarSign, FiList, FiHash, FiCalendar, FiCheckCircle, FiXCircle, FiAlertTriangle, FiAlertOctagon } from 'react-icons/fi';
// Interface matching backend ChargingSessionVO
interface ChargingSession {
id: number;
spotUidSnapshot: string | null;
chargeStartTime: string | null;
chargeEndTime: string | null;
totalDurationSeconds: number | null;
cost: number | null;
status: string;
statusText?: string;
energyConsumedKwh?: number | null;
}
// Interface for the Page object from backend
interface Page<T> {
records: T[];
total: number;
size: number;
current: number;
pages?: number;
}
// Helper function to format duration
const formatDuration = (seconds: number | null | undefined): string => {
if (seconds === null || seconds === undefined || seconds < 0) return 'N/A';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h > 0 ? `${h}小时 ` : ''}${m}分钟`;
};
// Helper function to get status display
const getStatusDisplay = (status: string | null | undefined) => {
switch (status) {
case 'COMPLETED':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"><FiCheckCircle className="mr-1" /> </span>;
case 'CANCELLED_BY_USER':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800"><FiXCircle className="mr-1" /> </span>;
case 'CANCELLED_BY_SYSTEM':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"><FiAlertTriangle className="mr-1" /> </span>;
case 'PAYMENT_PENDING':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"><FiClock className="mr-1" /> </span>;
case 'CHARGING_IN_PROGRESS':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-teal-100 text-teal-800"><FiZap className="mr-1" /> </span>;
case 'ENDED_AWAITING_PAYMENT':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"><FiDollarSign className="mr-1" /> </span>;
case 'ERROR':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-200 text-red-900"><FiAlertOctagon className="mr-1" /> </span>;
default:
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">{status || '未知'}</span>;
}
};
export default function MySessionsPage() {
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
const [sessions, setSessions] = useState<ChargingSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const itemsPerPage = 10;
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.replace('/login');
}
}, [authLoading, isAuthenticated, router]);
const fetchSessions = useCallback(async (page: number) => {
if (!isAuthenticated || user?.role !== 'user') return;
setSessionsLoading(true);
setError(null);
try {
const response = await api.post<BaseResponse<Page<ChargingSession>>>('/session/my/list/page', {
current: page,
pageSize: itemsPerPage,
});
console.log("充电记录响应:", response.data);
if (response.data && response.data.code === 0 && response.data.data) {
const pageData = response.data.data;
setSessions(pageData.records || []);
setTotalPages(Math.ceil(pageData.total / itemsPerPage));
setCurrentPage(pageData.current);
} else {
console.warn("获取充电记录失败或数据为空:", response.data?.message);
setSessions([]);
setTotalPages(0);
}
} catch (err: any) {
console.error("Error fetching charging sessions:", err);
setError(err.response?.data?.message || err.message || '获取充电记录失败,请稍后再试。');
setSessions([]);
setTotalPages(0);
}
setSessionsLoading(false);
}, [isAuthenticated, user, itemsPerPage]);
useEffect(() => {
fetchSessions(currentPage);
}, [fetchSessions, currentPage]);
if (authLoading) {
return <LoadingSpinner />;
}
if (!isAuthenticated) {
return <LoadingSpinner />;
}
if (!user || user.role === 'admin') {
return <div className="p-4"></div>;
}
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
fetchSessions(newPage);
}
};
return (
<div className="min-h-screen bg-gray-100 p-4 md:p-6 lg:p-8">
<div className="max-w-6xl mx-auto">
<header className="mb-6 md:mb-8">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between">
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 flex items-center mb-4 md:mb-0">
<FiList className="mr-3 text-indigo-600" />
</h1>
<Link href="/dashboard" className="flex items-center text-sm text-indigo-600 hover:text-indigo-800 font-medium">
<FiChevronLeft className="mr-1" />
</Link>
</div>
</header>
{error && (
<div className="mb-4 p-4 bg-red-100 text-red-700 border border-red-300 rounded-md">
<p>{error}</p>
</div>
)}
{sessionsLoading && !sessions.length ? (
<LoadingSpinner />
) : !sessionsLoading && sessions.length === 0 && !error ? (
<div className="text-center py-12 bg-white rounded-xl shadow-lg">
<FiList size={48} className="mx-auto text-gray-400 mb-4" />
<h3 className="text-xl font-medium text-gray-700"></h3>
<p className="text-sm text-gray-500 mt-1"></p>
<Link href="/request-charging" className="mt-4 inline-block bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg shadow-md transition duration-150 ease-in-out">
</Link>
</div>
) : sessions.length > 0 ? (
<div className="bg-white rounded-xl shadow-lg overflow-x-auto">
{sessionsLoading && <div className="absolute inset-0 bg-white bg-opacity-50 flex items-center justify-center"><LoadingSpinner /></div>}
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">UID</th>
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> (kWh)</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sessions.map((session) => (
<tr key={session.id}>
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900"><FiHash className="inline mr-1 text-gray-400"/>{session.spotUidSnapshot || 'N/A'}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500"><FiCalendar className="inline mr-1 text-gray-400"/>{session.chargeStartTime ? new Date(session.chargeStartTime).toLocaleString() : 'N/A'}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500"><FiCalendar className="inline mr-1 text-gray-400"/>{session.chargeEndTime ? new Date(session.chargeEndTime).toLocaleString() : 'N/A'}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500"><FiClock className="inline mr-1 text-gray-400"/>{formatDuration(session.totalDurationSeconds)}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500"><FiDollarSign className="inline mr-1 text-green-500"/>{session.cost !== null ? session.cost.toFixed(2) : 'N/A'}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">{getStatusDisplay(session.status)}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500"><FiZap className="inline mr-1 text-yellow-500"/>{session.energyConsumedKwh !== null && session.energyConsumedKwh !== undefined ? `${session.energyConsumedKwh.toFixed(2)} kWh` : 'N/A'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null }
{totalPages > 1 && (
<div className="mt-6 flex justify-center items-center space-x-2">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || sessionsLoading}
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
{[...Array(totalPages).keys()].map(number => (
<button
key={number + 1}
onClick={() => handlePageChange(number + 1)}
disabled={sessionsLoading}
className={`px-3 py-1 text-sm border rounded-md ${currentPage === number + 1 ? 'bg-indigo-600 text-white border-indigo-600' : 'border-gray-300 hover:bg-gray-50'} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{number + 1}
</button>
))}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages || sessionsLoading}
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,174 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
// import api from '@/utils/api'; // Previous attempt
import { api } from '@/services/api'; // New attempt, assuming api.ts is in src/services/
import LoadingSpinner from '@/components/LoadingSpinner';
interface ParkingSpot {
id: number;
spotUid: string;
locationDescription?: string;
status: string; // Expecting "AVAILABLE"
}
export default function RequestChargingPage() {
const { isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
const [parkingSpots, setParkingSpots] = useState<ParkingSpot[]>([]);
const [selectedSpotId, setSelectedSpotId] = useState<number | null>(null);
const [isLoadingSpots, setIsLoadingSpots] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.replace('/login');
}
}, [authLoading, isAuthenticated, router]);
useEffect(() => {
if (isAuthenticated) {
const fetchAvailableSpots = async () => {
setIsLoadingSpots(true);
try {
// Corrected path
const response = await api.get('/parking-spot/list/available-assignable');
if (response.data && response.data.code === 0) {
setParkingSpots(response.data.data || []);
} else {
setError(response.data.message || '获取可用车位失败');
setParkingSpots([]);
}
} catch (err: any) {
setError(err.message || '加载车位列表时发生网络错误');
setParkingSpots([]);
}
setIsLoadingSpots(false);
};
fetchAvailableSpots();
}
}, [isAuthenticated]);
const handleSelectSpot = (spotId: number) => {
setSelectedSpotId(spotId);
};
const handleSubmitRequest = async () => {
if (!selectedSpotId) {
setError('请先选择一个车位。');
return;
}
setIsSubmitting(true);
setError(null);
try {
// Corrected path
const response = await api.post('/session/request', { spotId: selectedSpotId });
if (response.data && response.data.code === 0) {
const newSession = response.data.data;
// TODO: Navigate to a session status page or show success message
// For now, navigate to dashboard and show an alert
alert(`充电请求成功会话ID: ${newSession.id}. 机器人正在前往...`);
router.push('/dashboard');
} else {
setError(response.data.message || '发起充电请求失败');
}
} catch (err: any) {
setError(err.response?.data?.message || err.message || '发起充电请求时发生错误');
}
setIsSubmitting(false);
};
if (authLoading || isLoadingSpots) {
// Layout will be applied by Next.js, just return the specific loading state for this page
return (
<div className="flex items-center justify-center h-[calc(100vh-150px)]"> {/* Adjust height if needed based on header/footer size*/}
<LoadingSpinner />
</div>
);
}
if (!isAuthenticated) {
// This case should ideally be handled by redirect in useEffect,
// but as a fallback or if redirect hasn't happened yet.
return (
<div className="p-4 text-center">
<p>访</p>
<LoadingSpinner /> {/* Or a link to login */}
</div>
);
}
return (
<div className="container mx-auto p-4 md:p-8">
<header className="mb-8">
<h1 className="text-3xl font-bold text-gray-800"></h1>
<p className="text-gray-600 mt-1"></p>
</header>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
<strong className="font-bold">! </strong>
<span className="block sm:inline">{error}</span>
</div>
)}
{parkingSpots.length === 0 && !isLoadingSpots && !error && (
<div className="text-center text-gray-500 py-10">
<p className="text-xl mb-2"></p>
<p></p>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 mb-8">
{parkingSpots.map((spot) => (
<div
key={spot.id}
onClick={() => handleSelectSpot(spot.id)}
className={`p-6 rounded-xl shadow-lg cursor-pointer transition-all duration-200 ease-in-out
${selectedSpotId === spot.id
? 'bg-indigo-500 text-white ring-4 ring-indigo-300 scale-105'
: 'bg-white hover:shadow-xl hover:scale-102'}`}
>
<h3 className={`text-xl font-semibold mb-2 ${selectedSpotId === spot.id ? 'text-white' : 'text-gray-700'}`}>
: {spot.spotUid}
</h3>
{spot.locationDescription && (
<p className={`text-sm ${selectedSpotId === spot.id ? 'text-indigo-100' : 'text-gray-500'}`}>
: {spot.locationDescription}
</p>
)}
<p className={`text-sm font-medium mt-3 ${selectedSpotId === spot.id ? 'text-green-300' : 'text-green-500'}`}>
{spot.status === 'AVAILABLE' ? '可用' : spot.status}
</p>
</div>
))}
</div>
{parkingSpots.length > 0 && (
<div className="text-center">
<button
onClick={handleSubmitRequest}
disabled={!selectedSpotId || isSubmitting}
className="bg-green-500 hover:bg-green-600 text-white font-bold py-3 px-8 rounded-lg shadow-md transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center mx-auto"
>
{isSubmitting ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
...
</>
) : (
'确认选择并请求充电'
)}
</button>
</div>
)}
</div>
);
}

View File

@@ -24,3 +24,17 @@ body {
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
/* 自定义动画 */
@keyframes pulse-fast {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-pulse-fast {
animation: pulse-fast 1s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

View File

@@ -1,103 +1,33 @@
'use client';
import React, { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import LoadingSpinner from '@/components/LoadingSpinner';
import Image from "next/image"; import Image from "next/image";
export default function Home() { export default function HomePage() {
return ( const router = useRouter();
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]"> const { isAuthenticated, isLoading, user } = useAuth();
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row"> useEffect(() => {
<a if (!isLoading) {
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto" if (isAuthenticated) {
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" if (user?.role === 'admin') {
target="_blank" router.replace('/admin/dashboard');
rel="noopener noreferrer" } else {
> router.replace('/dashboard');
<Image }
className="dark:invert" } else {
src="/vercel.svg" router.replace('/login');
alt="Vercel logomark" }
width={20} }
height={20} }, [isLoading, isAuthenticated, user, router]);
/>
Deploy now // Display a loading spinner while determining auth state and redirecting
</a> return (
<a <div className="flex items-center justify-center min-h-screen">
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]" <LoadingSpinner />
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div> </div>
); );
} }

View File

@@ -0,0 +1,47 @@
import React, { ReactNode } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode; // Content of the modal
footer?: ReactNode; // Optional footer for buttons like Save, Cancel
}
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, footer }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 flex justify-center items-center">
<div className="relative mx-auto p-5 border w-full max-w-md shadow-lg rounded-md bg-white">
{/* Modal Header */}
<div className="flex justify-between items-center pb-3 border-b">
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<button
onClick={onClose}
className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center"
aria-label="Close modal"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Modal Body */}
<div className="mt-3">
{children}
</div>
{/* Modal Footer (Optional) */}
{footer && (
<div className="mt-4 pt-3 border-t">
{footer}
</div>
)}
</div>
</div>
);
};
export default Modal;

View File

@@ -0,0 +1,62 @@
import axios from 'axios';
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:7529/api',
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // <--- 取消注释
});
// 请求拦截器用于在每个请求发送前执行某些操作例如添加认证token
api.interceptors.request.use(
async (config) => {
// 假设token存储在localStorage中。实际应用中你可能会从AuthContext或类似地方获取。
// if (typeof window !== 'undefined') { // <--- 暂时注释掉这部分逻辑
// const token = localStorage.getItem('authToken'); // 确保你的登录逻辑中存储了名为 'authToken' 的token
// if (token) {
// config.headers.Authorization = `Bearer ${token}`;
// }
// } // <--- 暂时注释掉这部分逻辑
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器:用于在接收到响应后执行某些操作
api.interceptors.response.use(
(response) => {
// 如果后端返回的数据结构在 data 字段中,有些项目会直接返回 response.data
// 例如: return response.data;
return response; // 当前返回完整的 AxiosResponse 对象
},
(error) => {
// 全局错误处理
// 例如如果401未授权则重定向到登录页面
if (error.response && error.response.status === 401) {
if (typeof window !== 'undefined') {
// 清理认证相关的 localStorage (如果适用)
// localStorage.removeItem('authToken');
// localStorage.removeItem('user');
// window.location.href = '/login';
// 注意:直接使用 window.location.href 会导致全页面刷新。
// 在Next.js中更好的做法是使用router.push(),但这需要在组件上下文中,或通过事件总线/状态管理触发。
// 对于axios拦截器这种非组件上下文可能需要一个更复杂的解决方案来触发路由跳转
// 或者接受这里的全页面刷新或者在调用api的地方具体处理401。
console.error('Unauthorized, redirecting to login might be needed.');
}
}
return Promise.reject(error);
}
);
export { api };
// 定义通用的后端响应格式接口与Spring后端的BaseResponse对应
export interface BaseResponse<T> {
code: number; // 响应状态码0表示成功
data: T; // 泛型数据,可以是任何类型
message?: string; // 可选的消息说明
}

31
package-lock.json generated Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "mqtt_power",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"react-icons": "^5.5.0"
}
},
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmmirror.com/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmmirror.com/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
}
}
}

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"react-icons": "^5.5.0"
}
}

View File

@@ -126,6 +126,44 @@
* 对于超时的任务,更新其状态为 `TIMED_OUT`,记录错误信息。 * 对于超时的任务,更新其状态为 `TIMED_OUT`,记录错误信息。
* 根据业务需要,可能需要同步更新关联的 `charging_robot``charging_session` 的状态。 * 根据业务需要,可能需要同步更新关联的 `charging_robot``charging_session` 的状态。
### 阶段 3: 管理端核心功能完善 与 前后端数据对接 (进行中)
**目标**: 搭建核心管理页面框架,并将部分用户端和管理端页面与真实后端数据对接。
**进行中 / 即将进行:**
1. **用户仪表盘 (`/dashboard`) 数据对接 (规划中)**
* **目的**: 将用户仪表盘的模拟数据替换为真实的后端数据,包括"当前充电状态"和"用户统计数据"(本月充电次数、本月总消费)。
* **后端接口需求**:
* `GET /api/session/my/active`: 获取用户当前激活的充电会话。若无激活会话,返回 null 或明确的空状态。
* **Controller**: `ChargingSessionController`
* **Service**: 查询用户状态为进行中(如 `CHARGING_IN_PROGRESS`)的会话。
* `GET /api/user/stats/mine`: 获取当前登录用户的统计数据。
* **响应示例**: `{ "monthlyCharges": 15, "monthlySpending": 250.75 }`
* **Controller**: `UserController` 或新的 `UserStatsController`
* **Service**: 计算用户本月已完成/已支付的充电次数和总消费。
* **前端修改 (`dashboard/page.tsx`)**:
* 移除相关模拟数据加载逻辑。
* 定义与后端响应匹配的数据接口。
* 使用 `axios` (api 实例) 并行调用上述两个接口。
* 根据真实数据更新UI组件的显示。
* 处理API错误和加载状态。
* **状态**: 等待后端接口实现。
2. **"我的充电记录" (`/my-sessions`) 页面数据对接 (已完成)**
* 前端已修改为调用 `POST /api/session/my/list/page` 接口。
* 后端 `ChargingSessionController``ChargingSessionVO` 已确认基本满足需求。
* **状态**: 已完成,等待用户测试确认。
3. **管理员仪表盘 (`/admin/dashboard`) 框架 (已完成)**
* 包含系统概览统计卡片(模拟数据)和管理模块导航卡片。
* **状态**: 初步完成,后续需对接真实统计数据和启用所有导航链接。
4. **机器人管理页面 (`/admin/robots`) 框架 (已完成)**
* 包含列表展示、搜索、筛选、分页(模拟数据)。
* 模拟的添加、编辑、删除按钮。
* **状态**: 初步完成后续需对接真实数据和实现CRUD操作。
## 4. 数据库 Schema (初步 DDL) ## 4. 数据库 Schema (初步 DDL)
```sql ```sql
@@ -228,6 +266,6 @@ CREATE TABLE `activation_code` (
* 用于指令 (`robot/command/{clientId}`) 和状态 (`robot/status/{clientId}`) Topic 的用户名和密码。 * 用于指令 (`robot/command/{clientId}`) 和状态 (`robot/status/{clientId}`) Topic 的用户名和密码。
* 与硬件团队确认最终的 MQTT Topic 和 Payload 结构。 * 与硬件团队确认最终的 MQTT Topic 和 Payload 结构。
2. **开始开发**: 在获取 MQTT 信息后,可以并行开始: 2. **开始开发**: 在获取 MQTT 信息后,可以并行开始:
* **阶段一**: 数据库初始化、用户模块开发、**`robot_task` 相关基础 Service 开发** * **阶段一**: 数据库初始化、用户模块开发、`robot_task` 相关基础 Service 开发。
* **阶段二**: MQTT 配置、基础连接、订阅实现、**集成 `robot_task` 检查与更新逻辑**、**任务超时处理实现** * **阶段二**: MQTT 配置、基础连接、订阅实现、集成 `robot_task` 检查与更新逻辑任务超时处理实现。
3. 按照开发阶段逐步推进。 3. 按照开发阶段逐步推进。

View File

@@ -0,0 +1,70 @@
package com.yupi.project.controller;
import com.yupi.project.annotation.AuthCheck;
import com.yupi.project.common.BaseResponse;
import com.yupi.project.common.ResultUtils;
import com.yupi.project.constant.UserConstant;
import com.yupi.project.model.vo.AdminDashboardStatsVO;
import com.yupi.project.service.ChargingRobotService;
import com.yupi.project.service.ChargingSessionService;
import com.yupi.project.service.ParkingSpotService;
import com.yupi.project.service.UserService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.math.BigDecimal;
/**
* 管理员统计信息接口
*/
@RestController
@RequestMapping("/admin/stats")
@Slf4j
@Api(tags = "管理员统计数据接口")
public class AdminStatsController {
@Resource
private UserService userService;
@Resource
private ChargingRobotService chargingRobotService;
@Resource
private ChargingSessionService chargingSessionService;
@Resource
private ParkingSpotService parkingSpotService;
@ApiOperation("获取管理员仪表盘统计数据")
@GetMapping("/summary")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<AdminDashboardStatsVO> getAdminDashboardStats() {
long totalUsers = userService.count();
long totalRobots = chargingRobotService.count();
long onlineRobots = chargingRobotService.countOnlineRobots();
long chargingRobots = chargingRobotService.countChargingRobots();
long idleRobots = chargingRobotService.countIdleRobots();
long activeSessions = chargingSessionService.countActiveSessions();
BigDecimal totalRevenue = chargingSessionService.getTotalRevenue();
long totalParkingSpots = parkingSpotService.count();
long availableParkingSpots = parkingSpotService.countAvailableSpots();
AdminDashboardStatsVO statsVO = new AdminDashboardStatsVO();
statsVO.setTotalUsers(totalUsers);
statsVO.setTotalRobots(totalRobots);
statsVO.setOnlineRobots(onlineRobots);
statsVO.setChargingRobots(chargingRobots);
statsVO.setIdleRobots(idleRobots);
statsVO.setActiveSessions(activeSessions);
statsVO.setTotalRevenue(totalRevenue);
statsVO.setTotalParkingSpots(totalParkingSpots);
statsVO.setAvailableParkingSpots(availableParkingSpots);
return ResultUtils.success(statsVO);
}
}

View File

@@ -55,23 +55,34 @@ public class ChargingRobotAdminController {
if (addRequest.getStatus() == null || RobotStatusEnum.getEnumByValue(addRequest.getStatus()) == null) { if (addRequest.getStatus() == null || RobotStatusEnum.getEnumByValue(addRequest.getStatus()) == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "无效的机器人状态"); throw new BusinessException(ErrorCode.PARAMS_ERROR, "无效的机器人状态");
} }
if (addRequest.getBatteryLevel() != null && (addRequest.getBatteryLevel() < 0 || addRequest.getBatteryLevel() > 100)) {
ChargingRobot robot = chargingRobotService.registerRobot(addRequest.getRobotUid(), RobotStatusEnum.getEnumByValue(addRequest.getStatus())); throw new BusinessException(ErrorCode.PARAMS_ERROR, "电池电量必须在0到100之间");
if (robot == null || robot.getId() == null) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "添加机器人失败");
} }
// 可以选择性地更新其他字段,如初始电量等
// 检查机器人UID是否已存在
if (chargingRobotService.findByRobotUid(addRequest.getRobotUid()) != null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "机器人UID '" + addRequest.getRobotUid() + "' 已存在");
}
ChargingRobot newRobot = new ChargingRobot();
newRobot.setRobotUid(addRequest.getRobotUid());
newRobot.setStatus(addRequest.getStatus()); // Directly use the validated status string
if (addRequest.getBatteryLevel() != null) { if (addRequest.getBatteryLevel() != null) {
chargingRobotService.updateBatteryLevel(robot.getId(), addRequest.getBatteryLevel()); newRobot.setBatteryLevel(addRequest.getBatteryLevel());
} }
if (StringUtils.isNotBlank(addRequest.getCurrentLocation())) { if (StringUtils.isNotBlank(addRequest.getCurrentLocation())) {
ChargingRobot updateRobot = new ChargingRobot(); newRobot.setCurrentLocation(addRequest.getCurrentLocation());
updateRobot.setId(robot.getId()); }
updateRobot.setCurrentLocation(addRequest.getCurrentLocation()); // Set createTime and updateTime if your entity doesn't auto-set them with @TableField(fill = ...)
chargingRobotService.updateById(updateRobot); // newRobot.setCreateTime(new Date());
// newRobot.setUpdateTime(new Date());
boolean saved = chargingRobotService.save(newRobot);
if (!saved || newRobot.getId() == null) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "添加机器人失败");
} }
return ResultUtils.success(robot.getId()); return ResultUtils.success(newRobot.getId());
} }
@ApiOperation("删除充电机器人") @ApiOperation("删除充电机器人")
@@ -108,9 +119,9 @@ public class ChargingRobotAdminController {
} }
@ApiOperation("根据ID获取充电机器人信息") @ApiOperation("根据ID获取充电机器人信息")
@GetMapping("/get") @GetMapping("/get/{id}")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE) @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<ChargingRobot> getChargingRobotById(long id, HttpServletRequest request) { public BaseResponse<ChargingRobot> getChargingRobotById(@PathVariable long id, HttpServletRequest request) {
if (id <= 0) { if (id <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR); throw new BusinessException(ErrorCode.PARAMS_ERROR);
} }

View File

@@ -79,6 +79,18 @@ public class ChargingSessionController {
return ResultUtils.success(sessionVOPage); return ResultUtils.success(sessionVOPage);
} }
@ApiOperation("用户获取当前激活的充电会话")
@GetMapping("/my/active")
@AuthCheck // 需要登录
public BaseResponse<ChargingSessionVO> getMyActiveChargingSession(HttpServletRequest request) {
User loginUser = userService.getCurrentUser(request);
ChargingSession activeSession = chargingSessionService.getActiveSessionByUserId(loginUser.getId());
if (activeSession == null) {
return ResultUtils.success(null); // 没有激活的会话
}
return ResultUtils.success(ChargingSessionVO.objToVo(activeSession));
}
@ApiOperation("用户获取单个充电会话详情") @ApiOperation("用户获取单个充电会话详情")
@GetMapping("/get") @GetMapping("/get")
@AuthCheck @AuthCheck

View File

@@ -21,6 +21,7 @@ import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource; import javax.annotation.Resource;
@@ -53,14 +54,23 @@ public class ParkingSpotAdminController {
} }
// robotAssignable 默认为true如果 DTO 中是 Boolean则 null 会被处理 // robotAssignable 默认为true如果 DTO 中是 Boolean则 null 会被处理
ParkingSpot spot = parkingSpotService.addParkingSpot( ParkingSpot spot;
try {
spot = parkingSpotService.addParkingSpot(
addRequest.getSpotUid(), addRequest.getSpotUid(),
addRequest.getLocationDescription(), addRequest.getLocationDescription(),
addRequest.getRobotAssignable() == null ? true : addRequest.getRobotAssignable() addRequest.getRobotAssignable() == null ? true : addRequest.getRobotAssignable()
); );
} catch (DuplicateKeyException e) {
//捕获由数据库唯一约束抛出的 DuplicateKeyException
log.warn("添加车位失败车位UID已存在 (数据库约束冲突): {}", addRequest.getSpotUid(), e);
throw new BusinessException(ErrorCode.PARAMS_ERROR, "车位UID '" + addRequest.getSpotUid() + "' 已存在。");
}
// BusinessException (如 "车位UID已存在") 会由 Service 层直接抛出,这里无需再次捕获
if (spot == null || spot.getId() == null) { if (spot == null || spot.getId() == null) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "添加车位失败"); // 这种情况理论上应该被Service层的异常覆盖或者save失败时抛出异常
throw new BusinessException(ErrorCode.OPERATION_ERROR, "添加车位失败,未知原因。");
} }
return ResultUtils.success(spot.getId()); return ResultUtils.success(spot.getId());
} }
@@ -130,7 +140,9 @@ public class ParkingSpotAdminController {
queryWrapper.eq("robot_assignable", queryRequest.getRobotAssignable()); queryWrapper.eq("robot_assignable", queryRequest.getRobotAssignable());
} }
if (StringUtils.isNotBlank(queryRequest.getSortField())) { if (StringUtils.isNotBlank(queryRequest.getSortField())) {
queryWrapper.orderBy(true, queryRequest.getSortOrder().equals("ascend"), queryRequest.getSortField()); // 确保 queryRequest.getSortOrder() 不为 null并转换为 boolean
boolean isAsc = "asc".equalsIgnoreCase(queryRequest.getSortOrder()) || "ascend".equalsIgnoreCase(queryRequest.getSortOrder());
queryWrapper.orderBy(true, isAsc, queryRequest.getSortField());
} }
parkingSpotService.page(page, queryWrapper); parkingSpotService.page(page, queryWrapper);

View File

@@ -0,0 +1,38 @@
package com.yupi.project.controller;
import com.yupi.project.common.BaseResponse;
import com.yupi.project.common.ResultUtils;
import com.yupi.project.model.entity.ParkingSpot;
import com.yupi.project.service.ParkingSpotService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
/**
* 车位接口 (用户端)
*/
@RestController
@RequestMapping("/parking-spot") // 修改这里:移除 /api 前缀
@Slf4j
@Api(tags = "车位接口 (用户端)")
public class ParkingSpotController {
@Resource
private ParkingSpotService parkingSpotService;
@ApiOperation("获取可用且可指派的充电车位列表")
@GetMapping("/list/available-assignable") // Specific path frontend is calling
public BaseResponse<List<ParkingSpot>> listAvailableAndAssignableSpots() {
List<ParkingSpot> spots = parkingSpotService.findAvailableAndAssignableSpots();
return ResultUtils.success(spots);
}
// Można tu dodać inne publicznie dostępne endpointy dotyczące miejsc parkingowych, jeśli potrzebne
// np. GET /parking-spot/{id} - publiczne dane o miejscu
}

View File

@@ -1,5 +1,6 @@
package com.yupi.project.controller; package com.yupi.project.controller;
import com.yupi.project.annotation.AuthCheck;
import com.yupi.project.common.BaseResponse; import com.yupi.project.common.BaseResponse;
import com.yupi.project.common.ErrorCode; import com.yupi.project.common.ErrorCode;
import com.yupi.project.common.ResultUtils; import com.yupi.project.common.ResultUtils;
@@ -11,10 +12,12 @@ import com.yupi.project.model.enums.UserRoleEnum;
import com.yupi.project.service.UserService; import com.yupi.project.service.UserService;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import io.swagger.annotations.ApiOperation;
import javax.annotation.Resource; import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 用户接口 * 用户接口
@@ -73,6 +76,15 @@ public class UserController {
return ResultUtils.success(currentUser); return ResultUtils.success(currentUser);
} }
@ApiOperation("获取当前登录用户的统计信息")
@GetMapping("/stats/mine")
@AuthCheck // 需要登录
public BaseResponse<Map<String, Object>> getMyStats(HttpServletRequest request) {
User loginUser = userService.getCurrentUser(request);
Map<String, Object> stats = userService.getUserDashboardStats(loginUser.getId());
return ResultUtils.success(stats);
}
// --- 管理员功能 --- // // --- 管理员功能 --- //
@GetMapping("/list") @GetMapping("/list")

View File

@@ -0,0 +1,20 @@
package com.yupi.project.model.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 管理员仪表盘统计数据视图对象
*/
@Data
public class AdminDashboardStatsVO {
private Long totalUsers;
private Long totalRobots;
private Long onlineRobots;
private Long chargingRobots;
private Long idleRobots;
private Long activeSessions;
private BigDecimal totalRevenue;
private Long totalParkingSpots;
private Long availableParkingSpots;
}

View File

@@ -86,4 +86,25 @@ public interface ChargingRobotService extends IService<ChargingRobot> {
* @return 更新是否成功 * @return 更新是否成功
*/ */
boolean updateRobotStatus(String robotUID, RobotStatusEnum status, String location, Integer batteryLevel, Long currentTaskId, Date lastHeartbeatTime); boolean updateRobotStatus(String robotUID, RobotStatusEnum status, String location, Integer batteryLevel, Long currentTaskId, Date lastHeartbeatTime);
/**
* 获取在线机器人数量 (状态不为 offline 或 error)
*
* @return 在线机器人数量
*/
long countOnlineRobots();
/**
* 获取正在充电的机器人数量 (状态为 charging)
*
* @return 正在充电的机器人数量
*/
long countChargingRobots();
/**
* 获取空闲机器人数量 (状态为 idle)
*
* @return 空闲机器人数量
*/
long countIdleRobots();
} }

View File

@@ -131,4 +131,30 @@ public interface ChargingSessionService extends IService<ChargingSession> {
*/ */
QueryWrapper<ChargingSession> getQueryWrapper(ChargingSessionQueryRequest queryRequest); QueryWrapper<ChargingSession> getQueryWrapper(ChargingSessionQueryRequest queryRequest);
/**
* 根据用户ID获取当前激活的充电会话
*
* @param userId 用户ID
* @return ChargingSession 实体,如果没有激活的则返回 null
*/
ChargingSession getActiveSessionByUserId(Long userId);
/**
* 获取今日的充电会话总数 (包括进行中和已完成)
*
* @return 今日充电会话总数
*/
long countTodaySessions();
/**
* 获取今日的总收入 (基于已支付的会话)
*
* @return 今日总收入
*/
BigDecimal sumTodayRevenue();
long countActiveSessions();
BigDecimal getTotalRevenue();
} }

View File

@@ -76,4 +76,11 @@ public interface ParkingSpotService extends IService<ParkingSpot> {
*/ */
boolean updateSpotStatus(String spotUID, ParkingSpotStatusEnum newStatus, Long currentSessionId); boolean updateSpotStatus(String spotUID, ParkingSpotStatusEnum newStatus, Long currentSessionId);
/**
* 获取可用车位数量 (状态为 available)
*
* @return 可用车位数量
*/
long countAvailableSpots();
} }

View File

@@ -5,6 +5,7 @@ import com.yupi.project.model.entity.User;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* @description 针对表【user(用户表)】的数据库操作Service * @description 针对表【user(用户表)】的数据库操作Service
@@ -104,4 +105,12 @@ public interface UserService extends IService<User> {
* @return 操作是否成功 * @return 操作是否成功
*/ */
boolean adminUpdateUser(com.yupi.project.model.dto.user.UserAdminUpdateRequest updateRequest); boolean adminUpdateUser(com.yupi.project.model.dto.user.UserAdminUpdateRequest updateRequest);
/**
* 获取用户的仪表盘统计数据 (例如本月充电次数和消费)
*
* @param userId 用户ID
* @return 包含统计数据的 Map
*/
Map<String, Object> getUserDashboardStats(Long userId);
} }

View File

@@ -13,6 +13,7 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
@@ -186,4 +187,26 @@ public class ChargingRobotServiceImpl extends ServiceImpl<ChargingRobotMapper, C
// 将机器人状态设置为空闲并清除当前任务ID // 将机器人状态设置为空闲并清除当前任务ID
return updateRobotStatus(robot.getRobotUid(), RobotStatusEnum.IDLE, null, null, null, null); // 第二个参数传null以清除任务ID return updateRobotStatus(robot.getRobotUid(), RobotStatusEnum.IDLE, null, null, null, null); // 第二个参数传null以清除任务ID
} }
@Override
public long countOnlineRobots() {
QueryWrapper<ChargingRobot> queryWrapper = new QueryWrapper<>();
List<String> offlineStatus = Arrays.asList(RobotStatusEnum.OFFLINE.getValue(), RobotStatusEnum.ERROR.getValue());
queryWrapper.notIn("status", offlineStatus);
return this.count(queryWrapper);
}
@Override
public long countChargingRobots() {
QueryWrapper<ChargingRobot> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("status", RobotStatusEnum.CHARGING.getValue());
return this.count(queryWrapper);
}
@Override
public long countIdleRobots() {
QueryWrapper<ChargingRobot> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("status", RobotStatusEnum.IDLE.getValue());
return this.count(queryWrapper);
}
} }

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yupi.project.common.ErrorCode; import com.yupi.project.common.ErrorCode;
import com.yupi.project.exception.BusinessException; import com.yupi.project.exception.BusinessException;
import com.yupi.project.exception.ThrowUtils;
import com.yupi.project.mapper.ChargingSessionMapper; import com.yupi.project.mapper.ChargingSessionMapper;
import com.yupi.project.model.dto.charging_session.ChargingRequest; import com.yupi.project.model.dto.charging_session.ChargingRequest;
import com.yupi.project.model.dto.charging_session.ChargingSessionQueryRequest; import com.yupi.project.model.dto.charging_session.ChargingSessionQueryRequest;
@@ -21,8 +22,13 @@ import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.Arrays;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
@@ -41,9 +47,6 @@ public class ChargingSessionServiceImpl extends ServiceImpl<ChargingSessionMappe
@Resource @Resource
private ChargingRobotService chargingRobotService; private ChargingRobotService chargingRobotService;
@Resource
private UserService userService;
@Resource @Resource
private RobotTaskService robotTaskService; // 用于创建和关联机器人任务 private RobotTaskService robotTaskService; // 用于创建和关联机器人任务
@@ -321,45 +324,22 @@ public class ChargingSessionServiceImpl extends ServiceImpl<ChargingSessionMappe
@Override @Override
@Transactional @Transactional
public boolean processPayment(Long sessionId, Long userId) { public boolean processPayment(Long sessionId, Long userId) {
// 省略具体支付逻辑,假设支付成功,更新会话状态
// 实际应用中这里会调用支付网关,并根据支付结果更新
ChargingSession session = this.getById(sessionId); ChargingSession session = this.getById(sessionId);
if (session == null) { if (session == null || !session.getUserId().equals(userId)) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "充电会话不存在"); throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "会话不存在或不属于您");
} }
if (!session.getUserId().equals(userId)) { if (!ChargingSessionStatusEnum.PAYMENT_PENDING.getValue().equals(session.getStatus())) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权操作他人充电记录"); throw new BusinessException(ErrorCode.OPERATION_ERROR, "会话状态不正确,无法支付");
}
if (!ChargingSessionStatusEnum.PAYMENT_PENDING.getValue().equals(session.getStatus()) ||
!PaymentStatusEnum.PENDING.getValue().equals(session.getPaymentStatus())) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "会话状态非待支付,无法处理支付");
}
BigDecimal cost = session.getCost();
if (cost == null || cost.compareTo(BigDecimal.ZERO) <= 0) {
log.info("会话 {} 费用为0或无效标记为已支付 (无需支付)", sessionId);
session.setPaymentStatus(PaymentStatusEnum.PAID.getValue());
session.setStatus(ChargingSessionStatusEnum.PAID.getValue()); // 主状态也更新为已支付
session.setUpdateTime(new Date());
return this.updateById(session);
}
// 模拟扣款
boolean paymentSuccess = userService.decreaseBalance(userId, cost);
if (!paymentSuccess) {
log.warn("用户 {} 为会话 {} 支付 {}元 失败,余额不足或操作失败", userId, sessionId, cost);
session.setPaymentStatus(PaymentStatusEnum.FAILED.getValue());
// 可以考虑是否将会话状态改为ERROR或保持PAYMENT_PENDING
this.updateById(session);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "支付失败,余额不足或系统错误");
} }
session.setPaymentStatus(PaymentStatusEnum.PAID.getValue()); session.setPaymentStatus(PaymentStatusEnum.PAID.getValue());
session.setStatus(ChargingSessionStatusEnum.PAID.getValue()); // 主状态也更新为已支付 session.setStatus(ChargingSessionStatusEnum.PAID.getValue());
session.setUpdateTime(new Date()); boolean success = this.updateById(session);
boolean updated = this.updateById(session); ThrowUtils.throwIf(!success, ErrorCode.SYSTEM_ERROR, "支付状态更新失败");
if (updated) { // TODO: 触发其他后续逻辑,如发送通知
log.info("用户 {} 为会话 {} 成功支付 {}元", userId, sessionId, cost); return true;
}
return updated;
} }
@Override @Override
@@ -579,4 +559,97 @@ public class ChargingSessionServiceImpl extends ServiceImpl<ChargingSessionMappe
log.info("用户 {} 已请求停止充电会话 {}。等待机器人确认。", userId, sessionId); log.info("用户 {} 已请求停止充电会话 {}。等待机器人确认。", userId, sessionId);
return true; return true;
} }
@Override
public ChargingSession getActiveSessionByUserId(Long userId) {
if (userId == null) {
return null;
}
QueryWrapper<ChargingSession> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_id", userId);
queryWrapper.eq("status", ChargingSessionStatusEnum.CHARGING_STARTED.getValue());
// 通常一个用户只有一个正在进行的会话,但为了严谨可以取最新的一个(如果有多条脏数据)
queryWrapper.orderByDesc("create_time");
queryWrapper.last("LIMIT 1");
return this.getOne(queryWrapper);
}
@Override
public long countTodaySessions() {
QueryWrapper<ChargingSession> queryWrapper = new QueryWrapper<>();
LocalDateTime startOfDay = LocalDateTime.of(LocalDate.now(), LocalTime.MIN);
LocalDateTime endOfDay = LocalDateTime.of(LocalDate.now(), LocalTime.MAX);
queryWrapper.ge("create_time", startOfDay); // 或者用 start_time取决于业务定义
queryWrapper.le("create_time", endOfDay);
// 包括正在进行的和已完成/已支付的
List<String> relevantStatuses = Arrays.asList(
ChargingSessionStatusEnum.CHARGING_STARTED.getValue(), // 充电进行中
// ChargingSessionStatusEnum.ROBOT_EN_ROUTE.getValue(), // 如果也算今日会话可以加上
// ChargingSessionStatusEnum.ROBOT_ARRIVED.getValue(), // 如果也算
ChargingSessionStatusEnum.CHARGING_COMPLETED.getValue(), // 充电已完成
ChargingSessionStatusEnum.PAID.getValue() // 已支付
);
queryWrapper.in("status", relevantStatuses);
return this.count(queryWrapper);
}
@Override
public BigDecimal sumTodayRevenue() {
// 获取今天的起始和结束时间
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX);
Date startDate = Date.from(startOfDay.atZone(java.time.ZoneId.systemDefault()).toInstant());
Date endDate = Date.from(endOfDay.atZone(java.time.ZoneId.systemDefault()).toInstant());
QueryWrapper<ChargingSession> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("status", ChargingSessionStatusEnum.PAID.getValue());
queryWrapper.between("charge_end_time", startDate, endDate); // 假设以充电结束时间为准
queryWrapper.select("IFNULL(SUM(cost), 0) as totalRevenue"); // 使用IFNULL处理没有记录的情况
Map<String, Object> result = this.getMap(queryWrapper);
if (result != null && result.get("totalRevenue") != null) {
// SUM(cost) 返回的可能是 BigDecimal 或 Double取决于数据库和驱动
Object revenueObj = result.get("totalRevenue");
if (revenueObj instanceof BigDecimal) {
return (BigDecimal) revenueObj;
} else if (revenueObj instanceof Number) {
return BigDecimal.valueOf(((Number) revenueObj).doubleValue());
}
}
return BigDecimal.ZERO;
}
@Override
public long countActiveSessions() {
QueryWrapper<ChargingSession> queryWrapper = new QueryWrapper<>();
// 定义哪些状态算作"活动中"
List<String> activeStatuses = Arrays.asList(
ChargingSessionStatusEnum.REQUESTED.getValue(),
ChargingSessionStatusEnum.ROBOT_ASSIGNED.getValue(),
ChargingSessionStatusEnum.ROBOT_ARRIVED.getValue(),
ChargingSessionStatusEnum.CHARGING_STARTED.getValue(),
ChargingSessionStatusEnum.PAYMENT_PENDING.getValue() // 用户停止充电后,等待支付也算活动
);
queryWrapper.in("status", activeStatuses);
return this.count(queryWrapper);
}
@Override
public BigDecimal getTotalRevenue() {
QueryWrapper<ChargingSession> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("status", ChargingSessionStatusEnum.PAID.getValue());
queryWrapper.select("IFNULL(SUM(cost), 0) as totalRevenue");
Map<String, Object> result = this.getMap(queryWrapper);
if (result != null && result.get("totalRevenue") != null) {
Object revenueObj = result.get("totalRevenue");
if (revenueObj instanceof BigDecimal) {
return (BigDecimal) revenueObj;
} else if (revenueObj instanceof Number) {
return BigDecimal.valueOf(((Number) revenueObj).doubleValue());
}
}
return BigDecimal.ZERO;
}
} }

View File

@@ -32,9 +32,14 @@ public class MqttServiceImpl implements MqttService {
// 1. Check if the robot has pending or already sent tasks // 1. Check if the robot has pending or already sent tasks
if (robotTaskService.hasPendingOrSentTask(robotId)) { if (robotTaskService.hasPendingOrSentTask(robotId)) {
// 添加优先级处理STOP_CHARGE 命令应该可以覆盖其他任务
if (CommandTypeEnum.STOP_CHARGE.equals(commandType)) {
log.info("Robot {} has pending tasks, but STOP_CHARGE is a priority command, proceeding anyway.", robotId);
} else {
log.warn("Robot {} is busy (has PENDING or SENT tasks). Command {} aborted.", robotId, commandType); log.warn("Robot {} is busy (has PENDING or SENT tasks). Command {} aborted.", robotId, commandType);
return false; return false;
} }
}
// 2. Create a new task in PENDING state // 2. Create a new task in PENDING state
RobotTask task = robotTaskService.createTask(robotId, commandType, payloadJson, sessionId); RobotTask task = robotTaskService.createTask(robotId, commandType, payloadJson, sessionId);

View File

@@ -167,4 +167,11 @@ public class ParkingSpotServiceImpl extends ServiceImpl<ParkingSpotMapper, Parki
} }
return success; return success;
} }
@Override
public long countAvailableSpots() {
QueryWrapper<ParkingSpot> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("status", ParkingSpotStatusEnum.AVAILABLE.getValue());
return this.count(queryWrapper);
}
} }

View File

@@ -8,6 +8,9 @@ import com.yupi.project.exception.BusinessException;
import com.yupi.project.mapper.UserMapper; import com.yupi.project.mapper.UserMapper;
import com.yupi.project.model.entity.User; import com.yupi.project.model.entity.User;
import com.yupi.project.service.UserService; import com.yupi.project.service.UserService;
import com.yupi.project.service.ChargingSessionService;
import com.yupi.project.model.entity.ChargingSession;
import com.yupi.project.model.enums.ChargingSessionStatusEnum;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
@@ -27,6 +30,12 @@ import java.util.regex.Pattern;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Map;
import java.util.HashMap;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.TemporalAdjusters;
import java.util.Date;
/** /**
* 用户服务实现类 * 用户服务实现类
@@ -42,6 +51,9 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User>
@Resource @Resource
private PasswordEncoder passwordEncoder; private PasswordEncoder passwordEncoder;
@Resource
private ChargingSessionService chargingSessionService;
// 用户名校验正则允许字母、数字、下划线长度4到16位 // 用户名校验正则允许字母、数字、下划线长度4到16位
private static final String USERNAME_PATTERN = "^[a-zA-Z0-9_]{4,16}$"; private static final String USERNAME_PATTERN = "^[a-zA-Z0-9_]{4,16}$";
// 密码校验正则至少包含字母和数字长度至少6位 // 密码校验正则至少包含字母和数字长度至少6位
@@ -395,4 +407,42 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User>
return false; return false;
} }
} }
@Override
public Map<String, Object> getUserDashboardStats(Long userId) {
if (userId == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户ID不能为空");
}
// 获取本月的第一天和最后一天
LocalDateTime now = LocalDateTime.now();
LocalDateTime firstDayOfMonth = now.with(TemporalAdjusters.firstDayOfMonth()).withHour(0).withMinute(0).withSecond(0);
LocalDateTime lastDayOfMonth = now.with(TemporalAdjusters.lastDayOfMonth()).withHour(23).withMinute(59).withSecond(59);
Date startDate = Date.from(firstDayOfMonth.atZone(ZoneId.systemDefault()).toInstant());
Date endDate = Date.from(lastDayOfMonth.atZone(ZoneId.systemDefault()).toInstant());
// 查询本月已完成的充电会话
QueryWrapper<ChargingSession> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_id", userId);
queryWrapper.eq("status", ChargingSessionStatusEnum.PAID.getValue()); // 修正: COMPLETED -> PAID (统计已支付完成的会话)
queryWrapper.between("charge_end_time", startDate, endDate); // 假设用 charge_end_time 判断是否在本月完成
List<ChargingSession> monthlySessions = chargingSessionService.list(queryWrapper);
long monthlyCharges = monthlySessions.size();
BigDecimal monthlySpending = BigDecimal.ZERO;
for (ChargingSession session : monthlySessions) {
if (session.getCost() != null) {
monthlySpending = monthlySpending.add(session.getCost());
}
}
Map<String, Object> stats = new HashMap<>();
stats.put("monthlyCharges", monthlyCharges);
stats.put("monthlySpending", monthlySpending.setScale(2, BigDecimal.ROUND_HALF_UP));
return stats;
}
} }

View File

@@ -0,0 +1,57 @@
{
"groups": [
{
"name": "mqtt",
"type": "com.yupi.project.config.properties.MqttProperties",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
}
],
"properties": [
{
"name": "mqtt.broker-url",
"type": "java.lang.String",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.client-id-prefix",
"type": "java.lang.String",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.command-topic-base",
"type": "java.lang.String",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.connection-timeout",
"type": "java.lang.Integer",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.default-qos",
"type": "java.lang.Integer",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.keep-alive-interval",
"type": "java.lang.Integer",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.password",
"type": "java.lang.String",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.status-topic-base",
"type": "java.lang.String",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.username",
"type": "java.lang.String",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
}
],
"hints": []
}

View File

@@ -0,0 +1,6 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/my_db
username: root
password: 123456

View File

@@ -0,0 +1,65 @@
spring:
application:
name: mqtt-charging-system
# DataSource Config
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://yuyun-us1.stormrain.cn:3306/mqtt_power?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: mysql_a4MQ4P
mvc:
pathmatch:
matching-strategy: ANT_PATH_MATCHER
# session 失效时间(秒)
session:
timeout: 86400
server:
port: 7529
servlet:
context-path: /api
session:
timeout: 86400 # 设置session的过期时间单位为秒这里设置为1天
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: isDelete # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
logic-delete-value: 1 # 逻辑已删除值(默认为 1)
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
# Logging configuration
logging:
level:
# Set root logger level (e.g., INFO, WARN, ERROR, DEBUG)
root: INFO
# Set specific package levels
com.yupi.project: DEBUG # Example: Set your project's base package to DEBUG
org.springframework.web: INFO # Set Spring Web logging level
org.springframework.security: DEBUG # Enable Spring Security DEBUG logging
org.mybatis: INFO # Set MyBatis logging level
# ... other specific loggers
#file:
#name: logs/application.log # Log file name
#path: ./logs # Log file path
#pattern:
#console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
#file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
# ===================================================================
# MQTT Configurations
# ===================================================================
mqtt:
broker-url: tcp://broker.emqx.io:1883
username: # Public broker, no credentials specified for connection
password: # Public broker, no credentials specified for connection
client-id-prefix: backend-yupi-mqtt-power- # Unique client ID prefix for our project
default-qos: 1 # Default Quality of Service (0, 1, 2)
connection-timeout: 30 # Connection timeout in seconds
keep-alive-interval: 60 # Keep alive interval in seconds
command-topic-base: yupi_mqtt_power_project/robot/command # Prefixed base topic for sending commands
status-topic-base: yupi_mqtt_power_project/robot/status # Prefixed base topic for receiving status
task: # Task specific configurations
timeoutSeconds: 300 # Default 300 seconds (5 minutes) for a task to be considered timed out
timeoutCheckRateMs: 60000 # Default 60000 ms (1 minute) for how often to check for timed out tasks

View File

@@ -0,0 +1 @@
我的项目 by 程序员鱼皮 https://github.com/liyupi

Some files were not shown because too many files have changed in this diff Show More