diff --git a/LogBook.md b/LogBook.md deleted file mode 100644 index 900a111..0000000 --- a/LogBook.md +++ /dev/null @@ -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` 和 `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)。 \ No newline at end of file diff --git a/LogBook_Phase2-3.md b/LogBook_Phase2-3.md new file mode 100644 index 0000000..842b565 --- /dev/null +++ b/LogBook_Phase2-3.md @@ -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` 和 `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`: + - 移除 `` 组件的 `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` 接口,解决了编译错误。 + - 在 `dashboard/page.tsx` 中添加字段映射逻辑,处理后端返回的 `monthlyCharges` 字段与前端 `monthlySessions` 字段的不匹配问题。 + - 添加了控制台日志输出,便于调试API响应的数据结构。 + - 此修复确保了无论后端返回哪种字段名,前端都能正确展示用户的月度充电次数。 + +- **修复** "我的充电记录" (`my-sessions/page.tsx`) 页面显示"暂无充电记录"的问题: + - 更新了 API 调用,使用正确的 `BaseResponse>` 泛型类型来解析后端分页数据。 + - 修改了数据提取逻辑,从 `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)。 \ No newline at end of file diff --git a/charging_web_app/package-lock.json b/charging_web_app/package-lock.json index 760ff16..4f4eba1 100644 --- a/charging_web_app/package-lock.json +++ b/charging_web_app/package-lock.json @@ -12,7 +12,8 @@ "axios": "^1.9.0", "next": "15.3.2", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-icons": "^5.5.0" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -5783,6 +5784,15 @@ "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": { "version": "16.13.1", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", diff --git a/charging_web_app/package.json b/charging_web_app/package.json index ba9b6d2..28f0fef 100644 --- a/charging_web_app/package.json +++ b/charging_web_app/package.json @@ -13,7 +13,8 @@ "axios": "^1.9.0", "next": "15.3.2", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-icons": "^5.5.0" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/charging_web_app/src/app/(authenticated)/admin/dashboard/page.tsx b/charging_web_app/src/app/(authenticated)/admin/dashboard/page.tsx index 389dace..d905cdd 100644 --- a/charging_web_app/src/app/(authenticated)/admin/dashboard/page.tsx +++ b/charging_web_app/src/app/(authenticated)/admin/dashboard/page.tsx @@ -1,69 +1,226 @@ 'use client'; -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { useAuth } from '@/contexts/AuthContext'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; 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 = () => { - const { user, logout, isLoading, isAuthenticated } = useAuth(); - const router = useRouter(); +// 匹配后端 AdminDashboardStatsVO +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; +} - if (isLoading) { - return ( -
- -
- ); - } +// 建议定义一个通用的 BaseResponse 接口 +interface BackendBaseResponse { + data: T; + message: string; + code?: number; +} - if (!isAuthenticated || !user) { - // AuthenticatedLayout 应该已经处理了重定向 - return
; - } +const AdminDashboardPage = () => { + const { user, isLoading, isAuthenticated } = useAuth(); + const router = useRouter(); + const [adminStats, setAdminStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(true); + const [statsError, setStatsError] = useState(null); - // 如果用户不是管理员,则重定向到普通用户dashboard - if (user.role !== 'admin') { - router.replace('/dashboard'); - return
; // 显示加载直到重定向完成 - } + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.replace('/login'); + return; + } + if (isAuthenticated && user?.role !== 'admin') { + router.replace('/dashboard'); + } + }, [isLoading, isAuthenticated, user, router]); - return ( -
-
-

- 管理员控制台 -

-

- 欢迎, 管理员 {user.username}! -

- -
- - -

用户管理

-

查看和管理用户列表

-
- - {/* 可以添加更多管理员功能模块链接 */} -
-

系统设置 (待开发)

-

配置系统参数

-
+ useEffect(() => { + if (isAuthenticated && user?.role === 'admin') { + setStatsLoading(true); + setStatsError(null); + api.get>('/admin/stats/summary') // 使用 BackendBaseResponse + .then((response: AxiosResponse>) => { + 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 ; + } + + if (isAuthenticated && user?.role !== 'admin') { + return ; + } + + if (!user) { + return ; + } + + const StatCard = ({ title, value, icon, subValue, unit }: { title: string; value: string | number; icon: React.ReactNode; subValue?: string, unit?: string }) => ( +
+
+ {icon} +
+
+

{title}

+

+ {value} + {unit && {unit}} +

+ {subValue &&

{subValue}

} +
+ ); - -
-
- ); +
+ {icon} +

{title}

+
+

{description}

+ + ); + + return ( +
+
+ +

管理员控制台

+ +
+

系统概览

+ {statsLoading ? ( +
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+ ))} +
+ ) : statsError ? ( +

{statsError}

+ ) : adminStats ? ( +
+ } /> + } + /> + } + /> + } + /> +
+ ) : ( +

当前无系统统计数据。

+ )} +
+ +
+

管理模块

+
+ } + /> + } + /> + } + disabled={true} + /> + } + /> + } + disabled={true} + /> +
+
+ +
+
+ ); }; -export default AdminDashboardPage; \ No newline at end of file +export default AdminDashboardPage; \ No newline at end of file diff --git a/charging_web_app/src/app/(authenticated)/admin/parking-spots/page.tsx b/charging_web_app/src/app/(authenticated)/admin/parking-spots/page.tsx new file mode 100644 index 0000000..d1a89fe --- /dev/null +++ b/charging_web_app/src/app/(authenticated)/admin/parking-spots/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [totalSpots, setTotalSpots] = useState(0); + + // State for query params and pagination + const [queryParams, setQueryParams] = useState({ + 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({ + spotUid: '', + locationDesc: '', + status: 'AVAILABLE', + robotAssignable: true, + }); + const [addSpotLoading, setAddSpotLoading] = useState(false); + const [addSpotError, setAddSpotError] = useState(null); + + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editingSpot, setEditingSpot] = useState(null); + const [editSpotLoading, setEditSpotLoading] = useState(false); + const [editSpotError, setEditSpotError] = useState(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(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) => { + 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) => { + 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) => { + 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' ? : ; + } + return null; + }; + + if (authLoading || (!isAdmin && user)) { + return ; + } + + if (!user) { + return

正在重定向到登录页面...

; + } + + if (!isAdmin) { + return

访问被拒绝。正在重定向...

; + } + + // Main content for Admin + return ( +
+ {/* Notification Area */} + {notification && ( +
+ {notification.message} +
+ )} + +
+
+

管理员:车位管理

+ + 返回工作台 + +
+ +
+ + {/* Search and Filter Bar */} +
+
+
+ + 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" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Add New Spot Modal */} + + + +
+ } + > +
{ e.preventDefault(); handleAddNewSpot(); }}> + {addSpotError &&

{addSpotError}

} +
+ + +
+
+ +