添加卧转立扫码、卧转立、大车、大理片笼基础任务流转逻辑
19个文件已修改
27个文件已添加
3个文件已删除
| New file |
| | |
| | | # 项目架构说明文档 |
| | | |
| | | ## 📋 项目主体架构 |
| | | |
| | | ### 核心主体:TaskExecutionEngine(任务执行引擎) |
| | | |
| | | **位置**:`com.mes.task.service.TaskExecutionEngine` |
| | | |
| | | **职责**:多设备任务执行的核心引擎,负责: |
| | | - 串行/并行执行模式控制 |
| | | - 设备步骤执行调度 |
| | | - 设备协调检查 |
| | | - 错误处理和重试 |
| | | - 实时状态通知 |
| | | |
| | | ## 🔄 调用流程 |
| | | |
| | | ### 1. 任务启动流程 |
| | | |
| | | ``` |
| | | 用户请求 (Controller) |
| | | ↓ |
| | | MultiDeviceTaskServiceImpl.startTask() |
| | | ├─ 验证设备组 |
| | | ├─ 获取设备列表 |
| | | ├─ 创建任务记录 (PENDING) |
| | | └─ 异步执行 executeTaskAsync() |
| | | ↓ |
| | | TaskExecutionEngine.execute() |
| | | ├─ 设备协调检查 (DeviceCoordinationService) |
| | | ├─ 确定执行模式 (串行/并行) |
| | | └─ 执行设备步骤 |
| | | ``` |
| | | |
| | | ### 2. 设备步骤执行流程 |
| | | |
| | | ``` |
| | | TaskExecutionEngine.executeStep() |
| | | ↓ |
| | | 检查是否有 DeviceInteraction |
| | | ├─ 有 → executeInteractionStepWithRetry() |
| | | │ ↓ |
| | | │ DeviceInteraction.execute(InteractionContext) |
| | | │ ↓ |
| | | │ 具体设备交互实现(如 LoadVehicleInteraction) |
| | | │ ↓ |
| | | │ 调用 DeviceInteractionService |
| | | │ ↓ |
| | | │ 最终调用 DeviceLogicHandler |
| | | │ |
| | | └─ 无 → 直接调用 DeviceLogicHandler |
| | | ↓ |
| | | DeviceLogicHandlerFactory.getHandler(deviceType) |
| | | ↓ |
| | | BaseDeviceLogicHandler.execute() |
| | | ↓ |
| | | 子类实现 doExecute() |
| | | ``` |
| | | |
| | | ### 3. 两种执行路径 |
| | | |
| | | #### 路径A:通过 DeviceInteraction(推荐用于复杂设备) |
| | | |
| | | ``` |
| | | TaskExecutionEngine |
| | | ↓ |
| | | DeviceInteraction.execute(InteractionContext) |
| | | ↓ |
| | | 具体实现类(如 LoadVehicleInteraction) |
| | | ├─ 状态检查 |
| | | ├─ 设备选择(多实例协调) |
| | | ├─ 数据准备 |
| | | └─ 调用 DeviceInteractionService |
| | | ↓ |
| | | DeviceLogicHandler.execute() |
| | | ``` |
| | | |
| | | **适用场景**: |
| | | - 需要多实例协调的设备(如大车设备) |
| | | - 需要复杂前置检查的设备 |
| | | - 需要数据转换和准备的设备 |
| | | |
| | | #### 路径B:直接调用 DeviceLogicHandler(简单设备) |
| | | |
| | | ``` |
| | | TaskExecutionEngine |
| | | ↓ |
| | | DeviceLogicHandlerFactory.getHandler(deviceType) |
| | | ↓ |
| | | BaseDeviceLogicHandler.execute() |
| | | ├─ 参数解析 |
| | | ├─ 逻辑参数提取 (extraParams.deviceLogic) |
| | | └─ 子类实现 doExecute() |
| | | ``` |
| | | |
| | | **适用场景**: |
| | | - 简单设备,逻辑单一 |
| | | - 不需要复杂协调的设备 |
| | | |
| | | ## 🏗️ 分层架构 |
| | | |
| | | ### 第一层:Controller 层(API入口) |
| | | |
| | | **位置**:`com.mes.task.controller.*` |
| | | |
| | | **职责**: |
| | | - 接收HTTP请求 |
| | | - 参数验证 |
| | | - 调用Service层 |
| | | |
| | | **主要类**: |
| | | - `MultiDeviceTaskController` - 任务管理API |
| | | - `TaskStatusNotificationController` - SSE实时通知API |
| | | |
| | | ### 第二层:Service 层(业务逻辑) |
| | | |
| | | **位置**:`com.mes.task.service.*` |
| | | |
| | | **职责**: |
| | | - 业务逻辑处理 |
| | | - 事务管理 |
| | | - 调用执行引擎 |
| | | |
| | | **主要类**: |
| | | - `MultiDeviceTaskServiceImpl` - 任务服务实现 |
| | | - 创建任务记录 |
| | | - 异步执行任务 |
| | | - 任务状态管理 |
| | | |
| | | - `TaskExecutionEngine` - **核心执行引擎** |
| | | - 设备协调检查 |
| | | - 执行模式判断(串行/并行) |
| | | - 步骤执行调度 |
| | | - 重试机制 |
| | | |
| | | ### 第三层:Interaction 层(设备交互) |
| | | |
| | | **位置**:`com.mes.interaction.*` |
| | | |
| | | **职责**: |
| | | - 设备交互逻辑封装 |
| | | - 多设备协调 |
| | | - 数据传递 |
| | | |
| | | **主要组件**: |
| | | |
| | | #### 3.1 DeviceInteraction(设备交互接口) |
| | | |
| | | **接口**:`com.mes.interaction.DeviceInteraction` |
| | | |
| | | **实现类示例**: |
| | | - `LoadVehicleInteraction` - 大车设备交互 |
| | | - `LargeGlassInteraction` - 大理片笼交互 |
| | | - `GlassStorageInteraction` - 玻璃存储交互 |
| | | |
| | | **注册机制**: |
| | | - `DeviceInteractionRegistry` - 自动注册所有 `@Component` 的 `DeviceInteraction` 实现 |
| | | |
| | | #### 3.2 DeviceLogicHandler(设备逻辑处理器) |
| | | |
| | | **接口**:`com.mes.interaction.DeviceLogicHandler` |
| | | |
| | | **基类**:`BaseDeviceLogicHandler` |
| | | - 提供通用功能: |
| | | - 参数解析 |
| | | - 逻辑参数提取(从 `extraParams.deviceLogic`) |
| | | - 错误处理 |
| | | |
| | | **实现类示例**: |
| | | - `LoadVehicleLogicHandler` - 大车设备逻辑 |
| | | - `HorizontalScannerLogicHandler` - 卧转立扫码逻辑 |
| | | - `HorizontalTransferLogicHandler` - 卧转立逻辑 |
| | | - `LargeGlassLogicHandler` - 大理片笼逻辑 |
| | | |
| | | **注册机制**: |
| | | - `DeviceLogicHandlerFactory` - 自动注册所有 `DeviceLogicHandler` 实现 |
| | | - 通过 `@PostConstruct` 初始化映射表 |
| | | |
| | | ### 第四层:Coordination 层(设备协调) |
| | | |
| | | **位置**:`com.mes.device.service.*` 和 `com.mes.interaction.*.coordination.*` |
| | | |
| | | **职责**: |
| | | - 设备间数据传递 |
| | | - 设备状态同步 |
| | | - 设备依赖管理 |
| | | - 多实例协调 |
| | | |
| | | **主要类**: |
| | | - `DeviceCoordinationService` - 通用设备协调服务 |
| | | - `VehicleCoordinationService` - 大车设备协调服务 |
| | | - `VehicleStatusManager` - 大车状态管理器 |
| | | |
| | | ### 第五层:PLC 操作层(硬件通信) |
| | | |
| | | **位置**:`com.mes.device.service.DevicePlcOperationService` |
| | | |
| | | **职责**: |
| | | - PLC读写操作 |
| | | - 地址映射 |
| | | - 通信管理 |
| | | |
| | | ## 📦 如何添加新设备 |
| | | |
| | | ### 步骤1:定义设备类型常量 |
| | | |
| | | 在 `DeviceConfig.DeviceType` 中添加: |
| | | |
| | | ```java |
| | | public static final class DeviceType { |
| | | public static final String NEW_DEVICE = "新设备类型"; |
| | | } |
| | | ``` |
| | | |
| | | ### 步骤2:创建设备逻辑处理器(必须) |
| | | |
| | | **位置**:`com.mes.interaction.*.handler.NewDeviceLogicHandler` |
| | | |
| | | ```java |
| | | @Component |
| | | public class NewDeviceLogicHandler extends BaseDeviceLogicHandler { |
| | | |
| | | @Override |
| | | public String getDeviceType() { |
| | | return DeviceConfig.DeviceType.NEW_DEVICE; |
| | | } |
| | | |
| | | @Override |
| | | protected DevicePlcVO.OperationResult doExecute( |
| | | DeviceConfig deviceConfig, |
| | | String operation, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | // 1. 从 logicParams 中获取配置参数 |
| | | Integer timeout = getLogicParam(logicParams, "timeout", 5000); |
| | | String mode = getLogicParam(logicParams, "mode", "default"); |
| | | |
| | | // 2. 从 params 中获取运行时参数 |
| | | String glassId = (String) params.get("glassId"); |
| | | |
| | | // 3. 执行设备逻辑 |
| | | // ... 具体实现 |
| | | |
| | | // 4. 调用PLC操作 |
| | | Map<String, Object> plcParams = new HashMap<>(); |
| | | plcParams.put("field1", value1); |
| | | DevicePlcVO.OperationResult result = |
| | | devicePlcOperationService.writePlcData(deviceConfig, plcParams); |
| | | |
| | | return result; |
| | | } |
| | | |
| | | @Override |
| | | protected Map<String, Object> getDefaultLogicParams() { |
| | | Map<String, Object> defaults = new HashMap<>(); |
| | | defaults.put("timeout", 5000); |
| | | defaults.put("mode", "default"); |
| | | return defaults; |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | **自动注册**: |
| | | - 实现 `DeviceLogicHandler` 接口 |
| | | - 添加 `@Component` 注解 |
| | | - `DeviceLogicHandlerFactory` 会自动发现并注册 |
| | | |
| | | ### 步骤3:创建设备交互类(可选,复杂设备推荐) |
| | | |
| | | **位置**:`com.mes.interaction.*.flow.NewDeviceInteraction` |
| | | |
| | | ```java |
| | | @Component |
| | | public class NewDeviceInteraction implements DeviceInteraction { |
| | | |
| | | @Override |
| | | public String getDeviceType() { |
| | | return DeviceConfig.DeviceType.NEW_DEVICE; |
| | | } |
| | | |
| | | @Override |
| | | public InteractionResult execute(InteractionContext context) { |
| | | DeviceConfig device = context.getCurrentDevice(); |
| | | |
| | | // 1. 前置条件检查 |
| | | if (device == null) { |
| | | return InteractionResult.fail("设备配置不存在"); |
| | | } |
| | | |
| | | // 2. 数据准备 |
| | | List<String> glassIds = context.getParameters().getGlassIds(); |
| | | |
| | | // 3. 调用设备逻辑处理器 |
| | | DeviceInteractionService service = ...; |
| | | Map<String, Object> params = new HashMap<>(); |
| | | params.put("glassIds", glassIds); |
| | | |
| | | DevicePlcVO.OperationResult result = |
| | | service.executeDeviceOperation(device, "operationName", params); |
| | | |
| | | if (result.isSuccess()) { |
| | | // 4. 数据传递到下一个设备 |
| | | context.getSharedData().put("processedGlasses", glassIds); |
| | | return InteractionResult.success("执行成功", result.getData()); |
| | | } else { |
| | | return InteractionResult.fail(result.getMessage()); |
| | | } |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | **自动注册**: |
| | | - 实现 `DeviceInteraction` 接口 |
| | | - 添加 `@Component` 注解 |
| | | - `DeviceInteractionRegistry` 会自动发现并注册 |
| | | |
| | | ### 步骤4:配置设备逻辑参数(前端) |
| | | |
| | | 在设备配置的 `extraParams.deviceLogic` 中配置: |
| | | |
| | | ```json |
| | | { |
| | | "deviceLogic": { |
| | | "timeout": 5000, |
| | | "mode": "default", |
| | | "customParam1": "value1" |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ### 步骤5:设备组织方式选择 |
| | | |
| | | #### 简单设备(推荐放在通用包) |
| | | |
| | | ``` |
| | | interaction/ |
| | | └── impl/ |
| | | └── NewDeviceLogicHandler.java |
| | | ``` |
| | | |
| | | #### 复杂设备(需要协调、状态管理等) |
| | | |
| | | ``` |
| | | interaction/ |
| | | └── newdevice/ |
| | | ├── handler/ |
| | | │ └── NewDeviceLogicHandler.java |
| | | ├── flow/ |
| | | │ └── NewDeviceInteraction.java |
| | | ├── coordination/ (可选) |
| | | │ └── NewDeviceCoordinationService.java |
| | | └── model/ (可选) |
| | | └── NewDeviceStatus.java |
| | | ``` |
| | | |
| | | ## 🔍 执行流程详解 |
| | | |
| | | ### 串行执行模式 |
| | | |
| | | ``` |
| | | TaskExecutionEngine.execute() |
| | | ↓ |
| | | for (每个设备) { |
| | | 1. 创建步骤记录 (TaskStepDetail) |
| | | 2. executeStep() |
| | | ↓ |
| | | 3. 检查是否有 DeviceInteraction |
| | | ├─ 有 → DeviceInteraction.execute() |
| | | │ ↓ |
| | | │ 调用 DeviceLogicHandler |
| | | │ |
| | | └─ 无 → 直接调用 DeviceLogicHandler |
| | | ↓ |
| | | BaseDeviceLogicHandler.execute() |
| | | ↓ |
| | | 子类 doExecute() |
| | | 4. 更新步骤状态 |
| | | 5. 传递数据到下一个设备 |
| | | } |
| | | ``` |
| | | |
| | | ### 并行执行模式 |
| | | |
| | | ``` |
| | | TaskExecutionEngine.execute() |
| | | ↓ |
| | | 创建线程池任务列表 |
| | | ↓ |
| | | for (每个设备) { |
| | | 提交到线程池执行 |
| | | ↓ |
| | | executeStep() (同上) |
| | | } |
| | | ↓ |
| | | 等待所有任务完成 |
| | | ↓ |
| | | 汇总结果 |
| | | ``` |
| | | |
| | | ## 📝 关键接口说明 |
| | | |
| | | ### DeviceLogicHandler |
| | | |
| | | **接口方法**: |
| | | ```java |
| | | DevicePlcVO.OperationResult execute( |
| | | DeviceConfig deviceConfig, |
| | | String operation, |
| | | Map<String, Object> params |
| | | ); |
| | | ``` |
| | | |
| | | **调用时机**: |
| | | - 任务执行引擎直接调用 |
| | | - 或通过 DeviceInteraction 间接调用 |
| | | |
| | | ### DeviceInteraction |
| | | |
| | | **接口方法**: |
| | | ```java |
| | | InteractionResult execute(InteractionContext context); |
| | | ``` |
| | | |
| | | **调用时机**: |
| | | - 任务执行引擎优先检查是否有 DeviceInteraction |
| | | - 如果有,优先使用 DeviceInteraction |
| | | - 如果没有,直接调用 DeviceLogicHandler |
| | | |
| | | ### InteractionContext |
| | | |
| | | **包含内容**: |
| | | - `currentDevice` - 当前设备配置 |
| | | - `taskContext` - 任务上下文 |
| | | - `parameters` - 任务参数 |
| | | - `sharedData` - 设备间共享数据 |
| | | |
| | | ## 🎯 最佳实践 |
| | | |
| | | ### 1. 何时使用 DeviceInteraction? |
| | | |
| | | **使用场景**: |
| | | - ✅ 需要多实例协调(如大车设备) |
| | | - ✅ 需要复杂前置检查 |
| | | - ✅ 需要数据转换和准备 |
| | | - ✅ 需要状态管理 |
| | | |
| | | **不使用场景**: |
| | | - ❌ 简单设备,逻辑单一 |
| | | - ❌ 不需要协调的设备 |
| | | |
| | | ### 2. 参数设计 |
| | | |
| | | **logicParams(逻辑参数)**: |
| | | - 从 `extraParams.deviceLogic` 中读取 |
| | | - 设备配置时设置,运行时不变 |
| | | - 如:超时时间、模式、容量等 |
| | | |
| | | **params(运行时参数)**: |
| | | - 任务执行时动态传入 |
| | | - 如:玻璃ID、位置、数量等 |
| | | |
| | | ### 3. 错误处理 |
| | | |
| | | **在 BaseDeviceLogicHandler 中**: |
| | | - 自动捕获异常 |
| | | - 返回统一的错误格式 |
| | | |
| | | **在子类中**: |
| | | - 可以抛出业务异常 |
| | | - 会被基类捕获并转换 |
| | | |
| | | ### 4. 数据传递 |
| | | |
| | | **通过 InteractionContext.sharedData**: |
| | | ```java |
| | | // 在设备A中设置 |
| | | context.getSharedData().put("glassIds", glassIds); |
| | | |
| | | // 在设备B中获取 |
| | | List<String> glassIds = (List<String>) context.getSharedData().get("glassIds"); |
| | | ``` |
| | | |
| | | ## 📚 相关文件位置 |
| | | |
| | | ### 核心文件 |
| | | - `TaskExecutionEngine.java` - 任务执行引擎 |
| | | - `MultiDeviceTaskServiceImpl.java` - 任务服务 |
| | | - `DeviceLogicHandlerFactory.java` - 处理器工厂 |
| | | - `DeviceInteractionRegistry.java` - 交互注册中心 |
| | | |
| | | ### 基类 |
| | | - `BaseDeviceLogicHandler.java` - 逻辑处理器基类 |
| | | - `DeviceInteraction.java` - 交互接口 |
| | | |
| | | ### 示例实现 |
| | | - `LoadVehicleLogicHandler.java` - 大车设备逻辑处理器 |
| | | - `LoadVehicleInteraction.java` - 大车设备交互 |
| | | - `HorizontalScannerLogicHandler.java` - 卧转立扫码处理器 |
| | | |
| | | ## 🔧 调试技巧 |
| | | |
| | | ### 1. 查看注册的设备处理器 |
| | | |
| | | ```java |
| | | @Autowired |
| | | private DeviceLogicHandlerFactory factory; |
| | | |
| | | // 查看所有已注册的设备类型 |
| | | Set<String> types = factory.getSupportedDeviceTypes(); |
| | | ``` |
| | | |
| | | ### 2. 查看注册的设备交互 |
| | | |
| | | ```java |
| | | @Autowired |
| | | private DeviceInteractionRegistry registry; |
| | | |
| | | // 查看所有已注册的交互 |
| | | Map<String, DeviceInteraction> interactions = registry.getInteractions(); |
| | | ``` |
| | | |
| | | ### 3. 日志输出 |
| | | |
| | | - 任务执行:查看 `TaskExecutionEngine` 日志 |
| | | - 设备执行:查看具体 Handler 日志 |
| | | - 协调服务:查看 `DeviceCoordinationService` 日志 |
| | | |
| | | ## ✅ 总结 |
| | | |
| | | 1. **项目主体**:`TaskExecutionEngine` 是核心执行引擎 |
| | | 2. **调用流程**:Controller → Service → Engine → Interaction/Handler → PLC |
| | | 3. **分层架构**:Controller → Service → Interaction → Coordination → PLC |
| | | 4. **添加设备**:实现 `DeviceLogicHandler`(必须)+ `DeviceInteraction`(可选) |
| | | 5. **自动注册**:通过 Spring 的 `@Component` 和工厂类自动发现和注册 |
| | | |
| New file |
| | |
| | | # MES PLC Send 项目文档 |
| | | |
| | | ## 📋 项目概述 |
| | | |
| | | MES PLC Send 是一个基于 Spring Boot 的多设备联合测试系统,支持 PLC 设备管理、设备组配置、多设备任务编排和执行。系统实现了"模板 + 实例"的设计模式,支持一个设备类型模板对应多个设备实例,实现了设备间的协调和数据传递。 |
| | | |
| | | **技术栈**: |
| | | - Spring Boot 2.x |
| | | - MyBatis-Plus |
| | | - S7NetPlus(PLC通信) |
| | | - MySQL |
| | | - Server-Sent Events (SSE) 实时推送 |
| | | |
| | | **服务端口**:10018 |
| | | |
| | | ## 🎯 核心功能 |
| | | |
| | | ### 1. 设备管理 |
| | | - 设备配置管理(PLC IP、设备类型、模块名称等) |
| | | - 设备状态监控(在线/离线/忙碌/错误/维护中) |
| | | - 支持5种设备类型: |
| | | - **大车设备** (`LOAD_VEHICLE`):支持多实例协调,自动状态管理,MES任务处理 |
| | | - **大理片笼** (`LARGE_GLASS`):格子范围配置,逻辑判断 |
| | | - **卧转立扫码** (`WORKSTATION_SCANNER`):定时扫描,MES数据读取 |
| | | - **卧转立** (`WORKSTATION_TRANSFER`):30s缓冲判定,批量处理 |
| | | - **卧式缓存** (`GLASS_STORAGE`):玻璃存储管理(已实现,但当前不使用) |
| | | |
| | | ### 2. 设备组管理 |
| | | - 设备组配置(支持最大并发设备数控制) |
| | | - 设备组拓扑可视化 |
| | | - 设备依赖关系管理(优先级、角色、连接顺序) |
| | | - 设备组任务编排 |
| | | |
| | | ### 3. 多设备任务执行 |
| | | - **串行执行**:按设备连接顺序执行 |
| | | - **并行执行**:支持多设备并行执行,通过 `max_concurrent_devices` 控制并发数 |
| | | - **实时监控**:基于 SSE(Server-Sent Events)的实时状态推送 |
| | | - **错误处理**:自动重试机制,支持指数退避 |
| | | - **数据传递**:设备间数据共享和状态同步 |
| | | |
| | | ### 4. 设备协调服务 |
| | | - 设备间数据传递机制 |
| | | - 设备状态同步 |
| | | - 设备依赖管理 |
| | | - 多实例协调(如多车协调) |
| | | |
| | | ## 🏗️ 架构设计 |
| | | |
| | | ### 目录结构 |
| | | |
| | | ``` |
| | | mes-plcSend/ |
| | | ├── device/ # 设备管理层 |
| | | │ ├── entity/ # 实体类 |
| | | │ │ ├── DeviceConfig.java # 设备配置 |
| | | │ │ ├── DeviceGroupConfig.java # 设备组配置 |
| | | │ │ ├── DeviceGroupRelation.java # 设备组关系 |
| | | │ │ ├── DeviceStatus.java # 设备状态 |
| | | │ │ └── GlassInfo.java # 玻璃信息 |
| | | │ ├── service/ # 服务层 |
| | | │ │ ├── DeviceConfigService.java |
| | | │ │ ├── DeviceGroupConfigService.java |
| | | │ │ ├── DeviceCoordinationService.java # 设备协调服务 |
| | | │ │ └── GlassInfoService.java |
| | | │ └── controller/ # 控制器 |
| | | │ ├── DeviceConfigController.java |
| | | │ ├── DeviceGroupController.java |
| | | │ └── DevicePlcController.java |
| | | │ |
| | | ├── interaction/ # 交互逻辑层 |
| | | │ ├── base/ # 基础接口 |
| | | │ │ ├── BaseDeviceLogicHandler.java |
| | | │ │ ├── DeviceInteraction.java |
| | | │ │ └── InteractionContext.java |
| | | │ │ |
| | | │ ├── vehicle/ # 大车设备专用包 |
| | | │ │ ├── handler/ |
| | | │ │ │ └── LoadVehicleLogicHandler.java # 共享逻辑处理器 |
| | | │ │ ├── flow/ |
| | | │ │ │ └── LoadVehicleInteraction.java |
| | | │ │ ├── coordination/ |
| | | │ │ │ ├── VehicleStatusManager.java # 状态管理器 |
| | | │ │ │ └── VehicleCoordinationService.java # 协调服务 |
| | | │ │ └── model/ |
| | | │ │ ├── VehicleStatus.java |
| | | │ │ ├── VehiclePosition.java |
| | | │ │ ├── VehicleState.java |
| | | │ │ ├── VehiclePath.java |
| | | │ │ └── VehicleTask.java |
| | | │ │ |
| | | │ ├── workstation/ # 卧转立设备包 |
| | | │ │ ├── base/ |
| | | │ │ │ └── WorkstationBaseHandler.java |
| | | │ │ ├── config/ |
| | | │ │ │ └── WorkstationLogicConfig.java |
| | | │ │ ├── scanner/ |
| | | │ │ │ └── handler/ |
| | | │ │ │ └── HorizontalScannerLogicHandler.java |
| | | │ │ └── transfer/ |
| | | │ │ └── handler/ |
| | | │ │ └── HorizontalTransferLogicHandler.java |
| | | │ │ |
| | | │ ├── largeglass/ # 大理片笼设备包 |
| | | │ │ ├── handler/ |
| | | │ │ │ └── LargeGlassLogicHandler.java |
| | | │ │ ├── config/ |
| | | │ │ │ └── LargeGlassConfig.java |
| | | │ │ └── model/ |
| | | │ │ └── GridRange.java |
| | | │ │ |
| | | │ ├── flow/ # 交互流程(通用) |
| | | │ │ ├── GlassStorageInteraction.java |
| | | │ │ └── LargeGlassInteraction.java |
| | | │ │ |
| | | │ └── impl/ # 简单设备实现 |
| | | │ └── GlassStorageLogicHandler.java |
| | | │ |
| | | ├── task/ # 任务管理层 |
| | | │ ├── entity/ |
| | | │ │ ├── MultiDeviceTask.java # 多设备任务 |
| | | │ │ └── TaskStepDetail.java # 任务步骤详情 |
| | | │ ├── service/ |
| | | │ │ ├── MultiDeviceTaskService.java |
| | | │ │ ├── TaskExecutionEngine.java # 任务执行引擎 |
| | | │ │ └── TaskStatusNotificationService.java # SSE推送服务 |
| | | │ ├── controller/ |
| | | │ │ ├── MultiDeviceTaskController.java |
| | | │ │ └── TaskStatusNotificationController.java |
| | | │ └── model/ |
| | | │ ├── RetryPolicy.java # 重试策略 |
| | | │ ├── TaskExecutionContext.java |
| | | │ └── TaskExecutionResult.java |
| | | │ |
| | | ├── service/ # PLC服务层 |
| | | │ ├── PlcDynamicDataService.java # PLC动态数据服务 |
| | | │ └── PlcTestWriteService.java |
| | | │ |
| | | └── s7/ # S7通信 |
| | | └── provider/ |
| | | └── S7SerializerProvider.java |
| | | ``` |
| | | |
| | | ### 核心设计模式 |
| | | |
| | | #### 1. 模板 + 实例模式 |
| | | |
| | | **设计理念**:一个设备类型模板(共享逻辑)+ 多个设备实例(独立状态) |
| | | |
| | | ``` |
| | | LoadVehicleLogicHandler (共享逻辑处理器) |
| | | ↓ |
| | | ┌─────────┐ ┌─────────┐ ┌─────────┐ |
| | | │ 大车实例1│ │ 大车实例2│ │ 大车实例3│ |
| | | │ 状态独立 │ │ 状态独立 │ │ 状态独立 │ |
| | | └─────────┘ └─────────┘ └─────────┘ |
| | | ``` |
| | | |
| | | **优势**: |
| | | - 共享逻辑:所有实例使用同一个逻辑处理器 |
| | | - 独立状态:每个实例有独立的运行时状态 |
| | | - 灵活配置:设备组可以包含任意数量的实例 |
| | | - 易于扩展:新增实例只需在数据库添加记录 |
| | | |
| | | #### 2. 混合分层架构 |
| | | |
| | | - **复杂设备**(如大车):独立包,包含协调服务、状态管理等 |
| | | - **简单设备**(如玻璃存储):放在通用包中 |
| | | |
| | | ## 🔧 核心组件说明 |
| | | |
| | | ### 1. 大车设备(LoadVehicle) |
| | | |
| | | #### 功能特性 |
| | | - **空闲状态监控**:没有任务时,`plcRequest` 保持为 1 |
| | | - **MES任务读取**:当 `mesSend=1` 时,读取 MES 参数(玻璃ID、起始位置、目标位置等) |
| | | - **位置映射**:将 MES 位置编号(如 900、901)映射为实际网格位置(如 100、500) |
| | | - **时间计算**:根据车辆速度(grids/second)、当前位置、目标位置计算 `gotime` 和 `cartime` |
| | | - **状态管理**:`state1~6` 状态流转(0→1→2),自动触发 MES 汇报 |
| | | - **自动协调**:当 `state=1`(上车完成)时,自动将"卧转立"设备的 `plcRequest` 设置为 0 |
| | | - **出片逻辑**:支持进片和出片任务,根据 `startSlot` 和 `outboundSlotRanges` 自动判断任务类型 |
| | | |
| | | #### 配置参数(extraParams.deviceLogic) |
| | | ```json |
| | | { |
| | | "vehicleCapacity": 6, |
| | | "vehicleSpeed": 1.0, |
| | | "minRange": 1, |
| | | "maxRange": 100, |
| | | "homePosition": 50, |
| | | "idleMonitorIntervalMs": 1000, |
| | | "taskMonitorIntervalMs": 1000, |
| | | "mesConfirmTimeoutMs": 30000, |
| | | "positionMapping": { |
| | | "900": 100, |
| | | "901": 500 |
| | | }, |
| | | "outboundSlotRanges": [ |
| | | {"start": 1000, "end": 2000} |
| | | ], |
| | | "gridPositionMapping": { |
| | | "1000": 80 |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | #### 状态流转 |
| | | ``` |
| | | IDLE (空闲) → EXECUTING (执行中) → IDLE (空闲) |
| | | ``` |
| | | |
| | | ### 2. 卧转立扫码(HorizontalScanner) |
| | | |
| | | #### 功能特性 |
| | | - **定时扫描**:可配置扫描间隔(默认 10s) |
| | | - **MES数据读取**:当 `mesSend=1` 时,读取玻璃信息(`mesGlassId`、`mesWidth`、`mesHeight`、`workLine`) |
| | | - **数据落库**:将玻璃信息保存到 `glass_info` 表 |
| | | - **自动确认**:读取后自动将 `mesSend` 写回 0 |
| | | |
| | | #### 配置参数(extraParams.deviceLogic) |
| | | ```json |
| | | { |
| | | "scanIntervalMs": 10000, |
| | | "workLine": "LINE_001", |
| | | "autoConfirm": true |
| | | } |
| | | ``` |
| | | |
| | | ### 3. 卧转立(HorizontalTransfer) |
| | | |
| | | #### 功能特性 |
| | | - **30s缓冲判定**:等待 30s,如果没有下一片玻璃扫码,则认为是最后一片 |
| | | - **容量判断**:判断能否放下第二片玻璃 |
| | | - **批量处理**:将多片玻璃组装成批次 |
| | | - **PLC写入**:写入 `plcGlassId1~6`、`plcGlassCount`、`inPosition`、`plcRequest` |
| | | |
| | | #### 配置参数(extraParams.deviceLogic) |
| | | ```json |
| | | { |
| | | "scanIntervalMs": 10000, |
| | | "bufferTimeoutMs": 30000, |
| | | "vehicleCapacity": 2, |
| | | "monitorIntervalMs": 1000, |
| | | "workLine": "LINE_001", |
| | | "positionValue": 100 |
| | | } |
| | | ``` |
| | | |
| | | ### 4. 大理片笼(LargeGlass) |
| | | |
| | | #### 功能特性 |
| | | - **格子范围配置**:支持多行格子配置(如第一行 1~52 格,第二行 53~101 格) |
| | | - **格子尺寸配置**:每格的长、宽、厚可配置 |
| | | - **逻辑判断**:用于位置验证和格子管理,不涉及 PLC 写入 |
| | | |
| | | #### 配置参数(extraParams.deviceLogic) |
| | | ```json |
| | | { |
| | | "gridRanges": [ |
| | | {"row": 1, "start": 1, "end": 52}, |
| | | {"row": 2, "start": 53, "end": 101} |
| | | ], |
| | | "gridLength": 2000, |
| | | "gridWidth": 1500, |
| | | "gridThickness": 5 |
| | | } |
| | | ``` |
| | | |
| | | ## 🚀 使用指南 |
| | | |
| | | ### 1. 设备配置 |
| | | |
| | | #### 创建设备 |
| | | ```java |
| | | DeviceConfig device = new DeviceConfig(); |
| | | device.setDeviceId("DEVICE_001"); |
| | | device.setDeviceCode("DEV_001"); |
| | | device.setDeviceName("大车设备1"); |
| | | device.setDeviceType(DeviceConfig.DeviceType.LOAD_VEHICLE); |
| | | device.setPlcIp("192.168.1.101"); |
| | | device.setPlcPort(102); |
| | | device.setPlcType(DeviceConfig.PlcType.S7_1200); |
| | | device.setModuleName("DB1"); |
| | | device.setProjectId(1L); |
| | | device.setEnabled(true); |
| | | // 设置逻辑参数 |
| | | device.setExtraParams(extraParams); |
| | | deviceConfigService.createDevice(device); |
| | | ``` |
| | | |
| | | #### 配置设备逻辑参数 |
| | | 在 `extraParams.deviceLogic` 中配置设备特定的逻辑参数,如车辆速度、位置映射等。 |
| | | |
| | | ### 2. 设备组配置 |
| | | |
| | | #### 创建设备组 |
| | | ```java |
| | | DeviceGroupConfig group = new DeviceGroupConfig(); |
| | | group.setGroupCode("GROUP_001"); |
| | | group.setGroupName("生产线A"); |
| | | group.setGroupType(1); // 1-生产线,2-测试线,3-辅助设备组 |
| | | group.setProjectId(1L); |
| | | group.setStatus(1); // 0-停用,1-启用,3-维护中 |
| | | group.setMaxConcurrentDevices(3); // 最大并发设备数 |
| | | group.setHeartbeatInterval(30); |
| | | group.setCommunicationTimeout(5000); |
| | | deviceGroupService.createGroup(group); |
| | | ``` |
| | | |
| | | #### 添加设备到设备组 |
| | | ```java |
| | | // 添加设备,设置优先级、角色、连接顺序 |
| | | deviceGroupService.addDevicesToGroup(groupId, deviceIds, priorities, roles, connectionOrders); |
| | | ``` |
| | | |
| | | ### 3. 任务执行 |
| | | |
| | | #### 启动多设备任务 |
| | | ```java |
| | | MultiDeviceTaskRequest request = new MultiDeviceTaskRequest(); |
| | | request.setGroupId(groupId); |
| | | request.setTaskName("测试任务"); |
| | | request.setProjectId("PROJECT_001"); |
| | | request.setParameters(taskParameters); |
| | | MultiDeviceTask task = multiDeviceTaskService.startTask(request); |
| | | ``` |
| | | |
| | | #### API 端点 |
| | | - `POST /device/task/start` - 启动任务 |
| | | - `POST /device/task/list` - 查询任务列表 |
| | | - `GET /device/task/{taskId}` - 查询任务详情 |
| | | - `GET /device/task/{taskId}/steps` - 查询任务步骤详情 |
| | | - `POST /device/task/{taskId}/cancel` - 取消任务 |
| | | |
| | | #### 实时监控(SSE) |
| | | 前端通过 SSE 连接实时接收任务状态更新: |
| | | ```javascript |
| | | // 监听指定任务 |
| | | const eventSource = new EventSource('/task/notification/sse?taskId=xxx'); |
| | | eventSource.addEventListener('taskStatus', (event) => { |
| | | const data = JSON.parse(event.data); |
| | | // 处理任务状态更新 |
| | | }); |
| | | eventSource.addEventListener('stepUpdate', (event) => { |
| | | const data = JSON.parse(event.data); |
| | | // 处理步骤更新 |
| | | }); |
| | | |
| | | // 监听所有任务 |
| | | const eventSourceAll = new EventSource('/task/notification/sse/all'); |
| | | ``` |
| | | |
| | | **SSE 端点**: |
| | | - `GET /task/notification/sse?taskId=xxx` - 监听指定任务 |
| | | - `GET /task/notification/sse/all` - 监听所有任务 |
| | | - `POST /task/notification/close/{taskId}` - 关闭指定任务的SSE连接 |
| | | - `POST /task/notification/close/all` - 关闭所有SSE连接 |
| | | |
| | | ## 📊 数据库设计 |
| | | |
| | | ### 核心表结构 |
| | | |
| | | #### device_config(设备配置表) |
| | | - `id`:主键(BIGINT) |
| | | - `device_id`:设备唯一标识(VARCHAR(50),唯一) |
| | | - `device_code`:设备编码(VARCHAR(50),唯一) |
| | | - `device_name`:设备名称(VARCHAR(100)) |
| | | - `device_type`:设备类型(VARCHAR(50)) |
| | | - `project_id`:所属项目ID(BIGINT) |
| | | - `plc_ip`:PLC IP地址(VARCHAR(15)) |
| | | - `plc_port`:PLC端口(INT) |
| | | - `plc_type`:PLC类型(VARCHAR(20)) |
| | | - `module_name`:模块名称(VARCHAR(50)) |
| | | - `status`:设备状态(VARCHAR(20)) |
| | | - `is_primary`:是否主控设备(BOOLEAN) |
| | | - `enabled`:是否启用(BOOLEAN) |
| | | - `config_json`:设备特定配置(TEXT,JSON格式) |
| | | - `extra_params`:扩展参数(JSON) |
| | | - `description`:设备描述(VARCHAR(200)) |
| | | - `is_deleted`:是否删除(INT,0-否,1-是) |
| | | - `created_time`、`updated_time`:创建/更新时间 |
| | | - `created_by`、`updated_by`:创建/更新人 |
| | | |
| | | #### device_group_config(设备组配置表) |
| | | - `id`:主键(BIGINT) |
| | | - `group_code`:设备组编码(VARCHAR(50),唯一) |
| | | - `group_name`:设备组名称(VARCHAR(100)) |
| | | - `group_type`:设备组类型(INT,1-生产线,2-测试线,3-辅助设备组) |
| | | - `project_id`:所属项目ID(BIGINT) |
| | | - `status`:设备组状态(INT,0-停用,1-启用,3-维护中) |
| | | - `max_concurrent_devices`:最大并发设备数(INT) |
| | | - `heartbeat_interval`:心跳检测间隔(INT,秒) |
| | | - `communication_timeout`:通信超时时间(INT,毫秒) |
| | | - `description`:设备组描述(VARCHAR(200)) |
| | | - `extra_config`:扩展配置(JSON) |
| | | - `is_deleted`:是否删除(INT) |
| | | - `created_time`、`updated_time`:创建/更新时间 |
| | | - `created_by`、`updated_by`:创建/更新人 |
| | | |
| | | #### device_group_relation(设备组关系表) |
| | | - `id`:主键(BIGINT) |
| | | - `group_id`:设备组ID(BIGINT) |
| | | - `device_id`:设备ID(BIGINT) |
| | | - `priority`:设备在组内的优先级(INT,1-最高,10-最低) |
| | | - `role`:设备在组内的角色(INT,1-主控,2-协作,3-监控) |
| | | - `status`:设备在该组中的状态(INT,0-未配置,1-正常,2-故障,3-维护) |
| | | - `connection_order`:连接顺序(INT,数值越小越先连接) |
| | | - `relation_desc`:关联描述(VARCHAR(200)) |
| | | - `extra_params`:扩展参数(JSON) |
| | | - `is_deleted`:是否删除(INT) |
| | | - `created_time`、`updated_time`:创建/更新时间 |
| | | - `created_by`、`updated_by`:创建/更新人 |
| | | |
| | | #### multi_device_task(多设备任务表) |
| | | - `id`:主键(BIGINT) |
| | | - `task_id`:任务唯一标识(VARCHAR(50),唯一) |
| | | - `group_id`:设备组ID(VARCHAR(50)) |
| | | - `project_id`:项目ID(VARCHAR(50)) |
| | | - `status`:任务状态(ENUM:PENDING, RUNNING, COMPLETED, FAILED, CANCELLED) |
| | | - `current_step`:当前执行步骤(INT) |
| | | - `total_steps`:总步骤数(INT) |
| | | - `start_time`:开始时间(DATETIME) |
| | | - `end_time`:结束时间(DATETIME) |
| | | - `error_message`:错误信息(TEXT) |
| | | - `result_data`:结果数据(JSON) |
| | | - `created_time`、`updated_time`:创建/更新时间 |
| | | |
| | | #### task_step_detail(任务步骤详情表) |
| | | - `id`:主键(BIGINT) |
| | | - `task_id`:任务ID(VARCHAR(50)) |
| | | - `step_order`:步骤顺序(INT) |
| | | - `device_id`:设备ID(VARCHAR(50)) |
| | | - `step_name`:步骤名称(VARCHAR(100)) |
| | | - `status`:步骤状态(ENUM:PENDING, RUNNING, COMPLETED, FAILED, SKIPPED) |
| | | - `start_time`:步骤开始时间(DATETIME) |
| | | - `end_time`:步骤结束时间(DATETIME) |
| | | - `duration_ms`:执行耗时(BIGINT,毫秒) |
| | | - `input_data`:输入数据(JSON) |
| | | - `output_data`:输出数据(JSON) |
| | | - `error_message`:错误信息(TEXT) |
| | | - `retry_count`:重试次数(INT) |
| | | - `created_time`:创建时间(DATETIME) |
| | | |
| | | #### glass_info(玻璃信息表) |
| | | - `id`:主键(BIGINT) |
| | | - `glass_id`:玻璃ID(VARCHAR(50)) |
| | | - `width`:宽度(DECIMAL) |
| | | - `height`:高度(DECIMAL) |
| | | - `work_line`:产线编号(VARCHAR(50)) |
| | | - `scan_time`:扫码时间(DATETIME) |
| | | - `status`:状态(VARCHAR(20)) |
| | | - `created_time`、`updated_time`:创建/更新时间 |
| | | |
| | | #### device_status(设备状态监控表) |
| | | - `id`:主键(BIGINT) |
| | | - `device_id`:设备ID(VARCHAR(50)) |
| | | - `task_id`:关联任务ID(VARCHAR(50),可选) |
| | | - `status`:设备状态(ENUM:ONLINE, OFFLINE, BUSY, ERROR, MAINTENANCE) |
| | | - `last_heartbeat`:最后心跳时间(DATETIME) |
| | | - `cpu_usage`:CPU使用率(DECIMAL(5,2)) |
| | | - `memory_usage`:内存使用率(DECIMAL(5,2)) |
| | | - `plc_connection_status`:PLC连接状态(ENUM:CONNECTED, DISCONNECTED, ERROR) |
| | | - `current_operation`:当前操作(VARCHAR(100)) |
| | | - `operation_progress`:操作进度(DECIMAL(5,2),0-100) |
| | | - `alert_message`:告警信息(TEXT) |
| | | - `created_time`:记录时间(DATETIME) |
| | | |
| | | ## 🔄 任务执行流程 |
| | | |
| | | ### 串行执行流程 |
| | | ``` |
| | | 1. 创建任务记录(status = PENDING) |
| | | 2. 获取设备组中的设备列表(按 connection_order 排序) |
| | | 3. 依次执行每个设备: |
| | | a. 检查前置条件 |
| | | b. 更新步骤状态为 RUNNING |
| | | c. 执行设备交互逻辑 |
| | | d. 传递数据到下一个设备 |
| | | e. 更新步骤状态为 COMPLETED |
| | | 4. 所有设备执行完成后,更新任务状态为 COMPLETED |
| | | ``` |
| | | |
| | | ### 并行执行流程 |
| | | ``` |
| | | 1. 创建任务记录(status = PENDING) |
| | | 2. 获取设备组中的设备列表 |
| | | 3. 使用线程池并行执行设备: |
| | | a. 使用 max_concurrent_devices 控制并发数 |
| | | b. 每个设备独立执行 |
| | | c. 等待所有设备完成 |
| | | 4. 所有设备执行完成后,更新任务状态为 COMPLETED |
| | | ``` |
| | | |
| | | ## ⚙️ 配置说明 |
| | | |
| | | ### 应用配置(application.yml) |
| | | |
| | | ```yaml |
| | | server: |
| | | port: 10018 |
| | | |
| | | spring: |
| | | profiles: |
| | | active: dev |
| | | application: |
| | | name: plcSend |
| | | liquibase: |
| | | enabled: true |
| | | change-log: classpath:changelog/changelogBase.xml |
| | | |
| | | # PLC配置 |
| | | s7: |
| | | load: |
| | | dbArea: DB1 |
| | | beginIndex: 0 |
| | | raw: |
| | | dbArea: DB2 |
| | | beginIndex: 0 |
| | | |
| | | # PLC模拟配置 |
| | | plc: |
| | | simulate: |
| | | enabled: false |
| | | interval: 5000 |
| | | failure-rate: 0 |
| | | task-count: 10 |
| | | task-type: normal |
| | | |
| | | # MES配置 |
| | | mes: |
| | | width: 2800 |
| | | height: 5000 |
| | | ``` |
| | | |
| | | ### 设备逻辑参数配置 |
| | | |
| | | 每个设备类型的逻辑参数在 `extraParams.deviceLogic` 中配置,具体参数见各设备类型的说明。 |
| | | |
| | | ## 🛠️ 扩展开发 |
| | | |
| | | ### 添加新设备类型 |
| | | |
| | | 1. **在 DeviceConfig.DeviceType 中添加常量** |
| | | ```java |
| | | public static final class DeviceType { |
| | | public static final String NEW_DEVICE = "新设备类型"; |
| | | } |
| | | ``` |
| | | |
| | | 2. **创建设备处理器** |
| | | ```java |
| | | @Component |
| | | public class NewDeviceLogicHandler extends BaseDeviceLogicHandler { |
| | | @Override |
| | | public String getDeviceType() { |
| | | return DeviceConfig.DeviceType.NEW_DEVICE; |
| | | } |
| | | |
| | | @Override |
| | | protected DevicePlcVO.OperationResult doExecute( |
| | | DeviceConfig deviceConfig, |
| | | String operation, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | // 实现设备逻辑 |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 3. **创建交互流程**(可选) |
| | | ```java |
| | | @Component |
| | | public class NewDeviceInteraction implements DeviceInteraction { |
| | | @Override |
| | | public InteractionResult execute(InteractionContext context) { |
| | | // 实现交互流程 |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ### 添加设备协调逻辑 |
| | | |
| | | 对于需要多实例协调的复杂设备,可以创建专用包: |
| | | ``` |
| | | interaction/ |
| | | └── newdevice/ |
| | | ├── handler/ |
| | | ├── coordination/ |
| | | └── model/ |
| | | ``` |
| | | |
| | | ## 📝 注意事项 |
| | | |
| | | ### 1. 状态管理 |
| | | - 大车设备的状态存储在内存中(`VehicleStatusManager`),服务重启后会丢失 |
| | | - 如需持久化,可以扩展支持数据库存储 |
| | | |
| | | ### 2. 并发安全 |
| | | - 状态管理器使用 `ConcurrentHashMap`,支持并发访问 |
| | | - 任务执行使用线程池和 `max_concurrent_devices` 控制并发 |
| | | |
| | | ### 3. 错误处理 |
| | | - 支持自动重试机制(默认最多3次) |
| | | - 支持指数退避策略 |
| | | - 错误分类:网络错误、超时错误可重试,业务错误不可重试 |
| | | |
| | | ### 4. 实时监控 |
| | | - SSE 连接超时时间:30分钟 |
| | | - 支持监听指定任务或所有任务 |
| | | - 连接断开后需要重新连接 |
| | | |
| | | ### 5. 设备类型说明 |
| | | - **卧式缓存**(`GLASS_STORAGE`):代码中已实现,但当前业务场景不使用,保留用于未来扩展 |
| | | |
| | | ## 🎯 已完成功能 |
| | | |
| | | ### ✅ 核心功能 |
| | | - [x] 设备管理(配置、状态监控) |
| | | - [x] 设备组管理(并发控制、优先级、角色) |
| | | - [x] 多设备任务执行引擎(串行/并行) |
| | | - [x] 设备协调服务(数据传递、状态同步) |
| | | - [x] 实时监控推送(SSE) |
| | | - [x] 错误处理和重试机制 |
| | | |
| | | ### ✅ 设备类型支持 |
| | | - [x] 大车设备(多实例协调、状态管理、MES任务处理、进片/出片) |
| | | - [x] 卧转立扫码(定时扫描、数据落库) |
| | | - [x] 卧转立(30s缓冲、批量处理) |
| | | - [x] 大理片笼(格子配置、逻辑判断) |
| | | - [x] 卧式缓存(已实现,当前不使用) |
| | | |
| | | ### ✅ API 端点 |
| | | - [x] 设备管理 API(`/device/*`) |
| | | - [x] 设备组管理 API(`/device/group/*`) |
| | | - [x] 多设备任务 API(`/device/task/*`) |
| | | - [x] SSE 实时通知 API(`/task/notification/*`) |
| | | |
| | | ## 📚 相关文档 |
| | | |
| | | - 数据库迁移脚本:`src/main/resources/db/migration/` |
| | | - API 文档:通过 Swagger 访问 `/swagger-ui.html` |
| | | - 前端文档:见 `mes-web` 项目 |
| | | |
| | | ## 📞 联系方式 |
| | | |
| | | 如有问题或建议,请联系开发团队。 |
| New file |
| | |
| | | package com.mes.config; |
| | | |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.context.annotation.Bean; |
| | | import org.springframework.context.annotation.Configuration; |
| | | import org.springframework.scheduling.annotation.EnableAsync; |
| | | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; |
| | | |
| | | import java.util.concurrent.Executor; |
| | | import java.util.concurrent.ThreadPoolExecutor; |
| | | |
| | | /** |
| | | * 任务执行器配置 |
| | | * 用于异步执行多设备组任务 |
| | | * |
| | | * @author mes |
| | | * @since 2025-01-XX |
| | | */ |
| | | @Slf4j |
| | | @Configuration |
| | | @EnableAsync |
| | | public class TaskExecutorConfig { |
| | | |
| | | /** |
| | | * 设备组任务执行线程池 |
| | | * 每个设备组作为一个独立线程执行 |
| | | */ |
| | | @Bean(name = "deviceGroupTaskExecutor") |
| | | public Executor deviceGroupTaskExecutor() { |
| | | ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); |
| | | |
| | | // 核心线程数:支持同时执行的核心设备组数量 |
| | | executor.setCorePoolSize(5); |
| | | |
| | | // 最大线程数:最多同时执行的设备组数量 |
| | | executor.setMaxPoolSize(20); |
| | | |
| | | // 队列容量:等待执行的设备组任务数量 |
| | | executor.setQueueCapacity(100); |
| | | |
| | | // 线程名前缀 |
| | | executor.setThreadNamePrefix("DeviceGroupTask-"); |
| | | |
| | | // 线程空闲时间(秒) |
| | | executor.setKeepAliveSeconds(60); |
| | | |
| | | // 拒绝策略:当线程池和队列都满时,由调用线程执行 |
| | | executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); |
| | | |
| | | // 等待所有任务结束后再关闭线程池 |
| | | executor.setWaitForTasksToCompleteOnShutdown(true); |
| | | |
| | | // 等待时间(秒) |
| | | executor.setAwaitTerminationSeconds(60); |
| | | |
| | | executor.initialize(); |
| | | |
| | | log.info("设备组任务线程池初始化完成: corePoolSize=5, maxPoolSize=20, queueCapacity=100"); |
| | | |
| | | return executor; |
| | | } |
| | | } |
| | | |
| | |
| | | @TableField("device_id") |
| | | private String deviceId; |
| | | |
| | | @ApiModelProperty(value = "设备名称", example = "上大车设备1") |
| | | @ApiModelProperty(value = "设备名称", example = "大车设备1") |
| | | @TableField("device_name") |
| | | private String deviceName; |
| | | |
| | |
| | | @TableField("device_code") |
| | | private String deviceCode; |
| | | |
| | | @ApiModelProperty(value = "设备类型", example = "上大车/大理片/玻璃存储") |
| | | @ApiModelProperty(value = "设备类型", example = "大车设备/大理片笼/卧式缓存") |
| | | @TableField("device_type") |
| | | private String deviceType; |
| | | |
| | |
| | | @TableField("plc_type") |
| | | private String plcType; |
| | | |
| | | @ApiModelProperty(value = "模块名称", example = "上大车模块") |
| | | @ApiModelProperty(value = "模块名称", example = "大车设备模块") |
| | | @TableField("module_name") |
| | | private String moduleName; |
| | | |
| | |
| | | @TableField("config_json") |
| | | private String configJson; |
| | | |
| | | @ApiModelProperty(value = "设备描述", example = "上大车设备1") |
| | | @ApiModelProperty(value = "设备描述", example = "大车设备1") |
| | | @TableField("description") |
| | | private String description; |
| | | |
| | |
| | | |
| | | // 设备类型常量 |
| | | public static final class DeviceType { |
| | | public static final String LOAD_VEHICLE = "上大车"; // 上大车 |
| | | public static final String LARGE_GLASS = "大理片"; // 大理片 |
| | | public static final String GLASS_STORAGE = "玻璃存储"; // 玻璃存储 |
| | | public static final String LOAD_VEHICLE = "大车设备"; // 大车设备 |
| | | public static final String LARGE_GLASS = "大理片笼"; // 大理片笼 |
| | | public static final String GLASS_STORAGE = "卧式缓存"; // 卧式缓存 |
| | | public static final String WORKSTATION_SCANNER = "卧转立扫码"; // 卧转立扫码设备 |
| | | public static final String WORKSTATION_TRANSFER = "卧转立"; // 卧转立设备 |
| | | } |
| | | |
| | | // PLC类型常量 |
| | |
| | | package com.mes.device.service; |
| | | |
| | | import com.mes.device.vo.DevicePlcVO; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.util.List; |
| | | import java.util.Map; |
| | |
| | | * @author mes |
| | | * @since 2025-11-17 |
| | | */ |
| | | @Service |
| | | public interface DevicePlcOperationService { |
| | | |
| | | DevicePlcVO.OperationResult triggerRequest(Long deviceId); |
| | |
| | | |
| | | // 设备类型过滤 |
| | | if (deviceType != null && !deviceType.trim().isEmpty()) { |
| | | String convertedDeviceType = convertDeviceTypeFromString(deviceType); |
| | | if (convertedDeviceType != null) { |
| | | wrapper.eq(DeviceConfig::getDeviceType, convertedDeviceType); |
| | | List<String> convertedDeviceTypes = convertDeviceTypeFromString(deviceType); |
| | | if (convertedDeviceTypes != null && !convertedDeviceTypes.isEmpty()) { |
| | | wrapper.in(DeviceConfig::getDeviceType, convertedDeviceTypes); |
| | | } |
| | | } |
| | | |
| | |
| | | /** |
| | | * 字符串转换为设备类型 |
| | | */ |
| | | private String convertDeviceTypeFromString(String deviceType) { |
| | | if (deviceType == null) return null; |
| | | private List<String> convertDeviceTypeFromString(String deviceType) { |
| | | if (deviceType == null) { |
| | | return Collections.emptyList(); |
| | | } |
| | | |
| | | switch (deviceType.trim().toLowerCase()) { |
| | | String normalized = deviceType.trim().toLowerCase(); |
| | | switch (normalized) { |
| | | case "load_vehicle": |
| | | case "上大车": |
| | | case "上大车设备": |
| | | case "大车设备": |
| | | case "1": |
| | | return DeviceConfig.DeviceType.LOAD_VEHICLE; |
| | | return Arrays.asList( |
| | | DeviceConfig.DeviceType.LOAD_VEHICLE, |
| | | "大车设备" |
| | | ); |
| | | case "large_glass": |
| | | case "大理片": |
| | | case "大理片笼": |
| | | case "2": |
| | | return DeviceConfig.DeviceType.LARGE_GLASS; |
| | | return Arrays.asList( |
| | | DeviceConfig.DeviceType.LARGE_GLASS, |
| | | "大理片笼" |
| | | ); |
| | | case "glass_storage": |
| | | case "玻璃存储": |
| | | case "卧式缓存": |
| | | case "玻璃存储设备": |
| | | case "3": |
| | | return DeviceConfig.DeviceType.GLASS_STORAGE; |
| | | return Arrays.asList( |
| | | DeviceConfig.DeviceType.GLASS_STORAGE, |
| | | "卧式缓存", |
| | | "玻璃存储设备" |
| | | ); |
| | | default: |
| | | return null; |
| | | return Collections.emptyList(); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | // 设备类型过滤 |
| | | if (deviceType != null && !deviceType.trim().isEmpty()) { |
| | | String convertedDeviceType = convertDeviceTypeFromString(deviceType); |
| | | if (convertedDeviceType != null) { |
| | | wrapper.eq(DeviceConfig::getDeviceType, convertedDeviceType); |
| | | } |
| | | if (deviceType != null && !deviceType.trim().isEmpty()) { |
| | | List<String> convertedDeviceTypes = convertDeviceTypeFromString(deviceType); |
| | | if (convertedDeviceTypes != null && !convertedDeviceTypes.isEmpty()) { |
| | | wrapper.in(DeviceConfig::getDeviceType, convertedDeviceTypes); |
| | | } |
| | | } |
| | | |
| | | // 设备状态过滤 |
| | | if (deviceStatus != null && !deviceStatus.trim().isEmpty()) { |
| | |
| | | |
| | | // 根据设备类型,提取关键数据并更新上下文 |
| | | if (DeviceConfig.DeviceType.LOAD_VEHICLE.equals(fromDevice.getDeviceType())) { |
| | | // 上大车设备完成,传递玻璃ID列表 |
| | | // 大车设备完成,传递玻璃ID列表 |
| | | Object glassIds = data.get("glassIds"); |
| | | if (glassIds instanceof List) { |
| | | @SuppressWarnings("unchecked") |
| | | List<String> ids = (List<String>) glassIds; |
| | | context.setLoadedGlassIds(new ArrayList<>(ids)); |
| | | log.info("上大车设备数据传递: fromDevice={}, toDevice={}, glassIds={}", |
| | | log.info("大车设备数据传递: fromDevice={}, toDevice={}, glassIds={}", |
| | | fromDevice.getDeviceCode(), toDevice.getDeviceCode(), ids); |
| | | } |
| | | } else if (DeviceConfig.DeviceType.LARGE_GLASS.equals(fromDevice.getDeviceType())) { |
| | |
| | | // 检查设备类型特定的依赖 |
| | | String deviceType = device.getDeviceType(); |
| | | if (DeviceConfig.DeviceType.LARGE_GLASS.equals(deviceType)) { |
| | | // 大理片设备需要上大车设备先完成 |
| | | // 大理片设备需要大车设备先完成 |
| | | List<String> loadedGlassIds = context.getSafeLoadedGlassIds(); |
| | | if (CollectionUtils.isEmpty(loadedGlassIds)) { |
| | | missingDependencies.add("上大车设备未完成,缺少玻璃ID列表"); |
| | | missingDependencies.add("大车设备未完成,缺少玻璃ID列表"); |
| | | } |
| | | } else if (DeviceConfig.DeviceType.GLASS_STORAGE.equals(deviceType)) { |
| | | // 玻璃存储设备需要大理片设备先完成(优先),或上大车设备完成 |
| | | // 玻璃存储设备需要大理片设备先完成(优先),或大车设备完成 |
| | | List<String> processedGlassIds = context.getSafeProcessedGlassIds(); |
| | | List<String> loadedGlassIds = context.getSafeLoadedGlassIds(); |
| | | if (CollectionUtils.isEmpty(processedGlassIds) && CollectionUtils.isEmpty(loadedGlassIds)) { |
| | |
| | | private final PlcTestWriteService plcTestWriteService; |
| | | private final ObjectMapper objectMapper; |
| | | |
| | | public enum PlcOperationType { |
| | | public static enum PlcOperationType { |
| | | /** PLC请求操作 */ |
| | | REQUEST("PLC请求", "PLC 请求发送成功", "PLC 请求发送失败"), |
| | | /** PLC汇报操作 */ |
| | | REPORT("PLC汇报", "PLC 汇报模拟成功", "PLC 汇报模拟失败"), |
| | | /** PLC重置操作 */ |
| | | RESET("PLC重置", "PLC 状态已重置", "PLC 状态重置失败"); |
| | | |
| | | /** 操作显示名称 */ |
| | | private final String display; |
| | | /** 操作成功提示信息 */ |
| | | private final String successMsg; |
| | | /** 操作失败提示信息 */ |
| | | private final String failedMsg; |
| | | |
| | | /** |
| | | * 构造方法 |
| | | * @param display 操作显示名称 |
| | | * @param successMsg 成功提示信息 |
| | | * @param failedMsg 失败提示信息 |
| | | */ |
| | | PlcOperationType(String display, String successMsg, String failedMsg) { |
| | | this.display = display; |
| | | this.successMsg = successMsg; |
| | |
| | | return DevicePlcVO.StatusInfo.builder() |
| | | .deviceId(deviceId) |
| | | .deviceName("未知设备") |
| | | .data(Collections.emptyMap()) |
| | | .fieldValues(Collections.emptyMap()) |
| | | .timestamp(LocalDateTime.now()) |
| | | .build(); |
| | | } |
| | |
| | | .deviceName(device.getDeviceName()) |
| | | .deviceCode(device.getDeviceCode()) |
| | | .projectId(String.valueOf(device.getProjectId())) |
| | | .data(data) |
| | | .fieldValues(data) |
| | | .timestamp(LocalDateTime.now()) |
| | | .build(); |
| | | } catch (Exception e) { |
| | |
| | | .deviceName(device.getDeviceName()) |
| | | .deviceCode(device.getDeviceCode()) |
| | | .projectId(null) |
| | | .data(Collections.emptyMap()) |
| | | .fieldValues(Collections.emptyMap()) |
| | | .timestamp(LocalDateTime.now()) |
| | | .build(); |
| | | } |
| | |
| | | private Boolean success; |
| | | private String message; |
| | | private LocalDateTime timestamp; |
| | | private Map<String, Object> data; |
| | | } |
| | | |
| | | /** |
| | |
| | | private String deviceName; |
| | | private String deviceCode; |
| | | private String projectId; |
| | | private Map<String, Object> data; |
| | | private Map<String, Object> fieldValues; |
| | | private LocalDateTime timestamp; |
| | | } |
| | | } |
| | |
| | | |
| | | /** |
| | | * 交互注册中心 |
| | | * @author huang |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | |
| | | /** |
| | | * 获取设备类型(用于匹配处理器) |
| | | * |
| | | * @return 设备类型,如:"上大车"、"大理片"、"玻璃存储" |
| | | * @return 设备类型,如:"大车设备"、"大理片笼"、"卧式缓存" |
| | | */ |
| | | String getDeviceType(); |
| | | |
| | |
| | | return InteractionResult.fail("设备配置不存在"); |
| | | } |
| | | |
| | | // 优先使用处理后的玻璃ID,如果没有则使用上大车的玻璃ID |
| | | // 优先使用处理后的玻璃ID,如果没有则使用大车设备的玻璃ID |
| | | List<String> processed = context.getProcessedGlassIds(); |
| | | if (CollectionUtils.isEmpty(processed)) { |
| | | processed = context.getLoadedGlassIds(); |
| | |
| | | return InteractionResult.fail("设备配置不存在"); |
| | | } |
| | | |
| | | // 检查上大车是否完成 |
| | | // 检查大车设备是否完成 |
| | | Object source = context.getSharedData().get("glassesFromVehicle"); |
| | | List<String> glassQueue = castList(source); |
| | | if (CollectionUtils.isEmpty(glassQueue)) { |
| | | // 也尝试从上下文获取 |
| | | glassQueue = context.getLoadedGlassIds(); |
| | | if (CollectionUtils.isEmpty(glassQueue)) { |
| | | return InteractionResult.waitResult("等待上大车输出", null); |
| | | return InteractionResult.waitResult("等待大车设备输出", null); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.interaction.largeglass.config; |
| | | |
| | | import com.mes.interaction.largeglass.model.GridRange; |
| | | import lombok.Data; |
| | | |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | | |
| | | /** |
| | | * 大理片笼配置 |
| | | * 对应 extraParams.deviceLogic 中的字段 |
| | | */ |
| | | @Data |
| | | public class LargeGlassConfig { |
| | | |
| | | /** |
| | | * 格子范围列表 |
| | | * 例如:第一行1~52格,第二行53~101格 |
| | | */ |
| | | private List<GridRange> gridRanges = new ArrayList<>(); |
| | | |
| | | /** |
| | | * 每格长度(mm) |
| | | */ |
| | | private Integer gridLength = 2000; |
| | | |
| | | /** |
| | | * 每格宽度(mm) |
| | | */ |
| | | private Integer gridWidth = 1500; |
| | | |
| | | /** |
| | | * 每格厚度(mm) |
| | | */ |
| | | private Integer gridThickness = 5; |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.interaction.largeglass.handler; |
| | | |
| | | import com.mes.device.entity.DeviceConfig; |
| | | import com.mes.device.vo.DevicePlcVO; |
| | | import com.mes.interaction.BaseDeviceLogicHandler; |
| | | import com.mes.device.service.DevicePlcOperationService; |
| | | import com.mes.interaction.largeglass.config.LargeGlassConfig; |
| | | import com.mes.interaction.largeglass.model.GridRange; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import java.time.LocalDateTime; |
| | | import java.util.*; |
| | | |
| | | /** |
| | | * 大理片笼设备逻辑处理器 |
| | | * 负责格子范围配置、每格尺寸配置、逻辑判断等 |
| | | * 不涉及PLC写入操作,只用于逻辑判断和配置管理 |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | | public class LargeGlassLogicHandler extends BaseDeviceLogicHandler { |
| | | |
| | | public LargeGlassLogicHandler(DevicePlcOperationService devicePlcOperationService) { |
| | | super(devicePlcOperationService); |
| | | } |
| | | |
| | | @Override |
| | | public String getDeviceType() { |
| | | return DeviceConfig.DeviceType.LARGE_GLASS; |
| | | } |
| | | |
| | | @Override |
| | | protected DevicePlcVO.OperationResult doExecute(DeviceConfig deviceConfig, |
| | | String operation, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | try { |
| | | LargeGlassConfig config = parseLargeGlassConfig(logicParams); |
| | | |
| | | switch (operation) { |
| | | case "checkGrid": |
| | | case "validateGrid": |
| | | return handleCheckGrid(deviceConfig, params, config); |
| | | case "getGridInfo": |
| | | return handleGetGridInfo(deviceConfig, params, config); |
| | | case "findAvailableGrid": |
| | | return handleFindAvailableGrid(deviceConfig, params, config); |
| | | case "getGridPosition": |
| | | return handleGetGridPosition(deviceConfig, params, config); |
| | | default: |
| | | return buildResult(deviceConfig, operation, false, |
| | | "不支持的操作: " + operation); |
| | | } |
| | | } catch (Exception e) { |
| | | log.error("大理片笼处理异常: deviceId={}, operation={}", |
| | | deviceConfig.getId(), operation, e); |
| | | return buildResult(deviceConfig, operation, false, |
| | | "处理异常: " + e.getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 检查格子是否有效 |
| | | */ |
| | | private DevicePlcVO.OperationResult handleCheckGrid( |
| | | DeviceConfig deviceConfig, |
| | | Map<String, Object> params, |
| | | LargeGlassConfig config) { |
| | | |
| | | Integer gridNumber = getIntegerParam(params, "gridNumber"); |
| | | if (gridNumber == null) { |
| | | return buildResult(deviceConfig, "checkGrid", false, |
| | | "未提供格子编号"); |
| | | } |
| | | |
| | | GridRange gridRange = findGridRange(gridNumber, config); |
| | | if (gridRange == null) { |
| | | return buildResult(deviceConfig, "checkGrid", false, |
| | | String.format("格子编号 %d 不在配置范围内", gridNumber)); |
| | | } |
| | | |
| | | return buildResult(deviceConfig, "checkGrid", true, |
| | | String.format("格子编号 %d 有效,位于第 %d 行,范围 %d~%d", |
| | | gridNumber, gridRange.getRow(), gridRange.getStart(), gridRange.getEnd())); |
| | | } |
| | | |
| | | /** |
| | | * 获取格子信息 |
| | | */ |
| | | private DevicePlcVO.OperationResult handleGetGridInfo( |
| | | DeviceConfig deviceConfig, |
| | | Map<String, Object> params, |
| | | LargeGlassConfig config) { |
| | | |
| | | Integer gridNumber = getIntegerParam(params, "gridNumber"); |
| | | if (gridNumber == null) { |
| | | return buildResult(deviceConfig, "getGridInfo", false, |
| | | "未提供格子编号"); |
| | | } |
| | | |
| | | GridRange gridRange = findGridRange(gridNumber, config); |
| | | if (gridRange == null) { |
| | | return buildResult(deviceConfig, "getGridInfo", false, |
| | | String.format("格子编号 %d 不在配置范围内", gridNumber)); |
| | | } |
| | | |
| | | Map<String, Object> gridInfo = new HashMap<>(); |
| | | gridInfo.put("gridNumber", gridNumber); |
| | | gridInfo.put("row", gridRange.getRow()); |
| | | gridInfo.put("start", gridRange.getStart()); |
| | | gridInfo.put("end", gridRange.getEnd()); |
| | | gridInfo.put("length", config.getGridLength()); |
| | | gridInfo.put("width", config.getGridWidth()); |
| | | gridInfo.put("thickness", config.getGridThickness()); |
| | | |
| | | return buildResult(deviceConfig, "getGridInfo", true, |
| | | "格子信息获取成功", gridInfo); |
| | | } |
| | | |
| | | /** |
| | | * 查找可用格子 |
| | | */ |
| | | private DevicePlcVO.OperationResult handleFindAvailableGrid( |
| | | DeviceConfig deviceConfig, |
| | | Map<String, Object> params, |
| | | LargeGlassConfig config) { |
| | | |
| | | // 这里可以根据实际需求实现查找逻辑 |
| | | // 例如:查找第一个可用格子、查找指定行可用格子等 |
| | | |
| | | List<GridRange> gridRanges = config.getGridRanges(); |
| | | if (gridRanges == null || gridRanges.isEmpty()) { |
| | | return buildResult(deviceConfig, "findAvailableGrid", false, |
| | | "未配置格子范围"); |
| | | } |
| | | |
| | | // 简单实现:返回第一个格子的起始位置 |
| | | GridRange firstRange = gridRanges.get(0); |
| | | Integer availableGrid = firstRange.getStart(); |
| | | |
| | | Map<String, Object> result = new HashMap<>(); |
| | | result.put("availableGrid", availableGrid); |
| | | result.put("row", firstRange.getRow()); |
| | | |
| | | return buildResult(deviceConfig, "findAvailableGrid", true, |
| | | String.format("找到可用格子: %d", availableGrid), result); |
| | | } |
| | | |
| | | /** |
| | | * 根据目标位置获取格子编号 |
| | | */ |
| | | private DevicePlcVO.OperationResult handleGetGridPosition( |
| | | DeviceConfig deviceConfig, |
| | | Map<String, Object> params, |
| | | LargeGlassConfig config) { |
| | | |
| | | Integer targetPosition = getIntegerParam(params, "targetPosition"); |
| | | if (targetPosition == null) { |
| | | return buildResult(deviceConfig, "getGridPosition", false, |
| | | "未提供目标位置"); |
| | | } |
| | | |
| | | // 查找目标位置在哪个格子范围内 |
| | | GridRange gridRange = findGridRangeByPosition(targetPosition, config); |
| | | if (gridRange == null) { |
| | | return buildResult(deviceConfig, "getGridPosition", false, |
| | | String.format("目标位置 %d 不在任何格子范围内", targetPosition)); |
| | | } |
| | | |
| | | // 计算格子编号(假设位置就是格子编号,或根据实际规则计算) |
| | | Integer gridNumber = targetPosition; |
| | | |
| | | Map<String, Object> result = new HashMap<>(); |
| | | result.put("gridNumber", gridNumber); |
| | | result.put("row", gridRange.getRow()); |
| | | result.put("position", targetPosition); |
| | | |
| | | return buildResult(deviceConfig, "getGridPosition", true, |
| | | String.format("目标位置 %d 对应格子编号 %d,第 %d 行", |
| | | targetPosition, gridNumber, gridRange.getRow()), result); |
| | | } |
| | | |
| | | /** |
| | | * 解析大理片笼配置 |
| | | */ |
| | | private LargeGlassConfig parseLargeGlassConfig(Map<String, Object> logicParams) { |
| | | LargeGlassConfig config = new LargeGlassConfig(); |
| | | |
| | | if (logicParams == null) { |
| | | return config; |
| | | } |
| | | |
| | | // 解析格子范围配置 |
| | | @SuppressWarnings("unchecked") |
| | | List<Map<String, Object>> gridRangesConfig = |
| | | (List<Map<String, Object>>) logicParams.get("gridRanges"); |
| | | |
| | | if (gridRangesConfig != null) { |
| | | List<GridRange> gridRanges = new ArrayList<>(); |
| | | for (Map<String, Object> rangeConfig : gridRangesConfig) { |
| | | Integer row = getIntegerValue(rangeConfig.get("row")); |
| | | Integer start = getIntegerValue(rangeConfig.get("start")); |
| | | Integer end = getIntegerValue(rangeConfig.get("end")); |
| | | |
| | | if (row != null && start != null && end != null) { |
| | | gridRanges.add(new GridRange(row, start, end)); |
| | | } |
| | | } |
| | | config.setGridRanges(gridRanges); |
| | | } |
| | | |
| | | // 解析每格尺寸 |
| | | config.setGridLength(getLogicParam(logicParams, "gridLength", 2000)); // 默认2000mm |
| | | config.setGridWidth(getLogicParam(logicParams, "gridWidth", 1500)); // 默认1500mm |
| | | config.setGridThickness(getLogicParam(logicParams, "gridThickness", 5)); // 默认5mm |
| | | |
| | | return config; |
| | | } |
| | | |
| | | /** |
| | | * 查找格子所在的格子范围 |
| | | */ |
| | | private GridRange findGridRange(Integer gridNumber, LargeGlassConfig config) { |
| | | List<GridRange> gridRanges = config.getGridRanges(); |
| | | if (gridRanges == null) { |
| | | return null; |
| | | } |
| | | |
| | | for (GridRange range : gridRanges) { |
| | | if (gridNumber >= range.getStart() && gridNumber <= range.getEnd()) { |
| | | return range; |
| | | } |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 根据位置查找格子范围 |
| | | */ |
| | | private GridRange findGridRangeByPosition(Integer position, LargeGlassConfig config) { |
| | | List<GridRange> gridRanges = config.getGridRanges(); |
| | | if (gridRanges == null) { |
| | | return null; |
| | | } |
| | | |
| | | for (GridRange range : gridRanges) { |
| | | if (position >= range.getStart() && position <= range.getEnd()) { |
| | | return range; |
| | | } |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 获取整数参数 |
| | | */ |
| | | private Integer getIntegerParam(Map<String, Object> params, String key) { |
| | | if (params == null) { |
| | | return null; |
| | | } |
| | | return getIntegerValue(params.get(key)); |
| | | } |
| | | |
| | | /** |
| | | * 获取整数值 |
| | | */ |
| | | private Integer getIntegerValue(Object value) { |
| | | if (value instanceof Number) { |
| | | return ((Number) value).intValue(); |
| | | } |
| | | if (value == null) { |
| | | return null; |
| | | } |
| | | try { |
| | | return Integer.parseInt(String.valueOf(value)); |
| | | } catch (NumberFormatException e) { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 构建操作结果 |
| | | */ |
| | | private DevicePlcVO.OperationResult buildResult(DeviceConfig deviceConfig, |
| | | String operation, |
| | | boolean success, |
| | | String message) { |
| | | return buildResult(deviceConfig, operation, success, message, null); |
| | | } |
| | | |
| | | /** |
| | | * 构建操作结果(带数据) |
| | | */ |
| | | private DevicePlcVO.OperationResult buildResult(DeviceConfig deviceConfig, |
| | | String operation, |
| | | boolean success, |
| | | String message, |
| | | Map<String, Object> data) { |
| | | DevicePlcVO.OperationResult.OperationResultBuilder builder = DevicePlcVO.OperationResult.builder() |
| | | .deviceId(deviceConfig.getId()) |
| | | .deviceName(deviceConfig.getDeviceName()) |
| | | .deviceCode(deviceConfig.getDeviceCode()) |
| | | .projectId(deviceConfig.getProjectId() != null ? |
| | | String.valueOf(deviceConfig.getProjectId()) : null) |
| | | .operation(operation) |
| | | .success(success) |
| | | .message(message) |
| | | .timestamp(LocalDateTime.now()); |
| | | |
| | | if (data != null) { |
| | | builder.data(data); |
| | | } |
| | | |
| | | return builder.build(); |
| | | } |
| | | |
| | | @Override |
| | | public String validateLogicParams(DeviceConfig deviceConfig) { |
| | | Map<String, Object> logicParams = parseLogicParams(deviceConfig); |
| | | LargeGlassConfig config = parseLargeGlassConfig(logicParams); |
| | | |
| | | // 验证格子范围配置 |
| | | List<GridRange> gridRanges = config.getGridRanges(); |
| | | if (gridRanges == null || gridRanges.isEmpty()) { |
| | | return "必须配置至少一个格子范围(gridRanges)"; |
| | | } |
| | | |
| | | // 验证每格尺寸 |
| | | if (config.getGridLength() == null || config.getGridLength() <= 0) { |
| | | return "格子长度(gridLength)必须大于0"; |
| | | } |
| | | if (config.getGridWidth() == null || config.getGridWidth() <= 0) { |
| | | return "格子宽度(gridWidth)必须大于0"; |
| | | } |
| | | if (config.getGridThickness() == null || config.getGridThickness() <= 0) { |
| | | return "格子厚度(gridThickness)必须大于0"; |
| | | } |
| | | |
| | | return null; // 验证通过 |
| | | } |
| | | |
| | | @Override |
| | | public String getDefaultLogicParams() { |
| | | Map<String, Object> defaultParams = new HashMap<>(); |
| | | |
| | | // 默认格子范围配置:第一行1~52格,第二行53~101格 |
| | | List<Map<String, Object>> gridRanges = new ArrayList<>(); |
| | | |
| | | Map<String, Object> row1 = new HashMap<>(); |
| | | row1.put("row", 1); |
| | | row1.put("start", 1); |
| | | row1.put("end", 52); |
| | | gridRanges.add(row1); |
| | | |
| | | Map<String, Object> row2 = new HashMap<>(); |
| | | row2.put("row", 2); |
| | | row2.put("start", 53); |
| | | row2.put("end", 101); |
| | | gridRanges.add(row2); |
| | | |
| | | defaultParams.put("gridRanges", gridRanges); |
| | | |
| | | // 默认每格尺寸(mm) |
| | | defaultParams.put("gridLength", 2000); // 长度2000mm |
| | | defaultParams.put("gridWidth", 1500); // 宽度1500mm |
| | | defaultParams.put("gridThickness", 5); // 厚度5mm |
| | | |
| | | try { |
| | | return objectMapper.writeValueAsString(defaultParams); |
| | | } catch (Exception e) { |
| | | log.error("生成默认逻辑参数失败", e); |
| | | return "{}"; |
| | | } |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.interaction.largeglass.model; |
| | | |
| | | import lombok.AllArgsConstructor; |
| | | import lombok.Data; |
| | | import lombok.NoArgsConstructor; |
| | | |
| | | /** |
| | | * 格子范围 |
| | | */ |
| | | @Data |
| | | @NoArgsConstructor |
| | | @AllArgsConstructor |
| | | public class GridRange { |
| | | |
| | | /** |
| | | * 行号(第几行) |
| | | */ |
| | | private Integer row; |
| | | |
| | | /** |
| | | * 起始格子编号 |
| | | */ |
| | | private Integer start; |
| | | |
| | | /** |
| | | * 结束格子编号 |
| | | */ |
| | | private Integer end; |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.interaction.vehicle.coordination; |
| | | |
| | | import com.mes.device.entity.DeviceConfig; |
| | | import com.mes.device.service.DeviceConfigService; |
| | | import com.mes.device.service.DeviceGroupRelationService; |
| | | import com.mes.device.vo.DeviceGroupVO; |
| | | import com.mes.interaction.vehicle.model.VehiclePath; |
| | | import com.mes.interaction.vehicle.model.VehicleStatus; |
| | | import com.mes.interaction.vehicle.model.VehicleState; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.util.Collections; |
| | | import java.util.List; |
| | | import java.util.stream.Collectors; |
| | | |
| | | /** |
| | | * 车辆协调服务 |
| | | * 负责在多个大车实例中选择和分配任务 |
| | | * |
| | | * @author mes |
| | | * @since 2025-01-XX |
| | | */ |
| | | @Slf4j |
| | | @Service |
| | | public class VehicleCoordinationService { |
| | | |
| | | @Autowired |
| | | private VehicleStatusManager statusManager; |
| | | |
| | | @Autowired |
| | | private DeviceConfigService deviceConfigService; |
| | | |
| | | @Autowired |
| | | private DeviceGroupRelationService deviceGroupRelationService; |
| | | |
| | | /** |
| | | * 从设备组中选择一个可用的大车实例 |
| | | * |
| | | * @param groupId 设备组ID |
| | | * @return 可用的设备配置,如果没有可用车辆则返回null |
| | | */ |
| | | public DeviceConfig selectAvailableVehicle(Long groupId) { |
| | | if (groupId == null) { |
| | | log.warn("设备组ID为空,无法选择车辆"); |
| | | return null; |
| | | } |
| | | |
| | | // 1. 获取设备组中的所有设备 |
| | | List<DeviceGroupVO.DeviceInfo> groupDevices = deviceGroupRelationService.getGroupDevices(groupId); |
| | | if (groupDevices == null || groupDevices.isEmpty()) { |
| | | log.warn("设备组 {} 中没有设备", groupId); |
| | | return null; |
| | | } |
| | | |
| | | // 2. 过滤出大车类型的设备 |
| | | List<DeviceGroupVO.DeviceInfo> vehicles = groupDevices.stream() |
| | | .filter(d -> DeviceConfig.DeviceType.LOAD_VEHICLE.equals(d.getDeviceType())) |
| | | .collect(Collectors.toList()); |
| | | |
| | | if (vehicles.isEmpty()) { |
| | | log.warn("设备组 {} 中没有大车设备", groupId); |
| | | return null; |
| | | } |
| | | |
| | | // 3. 过滤出可用的车辆 |
| | | List<DeviceGroupVO.DeviceInfo> availableVehicles = vehicles.stream() |
| | | .filter(v -> { |
| | | String deviceId = v.getId() != null ? v.getId().toString() : null; |
| | | if (deviceId == null) { |
| | | deviceId = v.getDeviceCode(); |
| | | } |
| | | return statusManager.isVehicleAvailable(deviceId); |
| | | }) |
| | | .collect(Collectors.toList()); |
| | | |
| | | if (availableVehicles.isEmpty()) { |
| | | log.warn("设备组 {} 中没有可用的大车设备", groupId); |
| | | return null; |
| | | } |
| | | |
| | | // 4. 选择策略(简单策略:选择第一个可用的,可以扩展为按优先级、负载等选择) |
| | | DeviceGroupVO.DeviceInfo selected = availableVehicles.get(0); |
| | | |
| | | // 5. 获取完整的设备配置 |
| | | DeviceConfig deviceConfig = null; |
| | | if (selected.getId() != null) { |
| | | deviceConfig = deviceConfigService.getDeviceById(selected.getId()); |
| | | } else if (selected.getDeviceCode() != null) { |
| | | deviceConfig = deviceConfigService.getDeviceByCode(selected.getDeviceCode()); |
| | | } |
| | | |
| | | if (deviceConfig != null) { |
| | | log.info("选择可用车辆: deviceId={}, deviceName={}", |
| | | deviceConfig.getDeviceId(), deviceConfig.getDeviceName()); |
| | | } |
| | | |
| | | return deviceConfig; |
| | | } |
| | | |
| | | /** |
| | | * 检查路径冲突 |
| | | * |
| | | * @param deviceId 设备ID |
| | | * @param plannedPath 计划路径 |
| | | * @return true表示有冲突,false表示无冲突 |
| | | */ |
| | | public boolean hasPathConflict(String deviceId, VehiclePath plannedPath) { |
| | | if (deviceId == null || plannedPath == null) { |
| | | return false; |
| | | } |
| | | |
| | | // 获取所有执行中的车辆 |
| | | List<VehicleStatus> executingVehicles = statusManager.getExecutingVehicles(); |
| | | |
| | | // 排除自己 |
| | | executingVehicles = executingVehicles.stream() |
| | | .filter(v -> !v.getDeviceId().equals(deviceId)) |
| | | .collect(Collectors.toList()); |
| | | |
| | | // 检查路径是否冲突 |
| | | for (VehicleStatus vehicle : executingVehicles) { |
| | | if (vehicle.getCurrentTask() != null) { |
| | | VehiclePath existingPath = vehicle.getCurrentTask().getPlannedPath(); |
| | | if (existingPath != null && plannedPath.conflictsWith(existingPath)) { |
| | | log.warn("检测到路径冲突: deviceId={}, 与 deviceId={} 的路径冲突", |
| | | deviceId, vehicle.getDeviceId()); |
| | | return true; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * 获取设备组中所有大车设备 |
| | | * |
| | | * @param groupId 设备组ID |
| | | * @return 大车设备列表 |
| | | */ |
| | | public List<DeviceConfig> getVehiclesInGroup(Long groupId) { |
| | | if (groupId == null) { |
| | | return Collections.emptyList(); |
| | | } |
| | | |
| | | List<DeviceGroupVO.DeviceInfo> groupDevices = deviceGroupRelationService.getGroupDevices(groupId); |
| | | if (groupDevices == null || groupDevices.isEmpty()) { |
| | | return Collections.emptyList(); |
| | | } |
| | | |
| | | return groupDevices.stream() |
| | | .filter(d -> DeviceConfig.DeviceType.LOAD_VEHICLE.equals(d.getDeviceType())) |
| | | .map(d -> { |
| | | if (d.getId() != null) { |
| | | return deviceConfigService.getDeviceById(d.getId()); |
| | | } else if (d.getDeviceCode() != null) { |
| | | return deviceConfigService.getDeviceByCode(d.getDeviceCode()); |
| | | } |
| | | return null; |
| | | }) |
| | | .filter(d -> d != null) |
| | | .collect(Collectors.toList()); |
| | | } |
| | | |
| | | /** |
| | | * 获取设备组中所有可用的大车设备 |
| | | * |
| | | * @param groupId 设备组ID |
| | | * @return 可用的大车设备列表 |
| | | */ |
| | | public List<DeviceConfig> getAvailableVehiclesInGroup(Long groupId) { |
| | | return getVehiclesInGroup(groupId).stream() |
| | | .filter(v -> statusManager.isVehicleAvailable(v.getDeviceId())) |
| | | .collect(Collectors.toList()); |
| | | } |
| | | |
| | | /** |
| | | * 检查设备组中是否有可用的大车 |
| | | * |
| | | * @param groupId 设备组ID |
| | | * @return true表示有可用车辆,false表示没有 |
| | | */ |
| | | public boolean hasAvailableVehicle(Long groupId) { |
| | | return selectAvailableVehicle(groupId) != null; |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.interaction.vehicle.coordination; |
| | | |
| | | import com.mes.interaction.vehicle.model.VehicleState; |
| | | import com.mes.interaction.vehicle.model.VehicleStatus; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.concurrent.ConcurrentHashMap; |
| | | import java.util.stream.Collectors; |
| | | |
| | | /** |
| | | * 车辆状态管理器 |
| | | * 管理所有大车设备实例的运行时状态 |
| | | * |
| | | * @author huang |
| | | * @since 2025-11-21 |
| | | */ |
| | | @Slf4j |
| | | @Service |
| | | public class VehicleStatusManager { |
| | | |
| | | /** |
| | | * 存储所有车辆实例的状态:deviceId -> VehicleStatus |
| | | */ |
| | | private final Map<String, VehicleStatus> vehicleStatusMap = new ConcurrentHashMap<>(); |
| | | |
| | | /** |
| | | * 获取车辆状态 |
| | | * 如果不存在则创建并返回空闲状态 |
| | | * |
| | | * @param deviceId 设备ID |
| | | * @return 车辆状态 |
| | | */ |
| | | public VehicleStatus getVehicleStatus(String deviceId) { |
| | | if (deviceId == null || deviceId.isEmpty()) { |
| | | return null; |
| | | } |
| | | return vehicleStatusMap.get(deviceId); |
| | | } |
| | | |
| | | /** |
| | | * 获取或创建车辆状态 |
| | | * |
| | | * @param deviceId 设备ID |
| | | * @param deviceName 设备名称 |
| | | * @return 车辆状态 |
| | | */ |
| | | public VehicleStatus getOrCreateVehicleStatus(String deviceId, String deviceName) { |
| | | return vehicleStatusMap.computeIfAbsent(deviceId, |
| | | k -> new VehicleStatus(deviceId, deviceName)); |
| | | } |
| | | |
| | | /** |
| | | * 更新车辆状态 |
| | | * |
| | | * @param deviceId 设备ID |
| | | * @param state 新状态 |
| | | */ |
| | | public void updateVehicleStatus(String deviceId, VehicleState state) { |
| | | if (deviceId == null || deviceId.isEmpty()) { |
| | | log.warn("设备ID为空,无法更新状态"); |
| | | return; |
| | | } |
| | | |
| | | VehicleStatus status = vehicleStatusMap.computeIfAbsent( |
| | | deviceId, |
| | | k -> new VehicleStatus(deviceId) |
| | | ); |
| | | status.setState(state); |
| | | |
| | | log.debug("更新车辆状态: deviceId={}, state={}", deviceId, state); |
| | | } |
| | | |
| | | /** |
| | | * 更新车辆状态(带设备名称) |
| | | * |
| | | * @param deviceId 设备ID |
| | | * @param deviceName 设备名称 |
| | | * @param state 新状态 |
| | | */ |
| | | public void updateVehicleStatus(String deviceId, String deviceName, VehicleState state) { |
| | | VehicleStatus status = getOrCreateVehicleStatus(deviceId, deviceName); |
| | | status.setState(state); |
| | | log.debug("更新车辆状态: deviceId={}, deviceName={}, state={}", deviceId, deviceName, state); |
| | | } |
| | | |
| | | /** |
| | | * 获取所有空闲的车辆 |
| | | * |
| | | * @return 空闲车辆状态列表 |
| | | */ |
| | | public List<VehicleStatus> getIdleVehicles() { |
| | | return vehicleStatusMap.values().stream() |
| | | .filter(v -> v.getState() == VehicleState.IDLE) |
| | | .collect(Collectors.toList()); |
| | | } |
| | | |
| | | /** |
| | | * 获取所有执行中的车辆 |
| | | * |
| | | * @return 执行中车辆状态列表 |
| | | */ |
| | | public List<VehicleStatus> getExecutingVehicles() { |
| | | return vehicleStatusMap.values().stream() |
| | | .filter(v -> v.getState() == VehicleState.EXECUTING) |
| | | .collect(Collectors.toList()); |
| | | } |
| | | |
| | | /** |
| | | * 获取所有等待中的车辆 |
| | | * |
| | | * @return 等待中车辆状态列表 |
| | | */ |
| | | public List<VehicleStatus> getWaitingVehicles() { |
| | | return vehicleStatusMap.values().stream() |
| | | .filter(v -> v.getState() == VehicleState.WAITING) |
| | | .collect(Collectors.toList()); |
| | | } |
| | | |
| | | /** |
| | | * 检查车辆是否可用 |
| | | * |
| | | * @param deviceId 设备ID |
| | | * @return true表示可用,false表示不可用 |
| | | */ |
| | | public boolean isVehicleAvailable(String deviceId) { |
| | | if (deviceId == null || deviceId.isEmpty()) { |
| | | return false; |
| | | } |
| | | |
| | | VehicleStatus status = vehicleStatusMap.get(deviceId); |
| | | return status == null || status.isAvailable(); |
| | | } |
| | | |
| | | /** |
| | | * 设置车辆任务 |
| | | * |
| | | * @param deviceId 设备ID |
| | | * @param task 任务 |
| | | */ |
| | | public void setVehicleTask(String deviceId, com.mes.interaction.vehicle.model.VehicleTask task) { |
| | | VehicleStatus status = vehicleStatusMap.get(deviceId); |
| | | if (status != null) { |
| | | status.setCurrentTask(task); |
| | | if (task != null && task.getPlannedPath() != null) { |
| | | status.setTargetPosition(task.getPlannedPath().getEndPosition()); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 清除车辆任务 |
| | | * |
| | | * @param deviceId 设备ID |
| | | */ |
| | | public void clearVehicleTask(String deviceId) { |
| | | VehicleStatus status = vehicleStatusMap.get(deviceId); |
| | | if (status != null) { |
| | | status.setCurrentTask(null); |
| | | status.setTargetPosition(null); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 移除车辆状态(当设备被删除时) |
| | | * |
| | | * @param deviceId 设备ID |
| | | */ |
| | | public void removeVehicleStatus(String deviceId) { |
| | | vehicleStatusMap.remove(deviceId); |
| | | log.info("移除车辆状态: deviceId={}", deviceId); |
| | | } |
| | | |
| | | /** |
| | | * 获取所有车辆状态 |
| | | * |
| | | * @return 所有车辆状态列表 |
| | | */ |
| | | public List<VehicleStatus> getAllVehicleStatuses() { |
| | | return vehicleStatusMap.values().stream() |
| | | .collect(Collectors.toList()); |
| | | } |
| | | |
| | | /** |
| | | * 获取指定设备组中的车辆状态 |
| | | * |
| | | * @param deviceIds 设备ID列表 |
| | | * @return 车辆状态列表 |
| | | */ |
| | | public List<VehicleStatus> getVehicleStatuses(List<String> deviceIds) { |
| | | return deviceIds.stream() |
| | | .map(this::getVehicleStatus) |
| | | .filter(status -> status != null) |
| | | .collect(Collectors.toList()); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.interaction.vehicle.flow; |
| | | |
| | | import com.mes.device.entity.DeviceConfig; |
| | | import com.mes.device.service.DeviceInteractionService; |
| | | import com.mes.device.vo.DevicePlcVO; |
| | | import com.mes.interaction.DeviceInteraction; |
| | | import com.mes.interaction.base.InteractionContext; |
| | | import com.mes.interaction.base.InteractionResult; |
| | | import com.mes.interaction.vehicle.coordination.VehicleCoordinationService; |
| | | import com.mes.interaction.vehicle.coordination.VehicleStatusManager; |
| | | import com.mes.interaction.vehicle.model.VehicleState; |
| | | import com.mes.interaction.vehicle.model.VehiclePosition; |
| | | import lombok.RequiredArgsConstructor; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.stereotype.Component; |
| | | import org.springframework.util.CollectionUtils; |
| | | |
| | | import java.util.ArrayList; |
| | | import java.util.HashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * 大车设备交互实现(增强版) |
| | | * 集成多车协调和状态管理功能 |
| | | * |
| | | * @author mes |
| | | * @since 2025-01-XX |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | | @RequiredArgsConstructor |
| | | public class LoadVehicleInteraction implements DeviceInteraction { |
| | | |
| | | private final DeviceInteractionService deviceInteractionService; |
| | | |
| | | @Autowired |
| | | private VehicleCoordinationService coordinationService; |
| | | |
| | | @Autowired |
| | | private VehicleStatusManager statusManager; |
| | | |
| | | @Override |
| | | public String getDeviceType() { |
| | | return DeviceConfig.DeviceType.LOAD_VEHICLE; |
| | | } |
| | | |
| | | @Override |
| | | public InteractionResult execute(InteractionContext context) { |
| | | try { |
| | | // 前置条件验证 |
| | | if (context.getCurrentDevice() == null) { |
| | | return InteractionResult.fail("设备配置不存在"); |
| | | } |
| | | |
| | | DeviceConfig currentDevice = context.getCurrentDevice(); |
| | | String deviceId = currentDevice.getDeviceId(); |
| | | |
| | | // 1. 检查车辆状态(如果设备已指定) |
| | | if (deviceId != null) { |
| | | if (!statusManager.isVehicleAvailable(deviceId)) { |
| | | com.mes.interaction.vehicle.model.VehicleStatus status = |
| | | statusManager.getVehicleStatus(deviceId); |
| | | String stateMsg = status != null ? status.getState().name() : "未知"; |
| | | return InteractionResult.fail( |
| | | String.format("车辆 %s (%s) 当前状态为 %s,无法执行操作", |
| | | currentDevice.getDeviceName(), deviceId, stateMsg)); |
| | | } |
| | | } |
| | | |
| | | // 2. 如果没有指定设备,尝试从设备组中选择可用车辆 |
| | | DeviceConfig selectedDevice = currentDevice; |
| | | Long groupId = extractGroupId(context); |
| | | if (groupId != null && (deviceId == null || !statusManager.isVehicleAvailable(deviceId))) { |
| | | DeviceConfig availableVehicle = coordinationService.selectAvailableVehicle(groupId); |
| | | if (availableVehicle != null) { |
| | | selectedDevice = availableVehicle; |
| | | log.info("从设备组 {} 中选择可用车辆: {}", groupId, availableVehicle.getDeviceName()); |
| | | } else if (deviceId == null) { |
| | | // 没有可用车辆,返回等待结果 |
| | | return InteractionResult.waitResult( |
| | | "设备组中没有可用的大车设备,等待车辆空闲", null); |
| | | } |
| | | } |
| | | |
| | | // 3. 验证玻璃ID |
| | | List<String> glassIds = context.getParameters().getGlassIds(); |
| | | if (CollectionUtils.isEmpty(glassIds)) { |
| | | return InteractionResult.waitResult("未提供玻璃ID,等待输入", null); |
| | | } |
| | | |
| | | // 验证玻璃ID格式 |
| | | for (String glassId : glassIds) { |
| | | if (glassId == null || glassId.trim().isEmpty()) { |
| | | return InteractionResult.fail("玻璃ID不能为空"); |
| | | } |
| | | } |
| | | |
| | | // 4. 标记车辆为执行中 |
| | | String selectedDeviceId = selectedDevice.getDeviceId(); |
| | | statusManager.updateVehicleStatus( |
| | | selectedDeviceId, |
| | | selectedDevice.getDeviceName(), |
| | | VehicleState.EXECUTING); |
| | | |
| | | try { |
| | | // 5. 构建PLC写入参数 |
| | | Map<String, Object> params = new HashMap<>(); |
| | | params.put("glassIds", glassIds); |
| | | params.put("positionCode", context.getParameters().getPositionCode()); |
| | | params.put("positionValue", context.getParameters().getPositionValue()); |
| | | params.put("triggerRequest", true); |
| | | |
| | | // 6. 执行实际的PLC写入操作 |
| | | DevicePlcVO.OperationResult plcResult = deviceInteractionService.executeOperation( |
| | | selectedDevice.getId(), |
| | | "feedGlass", |
| | | params |
| | | ); |
| | | |
| | | // 7. 检查PLC写入结果 |
| | | if (plcResult == null || !Boolean.TRUE.equals(plcResult.getSuccess())) { |
| | | String errorMsg = plcResult != null ? plcResult.getMessage() : "PLC写入操作返回空结果"; |
| | | |
| | | // 执行失败,恢复为空闲状态 |
| | | statusManager.updateVehicleStatus(selectedDeviceId, VehicleState.IDLE); |
| | | |
| | | return InteractionResult.fail("PLC写入失败: " + errorMsg); |
| | | } |
| | | |
| | | // 8. 更新车辆位置信息(如果有) |
| | | if (context.getParameters().getPositionCode() != null || |
| | | context.getParameters().getPositionValue() != null) { |
| | | com.mes.interaction.vehicle.model.VehicleStatus vehicleStatus = |
| | | statusManager.getOrCreateVehicleStatus( |
| | | selectedDeviceId, |
| | | selectedDevice.getDeviceName()); |
| | | VehiclePosition position = new VehiclePosition( |
| | | context.getParameters().getPositionCode(), |
| | | context.getParameters().getPositionValue()); |
| | | vehicleStatus.setCurrentPosition(position); |
| | | } |
| | | |
| | | // 9. 执行大车设备操作(数据流转) |
| | | List<String> copied = new ArrayList<>(glassIds); |
| | | context.setLoadedGlassIds(copied); |
| | | context.getSharedData().put("glassesFromVehicle", copied); |
| | | context.getSharedData().put("loadVehicleTime", System.currentTimeMillis()); |
| | | context.getSharedData().put("selectedVehicleId", selectedDeviceId); |
| | | context.getSharedData().put("selectedVehicleName", selectedDevice.getDeviceName()); |
| | | |
| | | // 10. 后置条件检查 |
| | | if (context.getLoadedGlassIds().isEmpty()) { |
| | | statusManager.updateVehicleStatus(selectedDeviceId, VehicleState.IDLE); |
| | | return InteractionResult.fail("大车设备操作失败:玻璃ID列表为空"); |
| | | } |
| | | |
| | | // 11. 构建返回数据 |
| | | Map<String, Object> data = new HashMap<>(); |
| | | data.put("loaded", copied); |
| | | data.put("glassCount", copied.size()); |
| | | data.put("deviceId", selectedDevice.getId()); |
| | | data.put("deviceCode", selectedDevice.getDeviceCode()); |
| | | data.put("deviceName", selectedDevice.getDeviceName()); |
| | | data.put("deviceIdString", selectedDeviceId); |
| | | data.put("plcResult", plcResult.getMessage()); |
| | | |
| | | // 注意:这里不立即恢复为空闲状态,因为实际执行可能需要时间 |
| | | // 真正的状态恢复应该在任务完成后通过回调或状态查询来更新 |
| | | // 或者可以通过异步任务在后台监控PLC状态,确认完成后再恢复 |
| | | |
| | | log.info("大车设备交互执行成功: deviceId={}, deviceName={}, glassCount={}", |
| | | selectedDeviceId, selectedDevice.getDeviceName(), copied.size()); |
| | | |
| | | return InteractionResult.success(data); |
| | | |
| | | } catch (Exception e) { |
| | | // 发生异常时,恢复为空闲状态 |
| | | statusManager.updateVehicleStatus(selectedDeviceId, VehicleState.ERROR); |
| | | log.error("大车设备交互执行异常: deviceId={}", selectedDeviceId, e); |
| | | throw e; |
| | | } |
| | | |
| | | } catch (Exception e) { |
| | | return InteractionResult.fail("大车设备交互执行异常: " + e.getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 从上下文中提取设备组ID |
| | | */ |
| | | private Long extractGroupId(InteractionContext context) { |
| | | // 尝试从共享数据中获取 |
| | | Map<String, Object> sharedData = context.getSharedData(); |
| | | if (sharedData != null) { |
| | | Object groupIdObj = sharedData.get("groupId"); |
| | | if (groupIdObj instanceof Number) { |
| | | return ((Number) groupIdObj).longValue(); |
| | | } else if (groupIdObj instanceof String) { |
| | | try { |
| | | return Long.parseLong((String) groupIdObj); |
| | | } catch (NumberFormatException ignored) { |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 尝试从任务参数中获取 |
| | | if (context.getParameters() != null && context.getParameters().getExtra() != null) { |
| | | Object groupIdObj = context.getParameters().getExtra().get("groupId"); |
| | | if (groupIdObj instanceof Number) { |
| | | return ((Number) groupIdObj).longValue(); |
| | | } |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | @Override |
| | | public boolean supportsOperation(String operation) { |
| | | // 支持所有操作,但主要关注 feedGlass |
| | | return true; |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.interaction.vehicle.handler; |
| | | |
| | | import com.mes.device.entity.DeviceConfig; |
| | | import com.mes.device.service.DeviceConfigService; |
| | | import com.mes.device.service.DeviceGroupRelationService; |
| | | import com.mes.device.service.DevicePlcOperationService; |
| | | import com.mes.device.service.GlassInfoService; |
| | | import com.mes.device.vo.DeviceGroupVO; |
| | | import com.mes.device.vo.DevicePlcVO; |
| | | import com.mes.interaction.BaseDeviceLogicHandler; |
| | | import com.mes.interaction.vehicle.coordination.VehicleStatusManager; |
| | | import com.mes.interaction.vehicle.model.VehiclePosition; |
| | | import com.mes.interaction.vehicle.model.VehicleState; |
| | | import com.mes.interaction.vehicle.model.VehicleStatus; |
| | | import com.mes.interaction.vehicle.model.VehicleTask; |
| | | import com.mes.s7.enhanced.EnhancedS7Serializer; |
| | | import com.mes.s7.provider.S7SerializerProvider; |
| | | import com.mes.service.PlcDynamicDataService; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.beans.factory.annotation.Qualifier; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import javax.annotation.PreDestroy; |
| | | import java.util.*; |
| | | import java.util.concurrent.*; |
| | | |
| | | /** |
| | | * 大车设备逻辑处理器 |
| | | * 所有大车设备实例共享这个处理器 |
| | | * 集成多实例状态管理和协调功能 |
| | | * |
| | | * @author huang |
| | | * @since 2025-11-21 |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | | public class LoadVehicleLogicHandler extends BaseDeviceLogicHandler { |
| | | |
| | | private final GlassInfoService glassInfoService; |
| | | |
| | | @Autowired |
| | | private VehicleStatusManager statusManager; |
| | | |
| | | @Autowired(required = false) |
| | | private DeviceConfigService deviceConfigService; |
| | | |
| | | @Autowired(required = false) |
| | | private DeviceGroupRelationService deviceGroupRelationService; |
| | | |
| | | @Autowired(required = false) |
| | | private PlcDynamicDataService plcDynamicDataService; |
| | | |
| | | @Autowired(required = false) |
| | | private S7SerializerProvider s7SerializerProvider; |
| | | |
| | | // MES字段列表(进片和出片共用同一套协议) |
| | | private static final List<String> MES_FIELDS = Arrays.asList( |
| | | "mesSend", "mesGlassId", "mesWidth", "mesHeight", |
| | | "startSlot", "targetSlot", "workLine" |
| | | ); |
| | | |
| | | // 监控线程池:用于定期检查大车状态并协调卧转立设备 |
| | | private final ScheduledExecutorService stateMonitorExecutor = Executors.newScheduledThreadPool(5, r -> { |
| | | Thread t = new Thread(r, "VehicleStateMonitor"); |
| | | t.setDaemon(true); |
| | | return t; |
| | | }); |
| | | |
| | | // 空闲监控线程池:用于保持plcRequest=1 |
| | | private final ScheduledExecutorService idleMonitorExecutor = Executors.newScheduledThreadPool(3, r -> { |
| | | Thread t = new Thread(r, "VehicleIdleMonitor"); |
| | | t.setDaemon(true); |
| | | return t; |
| | | }); |
| | | |
| | | // 任务监控线程池:用于监控任务执行和状态切换 |
| | | private final ScheduledExecutorService taskMonitorExecutor = Executors.newScheduledThreadPool(5, r -> { |
| | | Thread t = new Thread(r, "VehicleTaskMonitor"); |
| | | t.setDaemon(true); |
| | | return t; |
| | | }); |
| | | |
| | | // 记录正在监控的设备:deviceId -> 监控任务 |
| | | private final Map<String, ScheduledFuture<?>> monitoringTasks = new ConcurrentHashMap<>(); |
| | | |
| | | // 记录空闲监控任务:deviceId -> 空闲监控任务 |
| | | private final Map<String, ScheduledFuture<?>> idleMonitoringTasks = new ConcurrentHashMap<>(); |
| | | |
| | | // 记录任务监控任务:deviceId -> 任务监控任务 |
| | | private final Map<String, ScheduledFuture<?>> taskMonitoringTasks = new ConcurrentHashMap<>(); |
| | | |
| | | // 记录已协调的设备:deviceId -> 已协调的state字段集合(避免重复协调) |
| | | private final Map<String, List<String>> coordinatedStates = new ConcurrentHashMap<>(); |
| | | |
| | | // 记录当前任务:deviceId -> 任务信息 |
| | | private final Map<String, MesTaskInfo> currentTasks = new ConcurrentHashMap<>(); |
| | | |
| | | @Autowired |
| | | public LoadVehicleLogicHandler( |
| | | DevicePlcOperationService devicePlcOperationService, |
| | | @Qualifier("deviceGlassInfoService") GlassInfoService glassInfoService) { |
| | | super(devicePlcOperationService); |
| | | this.glassInfoService = glassInfoService; |
| | | } |
| | | |
| | | @Override |
| | | public String getDeviceType() { |
| | | return DeviceConfig.DeviceType.LOAD_VEHICLE; |
| | | } |
| | | |
| | | @Override |
| | | protected DevicePlcVO.OperationResult doExecute( |
| | | DeviceConfig deviceConfig, |
| | | String operation, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | String deviceId = deviceConfig.getDeviceId(); |
| | | log.info("执行大车设备操作: deviceId={}, deviceName={}, operation={}", |
| | | deviceId, deviceConfig.getDeviceName(), operation); |
| | | |
| | | // 1. 检查这个设备实例的状态(对于需要状态检查的操作) |
| | | if (needsStateCheck(operation)) { |
| | | VehicleStatus status = statusManager.getVehicleStatus(deviceId); |
| | | if (status != null && !status.isAvailable()) { |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(false) |
| | | .message(String.format("车辆 %s (%s) 当前状态为 %s,无法执行操作 %s", |
| | | deviceConfig.getDeviceName(), |
| | | deviceId, |
| | | status.getState(), |
| | | operation)) |
| | | .build(); |
| | | } |
| | | } |
| | | |
| | | // 2. 标记为执行中(对于需要状态管理的操作) |
| | | if (needsStateManagement(operation)) { |
| | | statusManager.updateVehicleStatus(deviceId, deviceConfig.getDeviceName(), VehicleState.EXECUTING); |
| | | |
| | | // 创建任务信息 |
| | | VehicleTask task = createVehicleTask(deviceConfig, operation, params, logicParams); |
| | | statusManager.setVehicleTask(deviceId, task); |
| | | } |
| | | |
| | | try { |
| | | // 3. 执行原有逻辑 |
| | | DevicePlcVO.OperationResult result; |
| | | switch (operation) { |
| | | case "feedGlass": |
| | | result = handleFeedGlass(deviceConfig, params, logicParams); |
| | | break; |
| | | case "triggerRequest": |
| | | result = handleTriggerRequest(deviceConfig, params, logicParams); |
| | | break; |
| | | case "triggerReport": |
| | | result = handleTriggerReport(deviceConfig, params, logicParams); |
| | | break; |
| | | case "reset": |
| | | result = handleReset(deviceConfig, params, logicParams); |
| | | break; |
| | | case "clearGlass": |
| | | case "clearPlc": |
| | | case "clear": |
| | | result = handleClearGlass(deviceConfig, params, logicParams); |
| | | break; |
| | | case "checkStateAndCoordinate": |
| | | result = handleCheckStateAndCoordinate(deviceConfig, params, logicParams); |
| | | break; |
| | | case "startIdleMonitor": |
| | | result = handleStartIdleMonitor(deviceConfig, params, logicParams); |
| | | break; |
| | | case "stopIdleMonitor": |
| | | result = handleStopIdleMonitor(deviceConfig); |
| | | break; |
| | | case "checkMesTask": |
| | | result = handleCheckMesTask(deviceConfig, params, logicParams); |
| | | break; |
| | | case "checkMesOutboundTask": |
| | | // 出片任务也使用同一套协议,通过handleCheckMesTask处理 |
| | | result = handleCheckMesTask(deviceConfig, params, logicParams); |
| | | break; |
| | | case "startTaskMonitor": |
| | | result = handleStartTaskMonitor(deviceConfig, params, logicParams); |
| | | break; |
| | | case "stopTaskMonitor": |
| | | result = handleStopTaskMonitor(deviceConfig); |
| | | break; |
| | | default: |
| | | log.warn("不支持的操作类型: {}", operation); |
| | | result = DevicePlcVO.OperationResult.builder() |
| | | .success(false) |
| | | .message("不支持的操作: " + operation) |
| | | .build(); |
| | | } |
| | | |
| | | return result; |
| | | |
| | | } catch (Exception e) { |
| | | log.error("执行大车设备操作异常: deviceId={}, operation={}", deviceId, operation, e); |
| | | // 发生异常时,将状态设置为错误 |
| | | if (needsStateManagement(operation)) { |
| | | statusManager.updateVehicleStatus(deviceId, VehicleState.ERROR); |
| | | } |
| | | throw e; |
| | | } finally { |
| | | // 4. 执行完成后恢复为空闲状态(对于需要状态管理的操作) |
| | | if (needsStateManagement(operation)) { |
| | | // 注意:这里不立即设置为IDLE,因为实际执行可能需要时间 |
| | | // 真正的状态更新应该在任务完成后通过回调或状态查询来更新 |
| | | // 这里先保持EXECUTING状态,等待外部确认完成后再更新 |
| | | log.debug("操作执行完成,保持执行中状态,等待外部确认: deviceId={}", deviceId); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 判断操作是否需要状态检查 |
| | | */ |
| | | private boolean needsStateCheck(String operation) { |
| | | // 所有操作都需要检查状态,除了查询类操作 |
| | | return !"query".equals(operation) && !"status".equals(operation); |
| | | } |
| | | |
| | | /** |
| | | * 判断操作是否需要状态管理 |
| | | */ |
| | | private boolean needsStateManagement(String operation) { |
| | | // feedGlass 需要状态管理,其他操作根据实际情况 |
| | | return "feedGlass".equals(operation); |
| | | } |
| | | |
| | | /** |
| | | * 创建车辆任务信息 |
| | | */ |
| | | private VehicleTask createVehicleTask( |
| | | DeviceConfig deviceConfig, |
| | | String operation, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | VehicleTask task = new VehicleTask(); |
| | | task.setTaskId(generateTaskId(deviceConfig.getDeviceId())); |
| | | task.setTaskName("大车设备-" + operation); |
| | | task.setOperation(operation); |
| | | |
| | | // 从参数中提取位置信息 |
| | | String positionCode = (String) params.get("positionCode"); |
| | | Integer positionValue = (Integer) params.get("positionValue"); |
| | | |
| | | if (positionCode != null || positionValue != null) { |
| | | VehiclePosition position = new VehiclePosition(positionCode, positionValue); |
| | | task.getPlannedPath().setStartPosition(position); |
| | | task.getPlannedPath().setEndPosition(position); |
| | | } |
| | | |
| | | // 从配置中获取速度(如果有) |
| | | Double speed = getLogicParam(logicParams, "vehicleSpeed", null); |
| | | if (speed != null) { |
| | | task.setSpeed(speed); |
| | | task.calculateEstimatedEndTime(); |
| | | } |
| | | |
| | | task.setParameters(new HashMap<>(params)); |
| | | |
| | | return task; |
| | | } |
| | | |
| | | /** |
| | | * 生成任务ID |
| | | */ |
| | | private String generateTaskId(String deviceId) { |
| | | return "TASK_" + deviceId + "_" + System.currentTimeMillis(); |
| | | } |
| | | |
| | | /** |
| | | * 处理玻璃上料操作 |
| | | */ |
| | | private DevicePlcVO.OperationResult handleFeedGlass( |
| | | DeviceConfig deviceConfig, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | // 从逻辑参数中获取配置(从 extraParams.deviceLogic 读取) |
| | | Integer vehicleCapacity = getLogicParam(logicParams, "vehicleCapacity", 6000); |
| | | Integer glassIntervalMs = getLogicParam(logicParams, "glassIntervalMs", 1000); |
| | | Boolean autoFeed = getLogicParam(logicParams, "autoFeed", true); |
| | | Integer maxRetryCount = getLogicParam(logicParams, "maxRetryCount", 5); |
| | | |
| | | // 从运行时参数中获取数据(从接口调用时传入) |
| | | List<GlassInfo> glassInfos = extractGlassInfos(params); |
| | | if (glassInfos.isEmpty()) { |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(false) |
| | | .message("未提供有效的玻璃信息") |
| | | .build(); |
| | | } |
| | | |
| | | String positionCode = (String) params.get("positionCode"); |
| | | Integer positionValue = (Integer) params.get("positionValue"); |
| | | Boolean triggerRequest = (Boolean) params.getOrDefault("triggerRequest", autoFeed); |
| | | |
| | | List<GlassInfo> plannedGlasses = planGlassLoading(glassInfos, vehicleCapacity, |
| | | getLogicParam(logicParams, "defaultGlassLength", 2000)); |
| | | if (plannedGlasses.isEmpty()) { |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(false) |
| | | .message("当前玻璃尺寸超出车辆容量,无法装载") |
| | | .build(); |
| | | } |
| | | |
| | | // 构建写入数据 |
| | | Map<String, Object> payload = new HashMap<>(); |
| | | |
| | | // 写入玻璃ID |
| | | int plcSlots = Math.min(plannedGlasses.size(), 6); |
| | | for (int i = 0; i < plcSlots; i++) { |
| | | String fieldName = "plcGlassId" + (i + 1); |
| | | payload.put(fieldName, plannedGlasses.get(i).getGlassId()); |
| | | } |
| | | payload.put("plcGlassCount", plcSlots); |
| | | |
| | | // 写入位置信息 |
| | | if (positionValue != null) { |
| | | payload.put("inPosition", positionValue); |
| | | } else if (positionCode != null) { |
| | | // 从位置映射中获取位置值 |
| | | @SuppressWarnings("unchecked") |
| | | Map<String, Integer> positionMapping = getLogicParam(logicParams, "positionMapping", new HashMap<>()); |
| | | Integer mappedValue = positionMapping.get(positionCode); |
| | | if (mappedValue != null) { |
| | | payload.put("inPosition", mappedValue); |
| | | } |
| | | } |
| | | |
| | | // 自动触发请求字 |
| | | if (triggerRequest != null && triggerRequest) { |
| | | payload.put("plcRequest", 1); |
| | | } |
| | | |
| | | String operationName = "大车设备-玻璃上料"; |
| | | if (positionCode != null) { |
| | | operationName += "(" + positionCode + ")"; |
| | | } |
| | | |
| | | log.info("大车设备玻璃上料: deviceId={}, glassCount={}, position={}, plannedGlassIds={}", |
| | | deviceConfig.getId(), plcSlots, positionCode, plannedGlasses); |
| | | |
| | | if (glassIntervalMs != null && glassIntervalMs > 0) { |
| | | try { |
| | | Thread.sleep(glassIntervalMs); |
| | | } catch (InterruptedException e) { |
| | | Thread.currentThread().interrupt(); |
| | | } |
| | | } |
| | | |
| | | DevicePlcVO.OperationResult result = devicePlcOperationService.writeFields( |
| | | deviceConfig.getId(), payload, operationName); |
| | | |
| | | // 如果执行成功,更新位置信息到状态,并启动状态监控 |
| | | if (Boolean.TRUE.equals(result.getSuccess())) { |
| | | VehicleStatus status = statusManager.getOrCreateVehicleStatus( |
| | | deviceConfig.getDeviceId(), deviceConfig.getDeviceName()); |
| | | if (positionCode != null || positionValue != null) { |
| | | VehiclePosition position = new VehiclePosition(positionCode, positionValue); |
| | | status.setCurrentPosition(position); |
| | | } |
| | | |
| | | // 启动自动状态监控,当 state=1 时自动协调卧转立设备 |
| | | startStateMonitoring(deviceConfig, logicParams); |
| | | } |
| | | |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 处理触发请求操作 |
| | | */ |
| | | private DevicePlcVO.OperationResult handleTriggerRequest( |
| | | DeviceConfig deviceConfig, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | Map<String, Object> payload = new HashMap<>(); |
| | | payload.put("plcRequest", 1); |
| | | |
| | | log.info("大车设备触发请求: deviceId={}", deviceConfig.getId()); |
| | | return devicePlcOperationService.writeFields( |
| | | deviceConfig.getId(), |
| | | payload, |
| | | "大车设备-触发请求" |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * 处理触发汇报操作 |
| | | */ |
| | | private DevicePlcVO.OperationResult handleTriggerReport( |
| | | DeviceConfig deviceConfig, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | Map<String, Object> payload = new HashMap<>(); |
| | | payload.put("plcReport", 1); |
| | | |
| | | log.info("大车设备触发汇报: deviceId={}", deviceConfig.getId()); |
| | | return devicePlcOperationService.writeFields( |
| | | deviceConfig.getId(), |
| | | payload, |
| | | "大车设备-触发汇报" |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * 处理重置操作 |
| | | */ |
| | | private DevicePlcVO.OperationResult handleReset( |
| | | DeviceConfig deviceConfig, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | Map<String, Object> payload = new HashMap<>(); |
| | | payload.put("plcRequest", 0); |
| | | payload.put("plcReport", 0); |
| | | |
| | | log.info("大车设备重置: deviceId={}", deviceConfig.getId()); |
| | | |
| | | DevicePlcVO.OperationResult result = devicePlcOperationService.writeFields( |
| | | deviceConfig.getId(), |
| | | payload, |
| | | "大车设备-重置" |
| | | ); |
| | | |
| | | // 重置时,清除任务并恢复为空闲状态,停止监控 |
| | | if (Boolean.TRUE.equals(result.getSuccess())) { |
| | | statusManager.clearVehicleTask(deviceConfig.getDeviceId()); |
| | | statusManager.updateVehicleStatus(deviceConfig.getDeviceId(), VehicleState.IDLE); |
| | | stopStateMonitoring(deviceConfig.getDeviceId()); |
| | | } |
| | | |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 清空PLC中的玻璃数据 |
| | | */ |
| | | private DevicePlcVO.OperationResult handleClearGlass( |
| | | DeviceConfig deviceConfig, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | Map<String, Object> payload = new HashMap<>(); |
| | | |
| | | int slotCount = getLogicParam(logicParams, "glassSlotCount", 6); |
| | | if (slotCount <= 0) { |
| | | slotCount = 6; |
| | | } |
| | | |
| | | List<String> slotFields = resolveGlassSlotFields(logicParams, slotCount); |
| | | for (String field : slotFields) { |
| | | payload.put(field, ""); |
| | | } |
| | | |
| | | payload.put("plcGlassCount", 0); |
| | | payload.put("plcRequest", 0); |
| | | payload.put("plcReport", 0); |
| | | |
| | | if (params != null && params.containsKey("positionValue")) { |
| | | payload.put("inPosition", params.get("positionValue")); |
| | | } else if (params != null && Boolean.TRUE.equals(params.get("clearPosition"))) { |
| | | payload.put("inPosition", 0); |
| | | } |
| | | |
| | | log.info("清空大车设备PLC玻璃数据: deviceId={}, clearedSlots={}", deviceConfig.getId(), slotFields.size()); |
| | | |
| | | DevicePlcVO.OperationResult result = devicePlcOperationService.writeFields( |
| | | deviceConfig.getId(), |
| | | payload, |
| | | "大车设备-清空玻璃数据" |
| | | ); |
| | | |
| | | // 清空后,恢复为空闲状态,停止监控 |
| | | if (Boolean.TRUE.equals(result.getSuccess())) { |
| | | statusManager.clearVehicleTask(deviceConfig.getDeviceId()); |
| | | statusManager.updateVehicleStatus(deviceConfig.getDeviceId(), VehicleState.IDLE); |
| | | stopStateMonitoring(deviceConfig.getDeviceId()); |
| | | } |
| | | |
| | | return result; |
| | | } |
| | | |
| | | private List<String> resolveGlassSlotFields(Map<String, Object> logicParams, int fallbackCount) { |
| | | List<String> fields = new ArrayList<>(); |
| | | if (logicParams != null) { |
| | | Object slotFieldConfig = logicParams.get("glassSlotFields"); |
| | | if (slotFieldConfig instanceof List) { |
| | | List<?> configured = (List<?>) slotFieldConfig; |
| | | for (Object item : configured) { |
| | | if (item != null) { |
| | | String fieldName = String.valueOf(item).trim(); |
| | | if (!fieldName.isEmpty()) { |
| | | fields.add(fieldName); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | if (fields.isEmpty()) { |
| | | for (int i = 1; i <= fallbackCount; i++) { |
| | | fields.add("plcGlassId" + i); |
| | | } |
| | | } |
| | | return fields; |
| | | } |
| | | |
| | | @Override |
| | | public String validateLogicParams(DeviceConfig deviceConfig) { |
| | | Map<String, Object> logicParams = parseLogicParams(deviceConfig); |
| | | |
| | | // 验证必填参数 |
| | | Integer vehicleCapacity = getLogicParam(logicParams, "vehicleCapacity", null); |
| | | if (vehicleCapacity == null || vehicleCapacity <= 0) { |
| | | return "车辆容量(vehicleCapacity)必须大于0"; |
| | | } |
| | | |
| | | Integer glassIntervalMs = getLogicParam(logicParams, "glassIntervalMs", null); |
| | | if (glassIntervalMs != null && glassIntervalMs < 0) { |
| | | return "玻璃间隔时间(glassIntervalMs)不能为负数"; |
| | | } |
| | | |
| | | return null; // 验证通过 |
| | | } |
| | | |
| | | @Override |
| | | public String getDefaultLogicParams() { |
| | | Map<String, Object> defaultParams = new HashMap<>(); |
| | | defaultParams.put("vehicleCapacity", 6000); |
| | | defaultParams.put("glassIntervalMs", 1000); |
| | | defaultParams.put("autoFeed", true); |
| | | defaultParams.put("maxRetryCount", 5); |
| | | defaultParams.put("defaultGlassLength", 2000); |
| | | |
| | | // MES任务相关配置 |
| | | defaultParams.put("vehicleSpeed", 1.0); // 车辆速度(格/秒,grid/s),默认1格/秒 |
| | | defaultParams.put("minRange", 1); // 最小运动距离(格子) |
| | | defaultParams.put("maxRange", 100); // 最大运动距离(格子),例如100格 |
| | | defaultParams.put("homePosition", 0); // 初始位置(格子) |
| | | defaultParams.put("idleMonitorIntervalMs", 2000); // 空闲监控间隔(毫秒) |
| | | defaultParams.put("taskMonitorIntervalMs", 1000); // 任务监控间隔(毫秒) |
| | | defaultParams.put("mesConfirmTimeoutMs", 30000); // MES确认超时(毫秒) |
| | | |
| | | // 出片任务相关配置 |
| | | // outboundSlotRanges: 出片任务的startSlot范围,例如[1, 101]表示格子1~101都是出片任务 |
| | | // 如果不配置,则通过判断startSlot是否在positionMapping中来区分进片/出片 |
| | | List<Integer> outboundSlotRanges = new ArrayList<>(); |
| | | outboundSlotRanges.add(1); // 最小格子编号 |
| | | outboundSlotRanges.add(101); // 最大格子编号 |
| | | defaultParams.put("outboundSlotRanges", outboundSlotRanges); |
| | | |
| | | // gridPositionMapping: 格子编号到位置的映射表(可选) |
| | | // 如果不配置,则格子编号直接作为位置值 |
| | | Map<String, Integer> gridPositionMapping = new HashMap<>(); |
| | | defaultParams.put("gridPositionMapping", gridPositionMapping); |
| | | |
| | | Map<String, Integer> positionMapping = new HashMap<>(); |
| | | positionMapping.put("POS1", 1); |
| | | positionMapping.put("POS2", 2); |
| | | defaultParams.put("positionMapping", positionMapping); |
| | | |
| | | try { |
| | | return objectMapper.writeValueAsString(defaultParams); |
| | | } catch (Exception e) { |
| | | log.error("生成默认逻辑参数失败", e); |
| | | return "{}"; |
| | | } |
| | | } |
| | | |
| | | @SuppressWarnings("unchecked") |
| | | private List<GlassInfo> extractGlassInfos(Map<String, Object> params) { |
| | | List<GlassInfo> result = new ArrayList<>(); |
| | | Object rawGlassInfos = params.get("glassInfos"); |
| | | if (rawGlassInfos instanceof List) { |
| | | List<?> list = (List<?>) rawGlassInfos; |
| | | for (Object item : list) { |
| | | GlassInfo info = convertToGlassInfo(item); |
| | | if (info != null) { |
| | | result.add(info); |
| | | } |
| | | } |
| | | } |
| | | |
| | | if (result.isEmpty()) { |
| | | List<String> glassIds = (List<String>) params.get("glassIds"); |
| | | if (glassIds != null && !glassIds.isEmpty()) { |
| | | // 从数据库查询玻璃尺寸 |
| | | Map<String, Integer> lengthMap = glassInfoService.getGlassLengthMap(glassIds); |
| | | for (String glassId : glassIds) { |
| | | Integer length = lengthMap.get(glassId); |
| | | result.add(new GlassInfo(glassId, length)); |
| | | } |
| | | log.debug("从数据库查询玻璃尺寸: glassIds={}, lengthMap={}", glassIds, lengthMap); |
| | | } |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | private GlassInfo convertToGlassInfo(Object source) { |
| | | if (source instanceof GlassInfo) { |
| | | return (GlassInfo) source; |
| | | } |
| | | if (source instanceof Map) { |
| | | Map<?, ?> map = (Map<?, ?>) source; |
| | | Object id = map.get("glassId"); |
| | | if (id == null) { |
| | | id = map.get("id"); |
| | | } |
| | | if (id == null) { |
| | | return null; |
| | | } |
| | | Integer length = parseLength(map.get("length")); |
| | | if (length == null) { |
| | | length = parseLength(map.get("size")); |
| | | } |
| | | return new GlassInfo(String.valueOf(id), length); |
| | | } |
| | | if (source instanceof String) { |
| | | return new GlassInfo((String) source, null); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | private Integer parseLength(Object value) { |
| | | if (value instanceof Number) { |
| | | return ((Number) value).intValue(); |
| | | } |
| | | if (value instanceof String) { |
| | | try { |
| | | return Integer.parseInt((String) value); |
| | | } catch (NumberFormatException ignored) { |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | private List<GlassInfo> planGlassLoading(List<GlassInfo> source, |
| | | int vehicleCapacity, |
| | | Integer defaultGlassLength) { |
| | | List<GlassInfo> planned = new ArrayList<>(); |
| | | int usedLength = 0; |
| | | int capacity = Math.max(vehicleCapacity, 1); |
| | | int fallbackLength = defaultGlassLength != null && defaultGlassLength > 0 ? defaultGlassLength : 2000; |
| | | |
| | | for (GlassInfo info : source) { |
| | | int length = info.getLength() != null && info.getLength() > 0 ? info.getLength() : fallbackLength; |
| | | if (planned.isEmpty()) { |
| | | planned.add(info.withLength(length)); |
| | | usedLength = length; |
| | | continue; |
| | | } |
| | | if (usedLength + length <= capacity) { |
| | | planned.add(info.withLength(length)); |
| | | usedLength += length; |
| | | } else { |
| | | break; |
| | | } |
| | | } |
| | | return planned; |
| | | } |
| | | |
| | | /** |
| | | * 启动状态监控 |
| | | * 定期检查大车的 state1~6,当检测到 state=1 时自动协调卧转立设备 |
| | | */ |
| | | private void startStateMonitoring(DeviceConfig deviceConfig, Map<String, Object> logicParams) { |
| | | String deviceId = deviceConfig.getDeviceId(); |
| | | |
| | | // 如果已经在监控,先停止旧的监控任务 |
| | | stopStateMonitoring(deviceId); |
| | | |
| | | // 获取监控配置 |
| | | Integer monitorIntervalMs = getLogicParam(logicParams, "stateMonitorIntervalMs", 1000); |
| | | Integer monitorTimeoutMs = getLogicParam(logicParams, "stateMonitorTimeoutMs", 300000); |
| | | if (monitorIntervalMs == null || monitorIntervalMs <= 0) { |
| | | monitorIntervalMs = 1000; |
| | | } |
| | | if (monitorTimeoutMs == null || monitorTimeoutMs <= 0) { |
| | | monitorTimeoutMs = 300000; |
| | | } |
| | | final int finalMonitorIntervalMs = monitorIntervalMs; |
| | | final int finalMonitorTimeoutMs = monitorTimeoutMs; |
| | | |
| | | // 初始化已协调状态记录 |
| | | coordinatedStates.put(deviceId, new CopyOnWriteArrayList<>()); |
| | | |
| | | // 记录监控开始时间 |
| | | final long startTime = System.currentTimeMillis(); |
| | | |
| | | // 启动监控任务 |
| | | ScheduledFuture<?> future = stateMonitorExecutor.scheduleWithFixedDelay(() -> { |
| | | try { |
| | | // 检查超时 |
| | | if (System.currentTimeMillis() - startTime > finalMonitorTimeoutMs) { |
| | | log.info("大车状态监控超时,停止监控: deviceId={}, timeout={}ms", deviceId, finalMonitorTimeoutMs); |
| | | stopStateMonitoring(deviceId); |
| | | return; |
| | | } |
| | | |
| | | // 检查车辆是否还在执行任务 |
| | | VehicleStatus status = statusManager.getVehicleStatus(deviceId); |
| | | if (status == null || status.getState() != VehicleState.EXECUTING) { |
| | | log.debug("大车状态已改变,停止监控: deviceId={}, state={}", |
| | | deviceId, status != null ? status.getState() : "null"); |
| | | stopStateMonitoring(deviceId); |
| | | return; |
| | | } |
| | | |
| | | // 执行状态检查和协调 |
| | | checkAndCoordinateState(deviceConfig); |
| | | |
| | | } catch (Exception e) { |
| | | log.error("大车状态监控异常: deviceId={}", deviceId, e); |
| | | } |
| | | }, finalMonitorIntervalMs, finalMonitorIntervalMs, TimeUnit.MILLISECONDS); |
| | | |
| | | monitoringTasks.put(deviceId, future); |
| | | log.info("已启动大车状态监控: deviceId={}, interval={}ms, timeout={}ms", |
| | | deviceId, finalMonitorIntervalMs, finalMonitorTimeoutMs); |
| | | } |
| | | |
| | | /** |
| | | * 停止状态监控 |
| | | */ |
| | | private void stopStateMonitoring(String deviceId) { |
| | | ScheduledFuture<?> future = monitoringTasks.remove(deviceId); |
| | | if (future != null && !future.isCancelled()) { |
| | | future.cancel(false); |
| | | log.debug("已停止大车状态监控: deviceId={}", deviceId); |
| | | } |
| | | coordinatedStates.remove(deviceId); |
| | | } |
| | | |
| | | /** |
| | | * 检查大车状态并协调卧转立设备(内部方法,由监控线程调用) |
| | | */ |
| | | private void checkAndCoordinateState(DeviceConfig deviceConfig) { |
| | | String deviceId = deviceConfig.getDeviceId(); |
| | | List<String> alreadyCoordinated = coordinatedStates.get(deviceId); |
| | | if (alreadyCoordinated == null) { |
| | | alreadyCoordinated = new CopyOnWriteArrayList<>(); |
| | | coordinatedStates.put(deviceId, alreadyCoordinated); |
| | | } |
| | | |
| | | try { |
| | | // 读取 state1~6 字段 |
| | | List<String> stateFields = Arrays.asList("state1", "state2", "state3", "state4", "state5", "state6"); |
| | | Map<String, Object> stateValues = new HashMap<>(); |
| | | |
| | | // 从 PLC 读取状态字段 |
| | | DevicePlcVO.StatusInfo statusInfo = devicePlcOperationService.readStatus(deviceConfig.getId()); |
| | | if (statusInfo == null || statusInfo.getFieldValues() == null) { |
| | | return; |
| | | } |
| | | |
| | | for (String field : stateFields) { |
| | | Object value = statusInfo.getFieldValues().get(field); |
| | | if (value != null) { |
| | | stateValues.put(field, value); |
| | | } |
| | | } |
| | | |
| | | // 检查是否有任何一个 state 为 1,且尚未协调过 |
| | | List<String> newStateOneFields = new ArrayList<>(); |
| | | for (String field : stateFields) { |
| | | Integer stateValue = parseInteger(stateValues.get(field)); |
| | | if (stateValue != null && stateValue == 1 && !alreadyCoordinated.contains(field)) { |
| | | newStateOneFields.add(field); |
| | | } |
| | | } |
| | | |
| | | if (newStateOneFields.isEmpty()) { |
| | | return; // 没有新的 state=1,无需协调 |
| | | } |
| | | |
| | | log.info("检测到大车新的 state=1: deviceId={}, stateFields={}", deviceId, newStateOneFields); |
| | | |
| | | // 查找同组的卧转立设备 |
| | | List<DeviceConfig> transferDevices = findTransferDevicesInSameGroup(deviceConfig); |
| | | if (transferDevices.isEmpty()) { |
| | | log.warn("未找到同组的卧转立设备: deviceId={}", deviceId); |
| | | // 即使找不到设备,也标记为已协调,避免重复检查 |
| | | alreadyCoordinated.addAll(newStateOneFields); |
| | | return; |
| | | } |
| | | |
| | | // 将每个卧转立设备的 plcRequest 置 0 |
| | | boolean allSuccess = true; |
| | | for (DeviceConfig transferDevice : transferDevices) { |
| | | Map<String, Object> payload = new HashMap<>(); |
| | | payload.put("plcRequest", 0); |
| | | |
| | | DevicePlcVO.OperationResult result = devicePlcOperationService.writeFields( |
| | | transferDevice.getId(), |
| | | payload, |
| | | "大车自动协调-清空卧转立请求" |
| | | ); |
| | | |
| | | if (Boolean.TRUE.equals(result.getSuccess())) { |
| | | log.info("已自动清空卧转立设备 plcRequest: vehicleDeviceId={}, transferDeviceId={}, transferDeviceName={}, stateFields={}", |
| | | deviceId, transferDevice.getId(), transferDevice.getDeviceName(), newStateOneFields); |
| | | } else { |
| | | log.warn("自动清空卧转立设备 plcRequest 失败: vehicleDeviceId={}, transferDeviceId={}, message={}", |
| | | deviceId, transferDevice.getId(), result.getMessage()); |
| | | allSuccess = false; |
| | | } |
| | | } |
| | | |
| | | // 标记为已协调(无论成功与否,避免重复协调) |
| | | if (allSuccess) { |
| | | alreadyCoordinated.addAll(newStateOneFields); |
| | | log.info("大车状态协调完成: deviceId={}, coordinatedStateFields={}", deviceId, newStateOneFields); |
| | | } |
| | | |
| | | } catch (Exception e) { |
| | | log.error("检查大车状态并协调卧转立设备异常: deviceId={}", deviceId, e); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 检查大车状态并协调卧转立设备(手动调用接口) |
| | | * 当 state1~6 中任何一个变为 1(上车完成)时,将同组卧转立设备的 plcRequest 置 0 |
| | | */ |
| | | private DevicePlcVO.OperationResult handleCheckStateAndCoordinate( |
| | | DeviceConfig deviceConfig, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | try { |
| | | // 读取 state1~6 字段 |
| | | List<String> stateFields = Arrays.asList("state1", "state2", "state3", "state4", "state5", "state6"); |
| | | Map<String, Object> stateValues = new HashMap<>(); |
| | | |
| | | // 从 PLC 读取状态字段 |
| | | DevicePlcVO.StatusInfo statusInfo = devicePlcOperationService.readStatus(deviceConfig.getId()); |
| | | if (statusInfo != null && statusInfo.getFieldValues() != null) { |
| | | for (String field : stateFields) { |
| | | Object value = statusInfo.getFieldValues().get(field); |
| | | if (value != null) { |
| | | stateValues.put(field, value); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 检查是否有任何一个 state 为 1 |
| | | boolean hasStateOne = false; |
| | | List<String> stateOneFields = new ArrayList<>(); |
| | | for (String field : stateFields) { |
| | | Integer stateValue = parseInteger(stateValues.get(field)); |
| | | if (stateValue != null && stateValue == 1) { |
| | | hasStateOne = true; |
| | | stateOneFields.add(field); |
| | | } |
| | | } |
| | | |
| | | if (!hasStateOne) { |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(true) |
| | | .message("当前无 state=1 的状态,无需协调") |
| | | .build(); |
| | | } |
| | | |
| | | log.info("检测到大车 state=1: deviceId={}, stateFields={}", |
| | | deviceConfig.getId(), stateOneFields); |
| | | |
| | | // 查找同组的卧转立设备 |
| | | List<DeviceConfig> transferDevices = findTransferDevicesInSameGroup(deviceConfig); |
| | | if (transferDevices.isEmpty()) { |
| | | log.warn("未找到同组的卧转立设备: deviceId={}", deviceConfig.getId()); |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(true) |
| | | .message("未找到同组的卧转立设备,跳过协调") |
| | | .build(); |
| | | } |
| | | |
| | | // 将每个卧转立设备的 plcRequest 置 0 |
| | | List<DevicePlcVO.OperationResult> results = new ArrayList<>(); |
| | | for (DeviceConfig transferDevice : transferDevices) { |
| | | Map<String, Object> payload = new HashMap<>(); |
| | | payload.put("plcRequest", 0); |
| | | |
| | | DevicePlcVO.OperationResult result = devicePlcOperationService.writeFields( |
| | | transferDevice.getId(), |
| | | payload, |
| | | "大车协调-清空卧转立请求" |
| | | ); |
| | | results.add(result); |
| | | |
| | | if (Boolean.TRUE.equals(result.getSuccess())) { |
| | | log.info("已清空卧转立设备 plcRequest: transferDeviceId={}, transferDeviceName={}", |
| | | transferDevice.getId(), transferDevice.getDeviceName()); |
| | | } else { |
| | | log.warn("清空卧转立设备 plcRequest 失败: transferDeviceId={}, message={}", |
| | | transferDevice.getId(), result.getMessage()); |
| | | } |
| | | } |
| | | |
| | | boolean allSuccess = results.stream() |
| | | .allMatch(r -> Boolean.TRUE.equals(r.getSuccess())); |
| | | |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(allSuccess) |
| | | .message(String.format("已协调 %d 个卧转立设备,成功: %d", |
| | | transferDevices.size(), |
| | | results.stream().mapToInt(r -> Boolean.TRUE.equals(r.getSuccess()) ? 1 : 0).sum())) |
| | | .build(); |
| | | |
| | | } catch (Exception e) { |
| | | log.error("检查大车状态并协调卧转立设备异常: deviceId={}", deviceConfig.getId(), e); |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(false) |
| | | .message("协调异常: " + e.getMessage()) |
| | | .build(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 查找同组内的卧转立设备 |
| | | */ |
| | | private List<DeviceConfig> findTransferDevicesInSameGroup(DeviceConfig vehicleDevice) { |
| | | List<DeviceConfig> transferDevices = new ArrayList<>(); |
| | | |
| | | if (deviceGroupRelationService == null || deviceConfigService == null) { |
| | | log.warn("DeviceGroupRelationService 或 DeviceConfigService 未注入,无法查找同组设备"); |
| | | return transferDevices; |
| | | } |
| | | |
| | | try { |
| | | // 获取大车所属的设备组 |
| | | List<DeviceGroupVO.GroupInfo> groups = deviceGroupRelationService.getDeviceGroups(vehicleDevice.getId()); |
| | | if (groups.isEmpty()) { |
| | | log.debug("大车设备未加入任何设备组: deviceId={}", vehicleDevice.getId()); |
| | | return transferDevices; |
| | | } |
| | | |
| | | // 遍历所有设备组,查找卧转立设备 |
| | | for (DeviceGroupVO.GroupInfo group : groups) { |
| | | List<DeviceGroupVO.DeviceInfo> groupDevices = deviceGroupRelationService.getGroupDevices(group.getId()); |
| | | for (DeviceGroupVO.DeviceInfo deviceInfo : groupDevices) { |
| | | // 检查是否为卧转立设备 |
| | | if (DeviceConfig.DeviceType.WORKSTATION_TRANSFER.equals(deviceInfo.getDeviceType())) { |
| | | DeviceConfig transferDevice = deviceConfigService.getDeviceById(deviceInfo.getId()); |
| | | if (transferDevice != null) { |
| | | transferDevices.add(transferDevice); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | log.debug("找到同组卧转立设备: vehicleDeviceId={}, transferDeviceCount={}", |
| | | vehicleDevice.getId(), transferDevices.size()); |
| | | |
| | | } catch (Exception e) { |
| | | log.error("查找同组卧转立设备异常: vehicleDeviceId={}", vehicleDevice.getId(), e); |
| | | } |
| | | |
| | | return transferDevices; |
| | | } |
| | | |
| | | private Integer parseInteger(Object value) { |
| | | if (value instanceof Number) { |
| | | return ((Number) value).intValue(); |
| | | } |
| | | if (value == null) { |
| | | return null; |
| | | } |
| | | try { |
| | | return Integer.parseInt(String.valueOf(value)); |
| | | } catch (NumberFormatException e) { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | private String parseString(Object value) { |
| | | return value == null ? null : String.valueOf(value).trim(); |
| | | } |
| | | |
| | | private static class GlassInfo { |
| | | private final String glassId; |
| | | private final Integer length; |
| | | |
| | | GlassInfo(String glassId, Integer length) { |
| | | this.glassId = glassId; |
| | | this.length = length; |
| | | } |
| | | |
| | | public String getGlassId() { |
| | | return glassId; |
| | | } |
| | | |
| | | public Integer getLength() { |
| | | return length; |
| | | } |
| | | |
| | | public GlassInfo withLength(Integer newLength) { |
| | | return new GlassInfo(this.glassId, newLength); |
| | | } |
| | | |
| | | @Override |
| | | public String toString() { |
| | | return glassId; |
| | | } |
| | | |
| | | @Override |
| | | public int hashCode() { |
| | | return Objects.hash(glassId, length); |
| | | } |
| | | |
| | | @Override |
| | | public boolean equals(Object obj) { |
| | | if (this == obj) return true; |
| | | if (obj == null || getClass() != obj.getClass()) return false; |
| | | GlassInfo other = (GlassInfo) obj; |
| | | return Objects.equals(glassId, other.glassId) && Objects.equals(length, other.length); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 启动空闲监控(没有任务时,plcRequest一直保持为1) |
| | | */ |
| | | private DevicePlcVO.OperationResult handleStartIdleMonitor( |
| | | DeviceConfig deviceConfig, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | String deviceId = deviceConfig.getDeviceId(); |
| | | |
| | | // 停止旧的监控任务 |
| | | handleStopIdleMonitor(deviceConfig); |
| | | |
| | | // 获取监控间隔 |
| | | Integer monitorIntervalMs = getLogicParam(logicParams, "idleMonitorIntervalMs", 2000); // 默认2秒 |
| | | |
| | | // 启动监控任务 |
| | | ScheduledFuture<?> future = idleMonitorExecutor.scheduleWithFixedDelay(() -> { |
| | | try { |
| | | // 检查是否有任务在执行 |
| | | VehicleStatus status = statusManager.getVehicleStatus(deviceId); |
| | | if (status != null && status.getState() == VehicleState.EXECUTING) { |
| | | // 有任务在执行,不设置plcRequest |
| | | return; |
| | | } |
| | | |
| | | // 检查是否有待处理的进片或出片任务 |
| | | if (plcDynamicDataService != null && s7SerializerProvider != null) { |
| | | EnhancedS7Serializer serializer = s7SerializerProvider.getSerializer(deviceConfig); |
| | | if (serializer != null) { |
| | | // 检查进片任务 |
| | | Map<String, Object> mesData = plcDynamicDataService.readPlcData( |
| | | deviceConfig, MES_FIELDS, serializer); |
| | | Integer mesSend = parseInteger(mesData != null ? mesData.get("mesSend") : null); |
| | | |
| | | // 进片和出片共用mesSend字段,只需检查一次 |
| | | // 如果有待处理的任务,不设置plcRequest(等待任务处理) |
| | | if (mesSend != null && mesSend == 1) { |
| | | log.debug("大车空闲监控: deviceId={}, 检测到待处理任务(mesSend=1),不设置plcRequest", deviceId); |
| | | return; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 没有任务,保持plcRequest=1 |
| | | Map<String, Object> payload = new HashMap<>(); |
| | | payload.put("plcRequest", 1); |
| | | |
| | | devicePlcOperationService.writeFields(deviceConfig.getId(), payload, "大车空闲监控-保持请求"); |
| | | log.debug("大车空闲监控: deviceId={}, 保持plcRequest=1", deviceId); |
| | | |
| | | } catch (Exception e) { |
| | | log.error("大车空闲监控异常: deviceId={}", deviceId, e); |
| | | } |
| | | }, monitorIntervalMs, monitorIntervalMs, TimeUnit.MILLISECONDS); |
| | | |
| | | idleMonitoringTasks.put(deviceId, future); |
| | | log.info("已启动大车空闲监控: deviceId={}, interval={}ms", deviceId, monitorIntervalMs); |
| | | |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(true) |
| | | .message("空闲监控已启动") |
| | | .build(); |
| | | } |
| | | |
| | | /** |
| | | * 停止空闲监控 |
| | | */ |
| | | private DevicePlcVO.OperationResult handleStopIdleMonitor(DeviceConfig deviceConfig) { |
| | | String deviceId = deviceConfig.getDeviceId(); |
| | | ScheduledFuture<?> future = idleMonitoringTasks.remove(deviceId); |
| | | if (future != null && !future.isCancelled()) { |
| | | future.cancel(false); |
| | | log.info("已停止大车空闲监控: deviceId={}", deviceId); |
| | | } |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(true) |
| | | .message("空闲监控已停止") |
| | | .build(); |
| | | } |
| | | |
| | | /** |
| | | * 检查MES任务(当mesSend=1时,读取MES参数并创建任务) |
| | | * 进片和出片共用同一套协议字段,通过位置信息判断任务类型 |
| | | */ |
| | | private DevicePlcVO.OperationResult handleCheckMesTask( |
| | | DeviceConfig deviceConfig, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | if (plcDynamicDataService == null || s7SerializerProvider == null) { |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(false) |
| | | .message("PlcDynamicDataService或S7SerializerProvider未注入") |
| | | .build(); |
| | | } |
| | | |
| | | String deviceId = deviceConfig.getDeviceId(); |
| | | EnhancedS7Serializer serializer = s7SerializerProvider.getSerializer(deviceConfig); |
| | | if (serializer == null) { |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(false) |
| | | .message("获取PLC序列化器失败") |
| | | .build(); |
| | | } |
| | | |
| | | try { |
| | | // 读取MES字段(进片和出片共用) |
| | | Map<String, Object> mesData = plcDynamicDataService.readPlcData( |
| | | deviceConfig, MES_FIELDS, serializer); |
| | | if (mesData == null || mesData.isEmpty()) { |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(false) |
| | | .message("读取MES字段失败") |
| | | .build(); |
| | | } |
| | | |
| | | Integer mesSend = parseInteger(mesData.get("mesSend")); |
| | | if (mesSend == null || mesSend == 0) { |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(true) |
| | | .message("暂无MES任务(mesSend=0)") |
| | | .build(); |
| | | } |
| | | |
| | | // mesSend=1,读取任务参数 |
| | | String glassId = parseString(mesData.get("mesGlassId")); |
| | | Integer startSlot = parseInteger(mesData.get("startSlot")); // 起始位置编号 |
| | | Integer targetSlot = parseInteger(mesData.get("targetSlot")); // 目标位置编号 |
| | | Integer workLine = parseInteger(mesData.get("workLine")); |
| | | |
| | | if (glassId == null || glassId.isEmpty()) { |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(false) |
| | | .message("MES未提供玻璃ID") |
| | | .build(); |
| | | } |
| | | |
| | | // 判断是进片还是出片任务 |
| | | // 方法:通过startSlot判断 |
| | | // - 如果startSlot是卧转立编号(如900/901),则是进片任务 |
| | | // - 如果startSlot是格子编号(在大理片笼范围内),则是出片任务 |
| | | boolean isOutbound = isOutboundTask(startSlot, logicParams); |
| | | |
| | | // 位置映射 |
| | | Integer startPosition; |
| | | if (isOutbound) { |
| | | // 出片任务:startSlot是格子编号,需要映射到实际位置 |
| | | startPosition = mapOutboundPosition(startSlot, logicParams); |
| | | } else { |
| | | // 进片任务:startSlot是卧转立编号,通过positionMapping映射 |
| | | startPosition = mapPosition(startSlot, logicParams); |
| | | } |
| | | |
| | | // targetSlot统一通过positionMapping映射 |
| | | Integer targetPosition = mapPosition(targetSlot, logicParams); |
| | | |
| | | if (startPosition == null || targetPosition == null) { |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(false) |
| | | .message(String.format("位置映射失败: startSlot=%s, targetSlot=%s, isOutbound=%s", |
| | | startSlot, targetSlot, isOutbound)) |
| | | .build(); |
| | | } |
| | | |
| | | // 读取当前位置 |
| | | Integer currentPosition = getCurrentPosition(deviceConfig, logicParams); |
| | | |
| | | // 计算时间 |
| | | TimeCalculation timeCalc = calculateTime( |
| | | currentPosition, startPosition, targetPosition, logicParams); |
| | | |
| | | // 创建任务信息 |
| | | MesTaskInfo taskInfo = new MesTaskInfo(); |
| | | taskInfo.glassId = glassId; |
| | | taskInfo.startSlot = startSlot; |
| | | taskInfo.targetSlot = targetSlot; |
| | | taskInfo.startPosition = startPosition; |
| | | taskInfo.targetPosition = targetPosition; |
| | | taskInfo.currentPosition = currentPosition; |
| | | taskInfo.gotime = timeCalc.gotime; |
| | | taskInfo.cartime = timeCalc.cartime; |
| | | taskInfo.workLine = workLine; |
| | | taskInfo.createdTime = System.currentTimeMillis(); |
| | | taskInfo.isOutbound = isOutbound; |
| | | |
| | | currentTasks.put(deviceId, taskInfo); |
| | | |
| | | // 清空plcRequest(表示已接收任务) |
| | | Map<String, Object> payload = new HashMap<>(); |
| | | payload.put("plcRequest", 0); |
| | | plcDynamicDataService.writePlcData(deviceConfig, payload, serializer); |
| | | |
| | | // 更新车辆状态为执行中 |
| | | statusManager.updateVehicleStatus(deviceId, deviceConfig.getDeviceName(), VehicleState.EXECUTING); |
| | | |
| | | // 启动任务监控 |
| | | handleStartTaskMonitor(deviceConfig, params, logicParams); |
| | | |
| | | String taskType = isOutbound ? "出片" : "进片"; |
| | | log.info("MES{}任务已创建: deviceId={}, glassId={}, startSlot={}(位置{}格), targetSlot={}(位置{}格), 距离{}格->{}格, gotime={}ms({}秒), cartime={}ms({}秒)", |
| | | taskType, deviceId, glassId, startSlot, startPosition, targetSlot, targetPosition, |
| | | Math.abs(startPosition - currentPosition), Math.abs(targetPosition - startPosition), |
| | | timeCalc.gotime, timeCalc.gotime / 1000.0, timeCalc.cartime, timeCalc.cartime / 1000.0); |
| | | |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(true) |
| | | .message(String.format("MES%s任务已创建: glassId=%s, start=%d, target=%d", |
| | | taskType, glassId, startPosition, targetPosition)) |
| | | .build(); |
| | | |
| | | } catch (Exception e) { |
| | | log.error("检查MES任务异常: deviceId={}", deviceId, e); |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(false) |
| | | .message("处理异常: " + e.getMessage()) |
| | | .build(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 判断是否为出片任务 |
| | | * 通过startSlot判断: |
| | | * - 如果startSlot在positionMapping中,且不在大理片笼格子范围内,则是进片任务 |
| | | * - 如果startSlot不在positionMapping中,或在大理片笼格子范围内,则是出片任务 |
| | | */ |
| | | private boolean isOutboundTask(Integer startSlot, Map<String, Object> logicParams) { |
| | | if (startSlot == null) { |
| | | return false; |
| | | } |
| | | |
| | | // 方法1:检查startSlot是否在positionMapping中(卧转立编号) |
| | | @SuppressWarnings("unchecked") |
| | | Map<String, Integer> positionMapping = getLogicParam(logicParams, "positionMapping", new HashMap<>()); |
| | | if (positionMapping.containsKey(String.valueOf(startSlot))) { |
| | | // startSlot在positionMapping中,说明是卧转立编号,是进片任务 |
| | | return false; |
| | | } |
| | | |
| | | // 方法2:检查startSlot是否在大理片笼格子范围内 |
| | | // 通过查找同组的大理片笼设备,检查格子范围 |
| | | // 这里简化处理:如果startSlot不在positionMapping中,且是数字,可能是格子编号 |
| | | // 可以通过配置指定格子编号范围,或者通过查找同组设备判断 |
| | | |
| | | // 方法3:通过配置指定出片任务的startSlot范围 |
| | | @SuppressWarnings("unchecked") |
| | | List<Integer> outboundSlotRanges = getLogicParam(logicParams, "outboundSlotRanges", null); |
| | | if (outboundSlotRanges != null && !outboundSlotRanges.isEmpty()) { |
| | | // 如果配置了出片slot范围,检查startSlot是否在范围内 |
| | | // 例如:[1, 101] 表示格子1~101都是出片任务 |
| | | if (outboundSlotRanges.size() >= 2) { |
| | | int minSlot = outboundSlotRanges.get(0); |
| | | int maxSlot = outboundSlotRanges.get(1); |
| | | if (startSlot >= minSlot && startSlot <= maxSlot) { |
| | | return true; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 默认:如果startSlot不在positionMapping中,且是较小的数字(可能是格子编号),判断为出片 |
| | | // 这里可以根据实际需求调整判断逻辑 |
| | | // 暂时:如果startSlot不在positionMapping中,判断为出片任务 |
| | | return true; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * 映射出片源位置(格子编号转换为实际位置) |
| | | * 通过查找同组的大理片笼设备配置,将格子编号转换为位置 |
| | | */ |
| | | private Integer mapOutboundPosition(Integer gridNumber, Map<String, Object> logicParams) { |
| | | if (gridNumber == null) { |
| | | return null; |
| | | } |
| | | |
| | | // 方法1:如果配置了格子到位置的映射表 |
| | | @SuppressWarnings("unchecked") |
| | | Map<String, Integer> gridPositionMapping = getLogicParam(logicParams, "gridPositionMapping", new HashMap<>()); |
| | | Integer position = gridPositionMapping.get(String.valueOf(gridNumber)); |
| | | if (position != null) { |
| | | return position; |
| | | } |
| | | |
| | | // 方法2:格子编号直接作为位置(如果格子编号就是位置值) |
| | | // 例如:格子1对应位置1格,格子52对应位置52格 |
| | | // 这里可以根据实际需求调整映射逻辑 |
| | | |
| | | // 暂时直接使用格子编号作为位置 |
| | | log.debug("使用格子编号作为位置: gridNumber={}", gridNumber); |
| | | return gridNumber; |
| | | } |
| | | |
| | | /** |
| | | * 位置映射:将MES给的编号(如900/901)转换为实际位置值(如100/500) |
| | | */ |
| | | private Integer mapPosition(Integer slotNumber, Map<String, Object> logicParams) { |
| | | if (slotNumber == null) { |
| | | return null; |
| | | } |
| | | |
| | | // 从配置中获取位置映射表 |
| | | @SuppressWarnings("unchecked") |
| | | Map<String, Integer> positionMapping = getLogicParam(logicParams, "positionMapping", new HashMap<>()); |
| | | |
| | | // 查找映射 |
| | | Integer position = positionMapping.get(String.valueOf(slotNumber)); |
| | | if (position != null) { |
| | | return position; |
| | | } |
| | | |
| | | // 如果没有配置映射,尝试直接使用编号 |
| | | log.warn("位置映射未找到: slotNumber={}, 使用编号作为位置值", slotNumber); |
| | | return slotNumber; |
| | | } |
| | | |
| | | /** |
| | | * 获取当前位置 |
| | | */ |
| | | private Integer getCurrentPosition(DeviceConfig deviceConfig, Map<String, Object> logicParams) { |
| | | // 从状态管理器获取 |
| | | VehicleStatus status = statusManager.getVehicleStatus(deviceConfig.getDeviceId()); |
| | | if (status != null && status.getCurrentPosition() != null) { |
| | | return status.getCurrentPosition().getPositionValue(); |
| | | } |
| | | |
| | | // 从配置中获取默认位置 |
| | | return getLogicParam(logicParams, "homePosition", 0); |
| | | } |
| | | |
| | | /** |
| | | * 时间计算:根据速度、当前位置、目标位置计算gotime和cartime |
| | | * 速度单位:格/秒(grid/s) |
| | | * 位置和距离单位:格子(grid) |
| | | */ |
| | | private TimeCalculation calculateTime(Integer currentPos, Integer startPos, |
| | | Integer targetPos, Map<String, Object> logicParams) { |
| | | // 获取速度(格/秒,grid/s) |
| | | Double speed = getLogicParam(logicParams, "vehicleSpeed", 1.0); |
| | | if (speed == null || speed <= 0) { |
| | | speed = 1.0; // 默认1格/秒 |
| | | } |
| | | |
| | | // 获取运动距离范围(格子) |
| | | Integer minRange = getLogicParam(logicParams, "minRange", 1); |
| | | Integer maxRange = getLogicParam(logicParams, "maxRange", 100); |
| | | |
| | | // 计算gotime:从当前位置到起始位置的时间(毫秒) |
| | | // 公式:时间(ms) = 距离(格) / 速度(格/秒) * 1000 |
| | | long gotime = 0; |
| | | if (currentPos != null && startPos != null) { |
| | | int distance = Math.abs(startPos - currentPos); // 距离(格子) |
| | | // 限制在范围内 |
| | | distance = Math.max(minRange, Math.min(maxRange, distance)); |
| | | gotime = (long) (distance / speed * 1000); // 转换为毫秒 |
| | | } |
| | | |
| | | // 计算cartime:从起始位置到目标位置的时间(毫秒) |
| | | // 公式:时间(ms) = 距离(格) / 速度(格/秒) * 1000 |
| | | long cartime = 0; |
| | | if (startPos != null && targetPos != null) { |
| | | int distance = Math.abs(targetPos - startPos); // 距离(格子) |
| | | // 限制在范围内 |
| | | distance = Math.max(minRange, Math.min(maxRange, distance)); |
| | | cartime = (long) (distance / speed * 1000); // 转换为毫秒 |
| | | } |
| | | |
| | | return new TimeCalculation(gotime, cartime); |
| | | } |
| | | |
| | | /** |
| | | * 启动任务监控(监控state状态切换和任务完成) |
| | | */ |
| | | private DevicePlcVO.OperationResult handleStartTaskMonitor( |
| | | DeviceConfig deviceConfig, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | String deviceId = deviceConfig.getDeviceId(); |
| | | |
| | | // 停止旧的监控任务 |
| | | handleStopTaskMonitor(deviceConfig); |
| | | |
| | | MesTaskInfo taskInfo = currentTasks.get(deviceId); |
| | | if (taskInfo == null) { |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(false) |
| | | .message("没有正在执行的任务") |
| | | .build(); |
| | | } |
| | | |
| | | // 获取监控间隔 |
| | | Integer monitorIntervalMs = getLogicParam(logicParams, "taskMonitorIntervalMs", 1000); // 默认1秒 |
| | | |
| | | // 启动监控任务 |
| | | ScheduledFuture<?> future = taskMonitorExecutor.scheduleWithFixedDelay(() -> { |
| | | try { |
| | | monitorTaskExecution(deviceConfig, taskInfo, logicParams); |
| | | } catch (Exception e) { |
| | | log.error("任务监控异常: deviceId={}", deviceId, e); |
| | | } |
| | | }, monitorIntervalMs, monitorIntervalMs, TimeUnit.MILLISECONDS); |
| | | |
| | | taskMonitoringTasks.put(deviceId, future); |
| | | log.info("已启动任务监控: deviceId={}, interval={}ms", deviceId, monitorIntervalMs); |
| | | |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(true) |
| | | .message("任务监控已启动") |
| | | .build(); |
| | | } |
| | | |
| | | /** |
| | | * 监控任务执行 |
| | | */ |
| | | private void monitorTaskExecution(DeviceConfig deviceConfig, |
| | | MesTaskInfo taskInfo, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | if (plcDynamicDataService == null || s7SerializerProvider == null) { |
| | | return; |
| | | } |
| | | |
| | | String deviceId = deviceConfig.getDeviceId(); |
| | | EnhancedS7Serializer serializer = s7SerializerProvider.getSerializer(deviceConfig); |
| | | if (serializer == null) { |
| | | return; |
| | | } |
| | | |
| | | try { |
| | | // 读取state1~6 |
| | | List<String> stateFields = Arrays.asList("state1", "state2", "state3", "state4", "state5", "state6"); |
| | | Map<String, Object> stateValues = new HashMap<>(); |
| | | |
| | | DevicePlcVO.StatusInfo statusInfo = devicePlcOperationService.readStatus(deviceConfig.getId()); |
| | | if (statusInfo != null && statusInfo.getFieldValues() != null) { |
| | | for (String field : stateFields) { |
| | | Object value = statusInfo.getFieldValues().get(field); |
| | | if (value != null) { |
| | | stateValues.put(field, value); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 根据时间计算更新state状态 |
| | | long currentTime = System.currentTimeMillis(); |
| | | long elapsed = currentTime - taskInfo.createdTime; |
| | | |
| | | // 计算状态切换时间点 |
| | | long state1Time = taskInfo.gotime; // 到达起始位置,上车完成 |
| | | long state2Time = taskInfo.gotime + taskInfo.cartime; // 到达目标位置,运输完成 |
| | | |
| | | // 更新state状态 |
| | | if (elapsed >= state1Time && elapsed < state2Time) { |
| | | // state应该为1 |
| | | if (taskInfo.isOutbound) { |
| | | // 出片任务:到达源位置(大理片笼),取片完成 |
| | | updateStateIfNeeded(deviceConfig, serializer, stateValues, 1, taskInfo); |
| | | } else { |
| | | // 进片任务:到达起始位置(卧转立),上车完成 |
| | | updateStateIfNeeded(deviceConfig, serializer, stateValues, 1, taskInfo); |
| | | } |
| | | } else if (elapsed >= state2Time) { |
| | | // state应该为2(运输完成) |
| | | updateStateIfNeeded(deviceConfig, serializer, stateValues, 2, taskInfo); |
| | | |
| | | // 检查是否所有state都>=2,如果是则给MES汇报 |
| | | if (allStatesCompleted(stateValues)) { |
| | | reportToMes(deviceConfig, serializer, taskInfo, logicParams); |
| | | } |
| | | } |
| | | |
| | | } catch (Exception e) { |
| | | log.error("监控任务执行异常: deviceId={}", deviceId, e); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 更新state状态(如果需要) |
| | | */ |
| | | private void updateStateIfNeeded(DeviceConfig deviceConfig, |
| | | EnhancedS7Serializer serializer, |
| | | Map<String, Object> currentStates, |
| | | int targetState, |
| | | MesTaskInfo taskInfo) { |
| | | |
| | | // 这里可以根据实际需求更新state字段 |
| | | // 暂时只记录日志,实际更新可能需要根据具体PLC字段配置 |
| | | log.debug("任务状态更新: deviceId={}, targetState={}", |
| | | deviceConfig.getDeviceId(), targetState); |
| | | } |
| | | |
| | | /** |
| | | * 检查是否所有state都已完成(>=2) |
| | | */ |
| | | private boolean allStatesCompleted(Map<String, Object> stateValues) { |
| | | for (Object value : stateValues.values()) { |
| | | Integer state = parseInteger(value); |
| | | if (state == null || state < 2) { |
| | | return false; |
| | | } |
| | | } |
| | | return !stateValues.isEmpty(); |
| | | } |
| | | |
| | | /** |
| | | * 给MES汇报 |
| | | */ |
| | | private void reportToMes(DeviceConfig deviceConfig, |
| | | EnhancedS7Serializer serializer, |
| | | MesTaskInfo taskInfo, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | try { |
| | | // 设置汇报字 |
| | | Map<String, Object> payload = new HashMap<>(); |
| | | payload.put("plcReport", 1); |
| | | plcDynamicDataService.writePlcData(deviceConfig, payload, serializer); |
| | | |
| | | String taskType = taskInfo.isOutbound ? "出片" : "进片"; |
| | | log.info("已给MES汇报({}任务): deviceId={}, glassId={}", |
| | | taskType, deviceConfig.getDeviceId(), taskInfo.glassId); |
| | | |
| | | // 等待MES确认 |
| | | waitForMesConfirm(deviceConfig, serializer, taskInfo, logicParams); |
| | | |
| | | } catch (Exception e) { |
| | | log.error("给MES汇报异常: deviceId={}", deviceConfig.getDeviceId(), e); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 等待MES确认 |
| | | */ |
| | | private void waitForMesConfirm(DeviceConfig deviceConfig, |
| | | EnhancedS7Serializer serializer, |
| | | MesTaskInfo taskInfo, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | try { |
| | | // 读取确认字(假设字段名为mesConfirm) |
| | | Integer maxWaitTime = getLogicParam(logicParams, "mesConfirmTimeoutMs", 30000); // 默认30秒 |
| | | long startTime = System.currentTimeMillis(); |
| | | |
| | | while (System.currentTimeMillis() - startTime < maxWaitTime) { |
| | | Object confirmValue = plcDynamicDataService.readPlcField( |
| | | deviceConfig, "mesConfirm", serializer); |
| | | Integer confirm = parseInteger(confirmValue); |
| | | |
| | | if (confirm != null && confirm == 1) { |
| | | // MES已确认,清空state和汇报字 |
| | | clearTaskStates(deviceConfig, serializer); |
| | | |
| | | // 任务完成,恢复为空闲状态 |
| | | statusManager.updateVehicleStatus( |
| | | deviceConfig.getDeviceId(), VehicleState.IDLE); |
| | | statusManager.clearVehicleTask(deviceConfig.getDeviceId()); |
| | | |
| | | // 移除任务 |
| | | currentTasks.remove(deviceConfig.getDeviceId()); |
| | | |
| | | // 停止任务监控 |
| | | handleStopTaskMonitor(deviceConfig); |
| | | |
| | | // 恢复plcRequest=1(可以接收新任务) |
| | | Map<String, Object> payload = new HashMap<>(); |
| | | payload.put("plcRequest", 1); |
| | | plcDynamicDataService.writePlcData(deviceConfig, payload, serializer); |
| | | |
| | | log.info("MES任务已完成: deviceId={}, glassId={}", |
| | | deviceConfig.getDeviceId(), taskInfo.glassId); |
| | | return; |
| | | } |
| | | |
| | | Thread.sleep(500); // 等待500ms后重试 |
| | | } |
| | | |
| | | log.warn("等待MES确认超时: deviceId={}, glassId={}", |
| | | deviceConfig.getDeviceId(), taskInfo.glassId); |
| | | |
| | | } catch (Exception e) { |
| | | log.error("等待MES确认异常: deviceId={}", deviceConfig.getDeviceId(), e); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 清空任务状态 |
| | | */ |
| | | private void clearTaskStates(DeviceConfig deviceConfig, EnhancedS7Serializer serializer) { |
| | | try { |
| | | Map<String, Object> payload = new HashMap<>(); |
| | | // 清空state1~6 |
| | | for (int i = 1; i <= 6; i++) { |
| | | payload.put("state" + i, 0); |
| | | } |
| | | // 清空汇报字 |
| | | payload.put("plcReport", 0); |
| | | plcDynamicDataService.writePlcData(deviceConfig, payload, serializer); |
| | | } catch (Exception e) { |
| | | log.error("清空任务状态异常: deviceId={}", deviceConfig.getDeviceId(), e); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 停止任务监控 |
| | | */ |
| | | private DevicePlcVO.OperationResult handleStopTaskMonitor(DeviceConfig deviceConfig) { |
| | | String deviceId = deviceConfig.getDeviceId(); |
| | | ScheduledFuture<?> future = taskMonitoringTasks.remove(deviceId); |
| | | if (future != null && !future.isCancelled()) { |
| | | future.cancel(false); |
| | | log.info("已停止任务监控: deviceId={}", deviceId); |
| | | } |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(true) |
| | | .message("任务监控已停止") |
| | | .build(); |
| | | } |
| | | |
| | | /** |
| | | * 时间计算结果 |
| | | */ |
| | | private static class TimeCalculation { |
| | | final long gotime; // 到起始位置的时间(毫秒) |
| | | final long cartime; // 从起始到目标位置的时间(毫秒) |
| | | |
| | | TimeCalculation(long gotime, long cartime) { |
| | | this.gotime = gotime; |
| | | this.cartime = cartime; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * MES任务信息 |
| | | */ |
| | | private static class MesTaskInfo { |
| | | String glassId; |
| | | Integer startSlot; |
| | | Integer targetSlot; |
| | | Integer startPosition; |
| | | Integer targetPosition; |
| | | Integer currentPosition; |
| | | long gotime; |
| | | long cartime; |
| | | Integer workLine; |
| | | long createdTime; |
| | | boolean isOutbound = false; // 是否为出片任务(false=进片,true=出片) |
| | | } |
| | | |
| | | /** |
| | | * 应用关闭时清理资源 |
| | | */ |
| | | @PreDestroy |
| | | public void destroy() { |
| | | log.info("正在关闭大车监控线程池..."); |
| | | |
| | | // 停止所有监控任务 |
| | | for (String deviceId : new ArrayList<>(monitoringTasks.keySet())) { |
| | | stopStateMonitoring(deviceId); |
| | | } |
| | | for (String deviceId : new ArrayList<>(idleMonitoringTasks.keySet())) { |
| | | ScheduledFuture<?> future = idleMonitoringTasks.remove(deviceId); |
| | | if (future != null) future.cancel(false); |
| | | } |
| | | for (String deviceId : new ArrayList<>(taskMonitoringTasks.keySet())) { |
| | | ScheduledFuture<?> future = taskMonitoringTasks.remove(deviceId); |
| | | if (future != null) future.cancel(false); |
| | | } |
| | | |
| | | // 关闭线程池 |
| | | shutdownExecutor(stateMonitorExecutor, "状态监控"); |
| | | shutdownExecutor(idleMonitorExecutor, "空闲监控"); |
| | | shutdownExecutor(taskMonitorExecutor, "任务监控"); |
| | | |
| | | log.info("大车监控线程池已关闭"); |
| | | } |
| | | |
| | | private void shutdownExecutor(ScheduledExecutorService executor, String name) { |
| | | executor.shutdown(); |
| | | try { |
| | | if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { |
| | | executor.shutdownNow(); |
| | | if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { |
| | | log.warn("{}线程池未能正常关闭", name); |
| | | } |
| | | } |
| | | } catch (InterruptedException e) { |
| | | executor.shutdownNow(); |
| | | Thread.currentThread().interrupt(); |
| | | } |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.interaction.vehicle.model; |
| | | |
| | | import lombok.Data; |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | | |
| | | /** |
| | | * 车辆路径信息 |
| | | * |
| | | * @author mes |
| | | * @since 2025-01-XX |
| | | */ |
| | | @Data |
| | | public class VehiclePath { |
| | | /** |
| | | * 起始位置 |
| | | */ |
| | | private VehiclePosition startPosition; |
| | | |
| | | /** |
| | | * 目标位置 |
| | | */ |
| | | private VehiclePosition endPosition; |
| | | |
| | | /** |
| | | * 路径点列表(如果路径不是直线) |
| | | */ |
| | | private List<VehiclePosition> waypoints; |
| | | |
| | | /** |
| | | * 路径宽度(用于冲突检测) |
| | | */ |
| | | private Double pathWidth; |
| | | |
| | | public VehiclePath() { |
| | | this.waypoints = new ArrayList<>(); |
| | | } |
| | | |
| | | public VehiclePath(VehiclePosition start, VehiclePosition end) { |
| | | this.startPosition = start; |
| | | this.endPosition = end; |
| | | this.waypoints = new ArrayList<>(); |
| | | } |
| | | |
| | | /** |
| | | * 检查路径是否与另一条路径冲突 |
| | | */ |
| | | public boolean conflictsWith(VehiclePath other) { |
| | | if (other == null) { |
| | | return false; |
| | | } |
| | | |
| | | // 简单冲突检测:检查起点和终点是否重叠 |
| | | if (startPosition != null && other.startPosition != null) { |
| | | if (positionsOverlap(startPosition, other.startPosition)) { |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | if (endPosition != null && other.endPosition != null) { |
| | | if (positionsOverlap(endPosition, other.endPosition)) { |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | // TODO: 更复杂的路径交叉检测 |
| | | return false; |
| | | } |
| | | |
| | | private boolean positionsOverlap(VehiclePosition pos1, VehiclePosition pos2) { |
| | | if (pos1 == null || pos2 == null) { |
| | | return false; |
| | | } |
| | | |
| | | // 如果有坐标,使用坐标判断 |
| | | if (pos1.getX() != null && pos1.getY() != null && |
| | | pos2.getX() != null && pos2.getY() != null) { |
| | | double distance = pos1.distanceTo(pos2); |
| | | double threshold = (pathWidth != null ? pathWidth : 100.0) / 2.0; |
| | | return distance < threshold; |
| | | } |
| | | |
| | | // 如果有位置值,使用位置值判断 |
| | | if (pos1.getPositionValue() != null && pos2.getPositionValue() != null) { |
| | | return pos1.getPositionValue().equals(pos2.getPositionValue()); |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.interaction.vehicle.model; |
| | | |
| | | import lombok.Data; |
| | | |
| | | /** |
| | | * 车辆位置信息 |
| | | * |
| | | * @author huang |
| | | * @since 2025-11-21 |
| | | */ |
| | | @Data |
| | | public class VehiclePosition { |
| | | /** |
| | | * X坐标 |
| | | */ |
| | | private Double x; |
| | | |
| | | /** |
| | | * Y坐标 |
| | | */ |
| | | private Double y; |
| | | |
| | | /** |
| | | * Z坐标(如果需要) |
| | | */ |
| | | private Double z; |
| | | |
| | | /** |
| | | * 位置编码(如:POS1, POS2) |
| | | */ |
| | | private String positionCode; |
| | | |
| | | /** |
| | | * 位置值(PLC中的位置值) |
| | | */ |
| | | private Integer positionValue; |
| | | |
| | | public VehiclePosition() { |
| | | } |
| | | |
| | | public VehiclePosition(Double x, Double y) { |
| | | this.x = x; |
| | | this.y = y; |
| | | } |
| | | |
| | | public VehiclePosition(String positionCode, Integer positionValue) { |
| | | this.positionCode = positionCode; |
| | | this.positionValue = positionValue; |
| | | } |
| | | |
| | | /** |
| | | * 计算到目标位置的距离 |
| | | */ |
| | | public double distanceTo(VehiclePosition target) { |
| | | if (x == null || y == null || target.x == null || target.y == null) { |
| | | return 0.0; |
| | | } |
| | | double dx = target.x - x; |
| | | double dy = target.y - y; |
| | | return Math.sqrt(dx * dx + dy * dy); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.interaction.vehicle.model; |
| | | |
| | | /** |
| | | * 车辆状态枚举 |
| | | * |
| | | * @author mes |
| | | * @since 2025-01-XX |
| | | */ |
| | | public enum VehicleState { |
| | | /** |
| | | * 空闲 - 车辆可用,可以接受新任务 |
| | | */ |
| | | IDLE, |
| | | |
| | | /** |
| | | * 执行中 - 车辆正在执行任务,不能操作 |
| | | */ |
| | | EXECUTING, |
| | | |
| | | /** |
| | | * 等待 - 车辆在排队等待执行 |
| | | */ |
| | | WAITING, |
| | | |
| | | /** |
| | | * 错误 - 车辆出现错误 |
| | | */ |
| | | ERROR |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.interaction.vehicle.model; |
| | | |
| | | import lombok.Data; |
| | | import java.time.LocalDateTime; |
| | | |
| | | /** |
| | | * 车辆运行时状态 |
| | | * 每个设备实例有独立的状态对象 |
| | | * |
| | | * @author huang |
| | | * @since 2025-11-21 |
| | | */ |
| | | @Data |
| | | public class VehicleStatus { |
| | | /** |
| | | * 设备ID(对应DeviceConfig.deviceId) |
| | | */ |
| | | private String deviceId; |
| | | |
| | | /** |
| | | * 设备名称 |
| | | */ |
| | | private String deviceName; |
| | | |
| | | /** |
| | | * 当前状态 |
| | | */ |
| | | private VehicleState state; |
| | | |
| | | /** |
| | | * 当前位置 |
| | | */ |
| | | private VehiclePosition currentPosition; |
| | | |
| | | /** |
| | | * 目标位置 |
| | | */ |
| | | private VehiclePosition targetPosition; |
| | | |
| | | /** |
| | | * 当前速度 |
| | | */ |
| | | private Double speed; |
| | | |
| | | /** |
| | | * 任务开始时间 |
| | | */ |
| | | private LocalDateTime taskStartTime; |
| | | |
| | | /** |
| | | * 预计结束时间 |
| | | */ |
| | | private LocalDateTime estimatedEndTime; |
| | | |
| | | /** |
| | | * 当前任务 |
| | | */ |
| | | private VehicleTask currentTask; |
| | | |
| | | /** |
| | | * 最后更新时间 |
| | | */ |
| | | private LocalDateTime lastUpdateTime; |
| | | |
| | | public VehicleStatus() { |
| | | this.state = VehicleState.IDLE; |
| | | this.lastUpdateTime = LocalDateTime.now(); |
| | | } |
| | | |
| | | public VehicleStatus(String deviceId) { |
| | | this(); |
| | | this.deviceId = deviceId; |
| | | } |
| | | |
| | | public VehicleStatus(String deviceId, String deviceName) { |
| | | this(deviceId); |
| | | this.deviceName = deviceName; |
| | | } |
| | | |
| | | /** |
| | | * 更新状态 |
| | | */ |
| | | public void setState(VehicleState newState) { |
| | | this.state = newState; |
| | | this.lastUpdateTime = LocalDateTime.now(); |
| | | |
| | | if (newState == VehicleState.EXECUTING && currentTask != null) { |
| | | this.taskStartTime = LocalDateTime.now(); |
| | | currentTask.setStartTime(taskStartTime); |
| | | currentTask.calculateEstimatedEndTime(); |
| | | this.estimatedEndTime = currentTask.getEstimatedEndTime(); |
| | | } else if (newState == VehicleState.IDLE) { |
| | | this.taskStartTime = null; |
| | | this.estimatedEndTime = null; |
| | | this.currentTask = null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 检查是否可用 |
| | | */ |
| | | public boolean isAvailable() { |
| | | return state == VehicleState.IDLE; |
| | | } |
| | | |
| | | /** |
| | | * 检查是否正在执行 |
| | | */ |
| | | public boolean isExecuting() { |
| | | return state == VehicleState.EXECUTING; |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.interaction.vehicle.model; |
| | | |
| | | import lombok.Data; |
| | | import java.time.LocalDateTime; |
| | | |
| | | /** |
| | | * 车辆任务信息 |
| | | * |
| | | * @author mes |
| | | * @since 2025-01-XX |
| | | */ |
| | | @Data |
| | | public class VehicleTask { |
| | | /** |
| | | * 任务ID |
| | | */ |
| | | private String taskId; |
| | | |
| | | /** |
| | | * 任务名称 |
| | | */ |
| | | private String taskName; |
| | | |
| | | /** |
| | | * 操作类型(如:feedGlass, triggerRequest等) |
| | | */ |
| | | private String operation; |
| | | |
| | | /** |
| | | * 计划路径 |
| | | */ |
| | | private VehiclePath plannedPath; |
| | | |
| | | /** |
| | | * 任务开始时间 |
| | | */ |
| | | private LocalDateTime startTime; |
| | | |
| | | /** |
| | | * 预计结束时间 |
| | | */ |
| | | private LocalDateTime estimatedEndTime; |
| | | |
| | | /** |
| | | * 车辆速度(用于计算预计结束时间) |
| | | */ |
| | | private Double speed; |
| | | |
| | | /** |
| | | * 任务参数 |
| | | */ |
| | | private java.util.Map<String, Object> parameters; |
| | | |
| | | public VehicleTask() { |
| | | this.parameters = new java.util.HashMap<>(); |
| | | } |
| | | |
| | | /** |
| | | * 计算预计结束时间 |
| | | */ |
| | | public void calculateEstimatedEndTime() { |
| | | if (plannedPath == null || speed == null || speed <= 0) { |
| | | return; |
| | | } |
| | | |
| | | VehiclePosition start = plannedPath.getStartPosition(); |
| | | VehiclePosition end = plannedPath.getEndPosition(); |
| | | |
| | | if (start != null && end != null) { |
| | | double distance = start.distanceTo(end); |
| | | if (distance > 0) { |
| | | // 时间 = 距离 / 速度(秒) |
| | | long seconds = (long) (distance / speed); |
| | | estimatedEndTime = startTime != null ? |
| | | startTime.plusSeconds(seconds) : |
| | | LocalDateTime.now().plusSeconds(seconds); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.interaction.workstation.base; |
| | | |
| | | import com.fasterxml.jackson.core.JsonProcessingException; |
| | | import com.mes.device.entity.DeviceConfig; |
| | | import com.mes.device.service.DevicePlcOperationService; |
| | | import com.mes.device.vo.DevicePlcVO; |
| | | import com.mes.interaction.BaseDeviceLogicHandler; |
| | | import com.mes.interaction.workstation.config.WorkstationLogicConfig; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | |
| | | import java.util.HashMap; |
| | | import java.util.Map; |
| | | |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * 卧转立系列设备的通用处理器基类 |
| | | * 负责解析 workstation 配置、提供占位的执行模板,后续具体设备在子类中实现。 |
| | | * |
| | | * @author mes |
| | | * @since 2025-11-24 |
| | | */ |
| | | @Slf4j |
| | | public abstract class WorkstationBaseHandler extends BaseDeviceLogicHandler { |
| | | |
| | | protected WorkstationBaseHandler(DevicePlcOperationService devicePlcOperationService) { |
| | | super(devicePlcOperationService); |
| | | } |
| | | |
| | | /** |
| | | * 解析 workstation 相关配置 |
| | | */ |
| | | protected WorkstationLogicConfig parseWorkstationConfig(Map<String, Object> logicParams) { |
| | | WorkstationLogicConfig config = new WorkstationLogicConfig(); |
| | | if (logicParams == null) { |
| | | return config; |
| | | } |
| | | config.setScanIntervalMs(getLogicParam(logicParams, "scanIntervalMs", 10_000)); |
| | | config.setTransferDelayMs(getLogicParam(logicParams, "transferDelayMs", 30_000)); |
| | | config.setVehicleCapacity(getLogicParam(logicParams, "vehicleCapacity", 6000)); |
| | | config.setAutoAck(getLogicParam(logicParams, "autoAck", Boolean.TRUE)); |
| | | return config; |
| | | } |
| | | |
| | | /** |
| | | * 默认实现:提示尚未实现具体逻辑 |
| | | */ |
| | | @Override |
| | | protected DevicePlcVO.OperationResult doExecute(com.mes.device.entity.DeviceConfig deviceConfig, |
| | | String operation, |
| | | java.util.Map<String, Object> params, |
| | | java.util.Map<String, Object> logicParams) { |
| | | log.warn("当前设备逻辑尚未实现: deviceType={}, operation={}", deviceConfig.getDeviceType(), operation); |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .success(false) |
| | | .message("设备逻辑待实现: " + deviceConfig.getDeviceType()) |
| | | .build(); |
| | | } |
| | | |
| | | @Override |
| | | public String validateLogicParams(DeviceConfig deviceConfig) { |
| | | // 默认认为配置合法,具体校验交由子类实现 |
| | | return null; |
| | | } |
| | | |
| | | @Override |
| | | public String getDefaultLogicParams() { |
| | | Map<String, Object> defaults = new HashMap<>(); |
| | | defaults.put("scanIntervalMs", 10_000); |
| | | defaults.put("transferDelayMs", 30_000); |
| | | defaults.put("vehicleCapacity", 6_000); |
| | | defaults.put("autoAck", true); |
| | | try { |
| | | return objectMapper.writeValueAsString(defaults); |
| | | } catch (JsonProcessingException e) { |
| | | return "{\"scanIntervalMs\":10000,\"transferDelayMs\":30000,\"vehicleCapacity\":6000,\"autoAck\":true}"; |
| | | } |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.interaction.workstation.config; |
| | | |
| | | import lombok.Data; |
| | | |
| | | /** |
| | | * 卧转立相关设备的逻辑配置 |
| | | * 对应 extraParams.deviceLogic 中的字段 |
| | | */ |
| | | @Data |
| | | public class WorkstationLogicConfig { |
| | | |
| | | /** |
| | | * 扫码设备发送请求的时间间隔(毫秒) |
| | | */ |
| | | private Integer scanIntervalMs = 10_000; |
| | | |
| | | /** |
| | | * 卧转立到大车的运输时间(毫秒) |
| | | */ |
| | | private Integer transferDelayMs = 30_000; |
| | | |
| | | /** |
| | | * 可装载的最大宽度(mm) |
| | | */ |
| | | private Integer vehicleCapacity = 6_000; |
| | | |
| | | /** |
| | | * 是否自动确认 MES 发送的玻璃信息 |
| | | */ |
| | | private Boolean autoAck = Boolean.TRUE; |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.interaction.workstation.scanner.handler; |
| | | |
| | | import com.mes.device.entity.DeviceConfig; |
| | | import com.mes.device.entity.GlassInfo; |
| | | import com.mes.device.service.DevicePlcOperationService; |
| | | import com.mes.device.service.GlassInfoService; |
| | | import com.mes.device.vo.DevicePlcVO; |
| | | import com.mes.interaction.workstation.base.WorkstationBaseHandler; |
| | | import com.mes.interaction.workstation.config.WorkstationLogicConfig; |
| | | import com.mes.s7.enhanced.EnhancedS7Serializer; |
| | | import com.mes.s7.provider.S7SerializerProvider; |
| | | import com.mes.service.PlcDynamicDataService; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.stereotype.Component; |
| | | import org.springframework.util.StringUtils; |
| | | |
| | | import java.time.LocalDateTime; |
| | | import java.util.Arrays; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * 卧转立扫码设备逻辑处理器 |
| | | * 负责从MES写区读取玻璃尺寸,并落库 glass_info |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | | public class HorizontalScannerLogicHandler extends WorkstationBaseHandler { |
| | | |
| | | private static final List<String> MES_FIELDS = Arrays.asList("mesSend", "mesGlassId", "mesWidth", "mesHeight", "workLine"); |
| | | |
| | | private final PlcDynamicDataService plcDynamicDataService; |
| | | private final GlassInfoService glassInfoService; |
| | | private final S7SerializerProvider s7SerializerProvider; |
| | | |
| | | public HorizontalScannerLogicHandler(DevicePlcOperationService devicePlcOperationService, |
| | | PlcDynamicDataService plcDynamicDataService, |
| | | GlassInfoService glassInfoService, |
| | | S7SerializerProvider s7SerializerProvider) { |
| | | super(devicePlcOperationService); |
| | | this.plcDynamicDataService = plcDynamicDataService; |
| | | this.glassInfoService = glassInfoService; |
| | | this.s7SerializerProvider = s7SerializerProvider; |
| | | } |
| | | |
| | | @Override |
| | | public String getDeviceType() { |
| | | return DeviceConfig.DeviceType.WORKSTATION_SCANNER; |
| | | } |
| | | |
| | | @Override |
| | | protected DevicePlcVO.OperationResult doExecute(DeviceConfig deviceConfig, |
| | | String operation, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | WorkstationLogicConfig config = parseWorkstationConfig(logicParams); |
| | | EnhancedS7Serializer serializer = s7SerializerProvider.getSerializer(deviceConfig); |
| | | if (serializer == null) { |
| | | return buildResult(deviceConfig, operation, false, "获取PLC序列化器失败"); |
| | | } |
| | | |
| | | try { |
| | | log.debug("卧转立扫码读取MES写区: deviceId={}, scanInterval={}ms", |
| | | deviceConfig.getId(), config.getScanIntervalMs()); |
| | | Map<String, Object> mesData = plcDynamicDataService.readPlcData(deviceConfig, MES_FIELDS, serializer); |
| | | if (mesData == null || mesData.isEmpty()) { |
| | | return buildResult(deviceConfig, operation, false, "读取MES写区失败"); |
| | | } |
| | | |
| | | Integer mesSend = parseInteger(mesData.get("mesSend")); |
| | | if (mesSend == null || mesSend == 0) { |
| | | return buildResult(deviceConfig, operation, true, "暂无待处理的玻璃信息"); |
| | | } |
| | | |
| | | String glassId = parseString(mesData.get("mesGlassId")); |
| | | if (!StringUtils.hasText(glassId)) { |
| | | return buildResult(deviceConfig, operation, false, "MES写区未提供玻璃ID"); |
| | | } |
| | | |
| | | Integer longSide = convertDimension(parseInteger(mesData.get("mesWidth"))); |
| | | Integer shortSide = convertDimension(parseInteger(mesData.get("mesHeight"))); |
| | | Integer workLine = parseInteger(mesData.get("workLine")); |
| | | |
| | | GlassInfo glassInfo = buildGlassInfo(glassId, longSide, shortSide, workLine); |
| | | boolean saved = glassInfoService.saveOrUpdateGlassInfo(glassInfo); |
| | | if (!saved) { |
| | | return buildResult(deviceConfig, operation, false, "保存玻璃信息失败: " + glassId); |
| | | } |
| | | |
| | | // 读取到MES数据后,重置mesSend,避免重复消费 |
| | | plcDynamicDataService.writePlcField(deviceConfig, "mesSend", 0, serializer); |
| | | |
| | | String msg = String.format("玻璃[%s] 尺寸[%s x %s] 已接收并入库,workLine=%s", |
| | | glassId, |
| | | longSide != null ? longSide + "mm" : "-", |
| | | shortSide != null ? shortSide + "mm" : "-", |
| | | workLine != null ? workLine : "-"); |
| | | return buildResult(deviceConfig, operation, true, msg); |
| | | } catch (Exception e) { |
| | | log.error("卧转立扫码处理异常, deviceId={}", deviceConfig.getId(), e); |
| | | return buildResult(deviceConfig, operation, false, "处理异常: " + e.getMessage()); |
| | | } |
| | | } |
| | | |
| | | private GlassInfo buildGlassInfo(String glassId, Integer longSide, Integer shortSide, Integer workLine) { |
| | | GlassInfo glassInfo = new GlassInfo(); |
| | | glassInfo.setGlassId(glassId.trim()); |
| | | if (longSide != null) { |
| | | glassInfo.setGlassLength(longSide); |
| | | } |
| | | if (shortSide != null) { |
| | | glassInfo.setGlassWidth(shortSide); |
| | | } |
| | | glassInfo.setStatus(GlassInfo.Status.ACTIVE); |
| | | if (workLine != null) { |
| | | glassInfo.setDescription("workLine=" + workLine); |
| | | } |
| | | return glassInfo; |
| | | } |
| | | |
| | | private Integer parseInteger(Object value) { |
| | | if (value instanceof Number) { |
| | | return ((Number) value).intValue(); |
| | | } |
| | | if (value == null) { |
| | | return null; |
| | | } |
| | | try { |
| | | return Integer.parseInt(String.valueOf(value)); |
| | | } catch (NumberFormatException e) { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | private String parseString(Object value) { |
| | | return value == null ? null : String.valueOf(value).trim(); |
| | | } |
| | | |
| | | private Integer convertDimension(Integer raw) { |
| | | if (raw == null) { |
| | | return null; |
| | | } |
| | | return raw / 10; |
| | | } |
| | | |
| | | private DevicePlcVO.OperationResult buildResult(DeviceConfig deviceConfig, |
| | | String operation, |
| | | boolean success, |
| | | String message) { |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .deviceId(deviceConfig.getId()) |
| | | .deviceName(deviceConfig.getDeviceName()) |
| | | .deviceCode(deviceConfig.getDeviceCode()) |
| | | .projectId(deviceConfig.getProjectId() != null ? String.valueOf(deviceConfig.getProjectId()) : null) |
| | | .operation(operation) |
| | | .success(success) |
| | | .message(message) |
| | | .timestamp(LocalDateTime.now()) |
| | | .build(); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.mes.interaction.workstation.transfer.handler; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.mes.device.entity.DeviceConfig; |
| | | import com.mes.device.entity.GlassInfo; |
| | | import com.mes.device.mapper.DeviceGlassInfoMapper; |
| | | import com.mes.device.service.DevicePlcOperationService; |
| | | import com.mes.device.service.GlassInfoService; |
| | | import com.mes.device.vo.DevicePlcVO; |
| | | import com.mes.interaction.workstation.base.WorkstationBaseHandler; |
| | | import com.mes.interaction.workstation.config.WorkstationLogicConfig; |
| | | import com.mes.s7.enhanced.EnhancedS7Serializer; |
| | | import com.mes.s7.provider.S7SerializerProvider; |
| | | import com.mes.service.PlcDynamicDataService; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.beans.factory.annotation.Qualifier; |
| | | import org.springframework.stereotype.Component; |
| | | import org.springframework.util.StringUtils; |
| | | import javax.annotation.PreDestroy; |
| | | |
| | | import java.time.LocalDateTime; |
| | | import java.util.*; |
| | | import java.util.concurrent.*; |
| | | import java.util.concurrent.atomic.AtomicLong; |
| | | import java.util.stream.Collectors; |
| | | |
| | | /** |
| | | * 卧转立主体设备逻辑处理器 |
| | | * 负责玻璃缓冲、容量校验、批次组装、PLC写入等逻辑 |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | | public class HorizontalTransferLogicHandler extends WorkstationBaseHandler { |
| | | |
| | | private final PlcDynamicDataService plcDynamicDataService; |
| | | private final GlassInfoService glassInfoService; |
| | | private final S7SerializerProvider s7SerializerProvider; |
| | | |
| | | @Autowired(required = false) |
| | | private DeviceGlassInfoMapper glassInfoMapper; |
| | | |
| | | // 玻璃缓冲队列:deviceId -> 玻璃信息列表 |
| | | private final Map<String, List<GlassBufferItem>> glassBuffer = new ConcurrentHashMap<>(); |
| | | |
| | | // 最后扫码时间:deviceId -> 最后扫码时间戳 |
| | | private final Map<String, AtomicLong> lastScanTime = new ConcurrentHashMap<>(); |
| | | |
| | | // 监控任务:deviceId -> 监控任务 |
| | | private final Map<String, ScheduledFuture<?>> monitorTasks = new ConcurrentHashMap<>(); |
| | | |
| | | // 监控线程池 |
| | | private final ScheduledExecutorService monitorExecutor = Executors.newScheduledThreadPool(5, r -> { |
| | | Thread t = new Thread(r, "HorizontalTransferMonitor"); |
| | | t.setDaemon(true); |
| | | return t; |
| | | }); |
| | | |
| | | @Autowired |
| | | public HorizontalTransferLogicHandler(DevicePlcOperationService devicePlcOperationService, |
| | | PlcDynamicDataService plcDynamicDataService, |
| | | @Qualifier("deviceGlassInfoService") GlassInfoService glassInfoService, |
| | | S7SerializerProvider s7SerializerProvider) { |
| | | super(devicePlcOperationService); |
| | | this.plcDynamicDataService = plcDynamicDataService; |
| | | this.glassInfoService = glassInfoService; |
| | | this.s7SerializerProvider = s7SerializerProvider; |
| | | } |
| | | |
| | | @Override |
| | | public String getDeviceType() { |
| | | return DeviceConfig.DeviceType.WORKSTATION_TRANSFER; |
| | | } |
| | | |
| | | @Override |
| | | protected DevicePlcVO.OperationResult doExecute(DeviceConfig deviceConfig, |
| | | String operation, |
| | | Map<String, Object> params, |
| | | Map<String, Object> logicParams) { |
| | | WorkstationLogicConfig config = parseWorkstationConfig(logicParams); |
| | | |
| | | try { |
| | | switch (operation) { |
| | | case "checkAndProcess": |
| | | case "process": |
| | | return handleCheckAndProcess(deviceConfig, config, logicParams); |
| | | case "startMonitor": |
| | | return handleStartMonitor(deviceConfig, config, logicParams); |
| | | case "stopMonitor": |
| | | return handleStopMonitor(deviceConfig); |
| | | case "clearBuffer": |
| | | return handleClearBuffer(deviceConfig); |
| | | default: |
| | | return buildResult(deviceConfig, operation, false, |
| | | "不支持的操作: " + operation); |
| | | } |
| | | } catch (Exception e) { |
| | | log.error("卧转立主体处理异常: deviceId={}, operation={}", |
| | | deviceConfig.getId(), operation, e); |
| | | return buildResult(deviceConfig, operation, false, |
| | | "处理异常: " + e.getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 检查并处理玻璃批次 |
| | | * 从数据库读取最近扫码的玻璃,进行容量判断,组装批次,写入PLC |
| | | */ |
| | | private DevicePlcVO.OperationResult handleCheckAndProcess( |
| | | DeviceConfig deviceConfig, |
| | | WorkstationLogicConfig config, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | String deviceId = deviceConfig.getDeviceId(); |
| | | EnhancedS7Serializer serializer = s7SerializerProvider.getSerializer(deviceConfig); |
| | | if (serializer == null) { |
| | | return buildResult(deviceConfig, "checkAndProcess", false, |
| | | "获取PLC序列化器失败"); |
| | | } |
| | | |
| | | try { |
| | | // 1. 从数据库查询最近扫码的玻璃信息(最近1分钟内的记录) |
| | | List<GlassInfo> recentGlasses = queryRecentScannedGlasses(deviceConfig, logicParams); |
| | | if (recentGlasses.isEmpty()) { |
| | | return buildResult(deviceConfig, "checkAndProcess", true, |
| | | "暂无待处理的玻璃信息"); |
| | | } |
| | | |
| | | log.info("查询到最近扫码的玻璃: deviceId={}, count={}", |
| | | deviceId, recentGlasses.size()); |
| | | |
| | | // 2. 更新缓冲队列和最后扫码时间 |
| | | updateBuffer(deviceId, recentGlasses); |
| | | lastScanTime.put(deviceId, new AtomicLong(System.currentTimeMillis())); |
| | | |
| | | // 3. 检查是否需要立即处理(容量已满或30s内无新玻璃) |
| | | List<GlassBufferItem> buffer = glassBuffer.get(deviceId); |
| | | if (buffer == null || buffer.isEmpty()) { |
| | | return buildResult(deviceConfig, "checkAndProcess", true, |
| | | "缓冲队列为空"); |
| | | } |
| | | |
| | | // 4. 判断是否满足处理条件 |
| | | boolean shouldProcess = shouldProcessBatch(deviceId, buffer, config); |
| | | if (!shouldProcess) { |
| | | return buildResult(deviceConfig, "checkAndProcess", true, |
| | | "等待更多玻璃或30s超时"); |
| | | } |
| | | |
| | | // 5. 容量判断和批次组装 |
| | | List<GlassInfo> batch = assembleBatch(buffer, config.getVehicleCapacity()); |
| | | if (batch.isEmpty()) { |
| | | return buildResult(deviceConfig, "checkAndProcess", false, |
| | | "无法组装有效批次(容量不足)"); |
| | | } |
| | | |
| | | // 6. 写入PLC |
| | | DevicePlcVO.OperationResult writeResult = writeBatchToPlc( |
| | | deviceConfig, batch, serializer, logicParams); |
| | | |
| | | if (!Boolean.TRUE.equals(writeResult.getSuccess())) { |
| | | return writeResult; |
| | | } |
| | | |
| | | // 7. 从缓冲队列中移除已处理的玻璃 |
| | | removeProcessedGlasses(deviceId, batch); |
| | | |
| | | String msg = String.format("批次已写入PLC: glassCount=%d, glassIds=%s", |
| | | batch.size(), |
| | | batch.stream().map(GlassInfo::getGlassId).collect(Collectors.joining(","))); |
| | | return buildResult(deviceConfig, "checkAndProcess", true, msg); |
| | | |
| | | } catch (Exception e) { |
| | | log.error("检查并处理玻璃批次异常: deviceId={}", deviceId, e); |
| | | return buildResult(deviceConfig, "checkAndProcess", false, |
| | | "处理异常: " + e.getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 查询最近扫码的玻璃信息 |
| | | */ |
| | | private List<GlassInfo> queryRecentScannedGlasses( |
| | | DeviceConfig deviceConfig, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | if (glassInfoMapper == null) { |
| | | log.warn("GlassInfoMapper未注入,无法查询最近扫码的玻璃"); |
| | | return Collections.emptyList(); |
| | | } |
| | | |
| | | try { |
| | | // 从配置中获取workLine,用于过滤 |
| | | String workLine = getLogicParam(logicParams, "workLine", null); |
| | | |
| | | // 查询最近2分钟内的玻璃记录(扩大时间窗口,确保不遗漏) |
| | | Date twoMinutesAgo = new Date(System.currentTimeMillis() - 120000); |
| | | |
| | | LambdaQueryWrapper<GlassInfo> wrapper = new LambdaQueryWrapper<>(); |
| | | wrapper.eq(GlassInfo::getStatus, GlassInfo.Status.ACTIVE) |
| | | .ge(GlassInfo::getCreatedTime, twoMinutesAgo) |
| | | .orderByDesc(GlassInfo::getCreatedTime) |
| | | .last("LIMIT 20"); // 限制查询数量,避免过多 |
| | | |
| | | // 如果配置了workLine,则过滤description |
| | | if (workLine != null && !workLine.isEmpty()) { |
| | | wrapper.like(GlassInfo::getDescription, "workLine=" + workLine); |
| | | } |
| | | |
| | | List<GlassInfo> recentGlasses = glassInfoMapper.selectList(wrapper); |
| | | |
| | | log.debug("查询到最近扫码的玻璃: deviceId={}, workLine={}, count={}", |
| | | deviceConfig.getId(), workLine, recentGlasses.size()); |
| | | |
| | | return recentGlasses; |
| | | |
| | | } catch (Exception e) { |
| | | log.error("查询最近扫码的玻璃信息异常: deviceId={}", |
| | | deviceConfig.getId(), e); |
| | | return Collections.emptyList(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 更新缓冲队列 |
| | | */ |
| | | private void updateBuffer(String deviceId, List<GlassInfo> newGlasses) { |
| | | List<GlassBufferItem> buffer = glassBuffer.computeIfAbsent( |
| | | deviceId, k -> new CopyOnWriteArrayList<>()); |
| | | |
| | | Set<String> existingIds = buffer.stream() |
| | | .map(item -> item.glassInfo.getGlassId()) |
| | | .collect(Collectors.toSet()); |
| | | |
| | | for (GlassInfo glass : newGlasses) { |
| | | if (!existingIds.contains(glass.getGlassId())) { |
| | | buffer.add(new GlassBufferItem(glass, System.currentTimeMillis())); |
| | | log.debug("添加玻璃到缓冲队列: deviceId={}, glassId={}", |
| | | deviceId, glass.getGlassId()); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 判断是否应该处理批次 |
| | | */ |
| | | private boolean shouldProcessBatch(String deviceId, |
| | | List<GlassBufferItem> buffer, |
| | | WorkstationLogicConfig config) { |
| | | // 条件1:缓冲队列已满(达到容量限制) |
| | | int totalLength = buffer.stream() |
| | | .mapToInt(item -> item.glassInfo.getGlassLength() != null ? |
| | | item.glassInfo.getGlassLength() : 0) |
| | | .sum(); |
| | | if (totalLength >= config.getVehicleCapacity()) { |
| | | log.info("缓冲队列容量已满,触发批次处理: deviceId={}, totalLength={}, capacity={}", |
| | | deviceId, totalLength, config.getVehicleCapacity()); |
| | | return true; |
| | | } |
| | | |
| | | // 条件2:30s内无新玻璃扫码 |
| | | AtomicLong lastTime = lastScanTime.get(deviceId); |
| | | if (lastTime != null) { |
| | | long elapsed = System.currentTimeMillis() - lastTime.get(); |
| | | if (elapsed >= config.getTransferDelayMs()) { |
| | | log.info("30s内无新玻璃扫码,触发批次处理: deviceId={}, elapsed={}ms", |
| | | deviceId, elapsed); |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * 组装批次(容量判断) |
| | | */ |
| | | private List<GlassInfo> assembleBatch(List<GlassBufferItem> buffer, |
| | | int vehicleCapacity) { |
| | | List<GlassInfo> batch = new ArrayList<>(); |
| | | int usedLength = 0; |
| | | |
| | | for (GlassBufferItem item : buffer) { |
| | | GlassInfo glass = item.glassInfo; |
| | | int glassLength = glass.getGlassLength() != null ? |
| | | glass.getGlassLength() : 0; |
| | | |
| | | if (usedLength + glassLength <= vehicleCapacity && batch.size() < 6) { |
| | | batch.add(glass); |
| | | usedLength += glassLength; |
| | | } else { |
| | | break; |
| | | } |
| | | } |
| | | |
| | | return batch; |
| | | } |
| | | |
| | | /** |
| | | * 写入批次到PLC |
| | | */ |
| | | private DevicePlcVO.OperationResult writeBatchToPlc( |
| | | DeviceConfig deviceConfig, |
| | | List<GlassInfo> batch, |
| | | EnhancedS7Serializer serializer, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | Map<String, Object> payload = new HashMap<>(); |
| | | |
| | | // 写入玻璃ID(最多6个) |
| | | int count = Math.min(batch.size(), 6); |
| | | for (int i = 0; i < count; i++) { |
| | | String fieldName = "plcGlassId" + (i + 1); |
| | | payload.put(fieldName, batch.get(i).getGlassId()); |
| | | } |
| | | |
| | | // 写入玻璃数量 |
| | | payload.put("plcGlassCount", count); |
| | | |
| | | // 写入位置信息(如果有配置) |
| | | Integer inPosition = getLogicParam(logicParams, "inPosition", null); |
| | | if (inPosition != null) { |
| | | payload.put("inPosition", inPosition); |
| | | } |
| | | |
| | | // 写入请求字(触发大车) |
| | | payload.put("plcRequest", 1); |
| | | |
| | | try { |
| | | plcDynamicDataService.writePlcData(deviceConfig, payload, serializer); |
| | | log.info("批次已写入PLC: deviceId={}, glassCount={}", |
| | | deviceConfig.getId(), count); |
| | | return buildResult(deviceConfig, "writeBatchToPlc", true, |
| | | "批次写入成功"); |
| | | } catch (Exception e) { |
| | | log.error("写入批次到PLC失败: deviceId={}", deviceConfig.getId(), e); |
| | | return buildResult(deviceConfig, "writeBatchToPlc", false, |
| | | "写入失败: " + e.getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 从缓冲队列移除已处理的玻璃 |
| | | */ |
| | | private void removeProcessedGlasses(String deviceId, List<GlassInfo> processed) { |
| | | List<GlassBufferItem> buffer = glassBuffer.get(deviceId); |
| | | if (buffer == null) { |
| | | return; |
| | | } |
| | | |
| | | Set<String> processedIds = processed.stream() |
| | | .map(GlassInfo::getGlassId) |
| | | .collect(Collectors.toSet()); |
| | | |
| | | buffer.removeIf(item -> processedIds.contains(item.glassInfo.getGlassId())); |
| | | } |
| | | |
| | | /** |
| | | * 启动监控任务(定期检查并处理) |
| | | */ |
| | | private DevicePlcVO.OperationResult handleStartMonitor( |
| | | DeviceConfig deviceConfig, |
| | | WorkstationLogicConfig config, |
| | | Map<String, Object> logicParams) { |
| | | |
| | | String deviceId = deviceConfig.getDeviceId(); |
| | | |
| | | // 停止旧的监控任务 |
| | | handleStopMonitor(deviceConfig); |
| | | |
| | | // 获取监控间隔 |
| | | Integer monitorIntervalMs = getLogicParam(logicParams, "monitorIntervalMs", |
| | | config.getScanIntervalMs()); |
| | | |
| | | // 启动监控任务 |
| | | ScheduledFuture<?> future = monitorExecutor.scheduleWithFixedDelay(() -> { |
| | | try { |
| | | handleCheckAndProcess(deviceConfig, config, logicParams); |
| | | } catch (Exception e) { |
| | | log.error("监控任务执行异常: deviceId={}", deviceId, e); |
| | | } |
| | | }, monitorIntervalMs, monitorIntervalMs, TimeUnit.MILLISECONDS); |
| | | |
| | | monitorTasks.put(deviceId, future); |
| | | log.info("已启动卧转立监控任务: deviceId={}, interval={}ms", |
| | | deviceId, monitorIntervalMs); |
| | | |
| | | return buildResult(deviceConfig, "startMonitor", true, |
| | | "监控任务已启动"); |
| | | } |
| | | |
| | | /** |
| | | * 停止监控任务 |
| | | */ |
| | | private DevicePlcVO.OperationResult handleStopMonitor(DeviceConfig deviceConfig) { |
| | | String deviceId = deviceConfig.getDeviceId(); |
| | | ScheduledFuture<?> future = monitorTasks.remove(deviceId); |
| | | if (future != null && !future.isCancelled()) { |
| | | future.cancel(false); |
| | | log.info("已停止卧转立监控任务: deviceId={}", deviceId); |
| | | } |
| | | return buildResult(deviceConfig, "stopMonitor", true, "监控任务已停止"); |
| | | } |
| | | |
| | | /** |
| | | * 清空缓冲队列 |
| | | */ |
| | | private DevicePlcVO.OperationResult handleClearBuffer(DeviceConfig deviceConfig) { |
| | | String deviceId = deviceConfig.getDeviceId(); |
| | | glassBuffer.remove(deviceId); |
| | | lastScanTime.remove(deviceId); |
| | | log.info("已清空缓冲队列: deviceId={}", deviceId); |
| | | return buildResult(deviceConfig, "clearBuffer", true, "缓冲队列已清空"); |
| | | } |
| | | |
| | | /** |
| | | * 构建操作结果 |
| | | */ |
| | | private DevicePlcVO.OperationResult buildResult(DeviceConfig deviceConfig, |
| | | String operation, |
| | | boolean success, |
| | | String message) { |
| | | return DevicePlcVO.OperationResult.builder() |
| | | .deviceId(deviceConfig.getId()) |
| | | .deviceName(deviceConfig.getDeviceName()) |
| | | .deviceCode(deviceConfig.getDeviceCode()) |
| | | .projectId(deviceConfig.getProjectId() != null ? |
| | | String.valueOf(deviceConfig.getProjectId()) : null) |
| | | .operation(operation) |
| | | .success(success) |
| | | .message(message) |
| | | .timestamp(LocalDateTime.now()) |
| | | .build(); |
| | | } |
| | | |
| | | /** |
| | | * 应用关闭时清理资源 |
| | | */ |
| | | @PreDestroy |
| | | public void destroy() { |
| | | log.info("正在关闭卧转立监控线程池..."); |
| | | |
| | | // 停止所有监控任务 |
| | | for (String deviceId : new ArrayList<>(monitorTasks.keySet())) { |
| | | ScheduledFuture<?> future = monitorTasks.remove(deviceId); |
| | | if (future != null && !future.isCancelled()) { |
| | | future.cancel(false); |
| | | } |
| | | } |
| | | |
| | | // 关闭线程池 |
| | | monitorExecutor.shutdown(); |
| | | try { |
| | | if (!monitorExecutor.awaitTermination(5, TimeUnit.SECONDS)) { |
| | | monitorExecutor.shutdownNow(); |
| | | if (!monitorExecutor.awaitTermination(5, TimeUnit.SECONDS)) { |
| | | log.warn("卧转立监控线程池未能正常关闭"); |
| | | } |
| | | } |
| | | } catch (InterruptedException e) { |
| | | monitorExecutor.shutdownNow(); |
| | | Thread.currentThread().interrupt(); |
| | | } |
| | | |
| | | log.info("卧转立监控线程池已关闭"); |
| | | } |
| | | |
| | | /** |
| | | * 玻璃缓冲项 |
| | | */ |
| | | private static class GlassBufferItem { |
| | | final GlassInfo glassInfo; |
| | | final long timestamp; |
| | | |
| | | GlassBufferItem(GlassInfo glassInfo, long timestamp) { |
| | | this.glassInfo = glassInfo; |
| | | this.timestamp = timestamp; |
| | | } |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.mes.s7.provider; |
| | | |
| | | import com.github.xingshuangs.iot.protocol.s7.enums.EPlcType; |
| | | import com.github.xingshuangs.iot.protocol.s7.service.S7PLC; |
| | | import com.mes.device.entity.DeviceConfig; |
| | | import com.mes.s7.enhanced.EnhancedS7Serializer; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import java.util.Objects; |
| | | import java.util.concurrent.ConcurrentHashMap; |
| | | import java.util.concurrent.ConcurrentMap; |
| | | |
| | | /** |
| | | * 提供 S7 序列化器的公共工厂,避免各处重复创建/缓存 |
| | | * @author huang |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | | public class S7SerializerProvider { |
| | | |
| | | private final ConcurrentMap<String, EnhancedS7Serializer> serializerCache = new ConcurrentHashMap<>(); |
| | | |
| | | /** |
| | | * 获取或创建与设备绑定的 S7 序列化器 |
| | | */ |
| | | public EnhancedS7Serializer getSerializer(DeviceConfig deviceConfig) { |
| | | if (deviceConfig == null) { |
| | | log.error("设备配置为空,无法获取S7序列化器"); |
| | | return null; |
| | | } |
| | | |
| | | String cacheKey = buildCacheKey(deviceConfig); |
| | | return serializerCache.computeIfAbsent(cacheKey, key -> createSerializer(deviceConfig)); |
| | | } |
| | | |
| | | /** |
| | | * 清除指定设备对应的缓存 |
| | | */ |
| | | public void clear(DeviceConfig deviceConfig) { |
| | | if (deviceConfig == null) { |
| | | return; |
| | | } |
| | | serializerCache.remove(buildCacheKey(deviceConfig)); |
| | | } |
| | | |
| | | private String buildCacheKey(DeviceConfig deviceConfig) { |
| | | if (deviceConfig.getId() != null) { |
| | | return "device:" + deviceConfig.getId(); |
| | | } |
| | | if (deviceConfig.getDeviceCode() != null) { |
| | | return "device:" + deviceConfig.getDeviceCode(); |
| | | } |
| | | return "device:" + Objects.hash(deviceConfig); |
| | | } |
| | | |
| | | private EnhancedS7Serializer createSerializer(DeviceConfig deviceConfig) { |
| | | try { |
| | | EPlcType plcType = parsePlcType(deviceConfig.getPlcType()); |
| | | String plcIp = deviceConfig.getPlcIp(); |
| | | if (plcIp == null || plcIp.isEmpty()) { |
| | | log.warn("设备未配置PLC IP,使用默认值 192.168.10.21, deviceId={}", deviceConfig.getId()); |
| | | plcIp = "192.168.10.21"; |
| | | } |
| | | S7PLC s7Plc = new S7PLC(plcType, plcIp); |
| | | EnhancedS7Serializer serializer = EnhancedS7Serializer.newInstance(s7Plc); |
| | | if (serializer == null) { |
| | | log.error("创建EnhancedS7Serializer失败: deviceId={}, plcIp={}, plcType={}", |
| | | deviceConfig.getId(), plcIp, plcType); |
| | | } |
| | | return serializer; |
| | | } catch (Exception e) { |
| | | log.error("创建S7序列化器异常: deviceId={}", deviceConfig.getId(), e); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | private EPlcType parsePlcType(String plcTypeValue) { |
| | | if (plcTypeValue == null || plcTypeValue.isEmpty()) { |
| | | return EPlcType.S1200; |
| | | } |
| | | try { |
| | | return EPlcType.valueOf(plcTypeValue); |
| | | } catch (IllegalArgumentException e) { |
| | | log.warn("未知的PLC类型: {},使用默认类型S1200", plcTypeValue); |
| | | return EPlcType.S1200; |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | * @since 2025-01-XX |
| | | */ |
| | | @RestController |
| | | @RequestMapping("/api/plcSend/task/notification") |
| | | @RequestMapping("task/notification") |
| | | @Api(tags = "任务状态通知") |
| | | @RequiredArgsConstructor |
| | | @CrossOrigin(origins = "*", maxAge = 3600) |
| | | public class TaskStatusNotificationController { |
| | | |
| | | private final TaskStatusNotificationService notificationService; |
| | | |
| | | @GetMapping(value = "/sse", produces = "text/event-stream") |
| | | @ApiOperation("创建SSE连接,监听任务状态变化") |
| | | @CrossOrigin(origins = "*") |
| | | public SseEmitter createConnection(@RequestParam(required = false) String taskId) { |
| | | SseEmitter emitter = notificationService.createConnection(taskId); |
| | | if (emitter == null) { |
| | |
| | | return emitter; |
| | | } |
| | | |
| | | @RequestMapping(value = "/sse", method = RequestMethod.OPTIONS) |
| | | @CrossOrigin(origins = "*") |
| | | public void options() { |
| | | // 处理 OPTIONS 预检请求 |
| | | } |
| | | |
| | | @GetMapping(value = "/sse/all", produces = "text/event-stream") |
| | | @ApiOperation("创建SSE连接,监听所有任务状态变化") |
| | | @CrossOrigin(origins = "*") |
| | | public SseEmitter createConnectionForAllTasks() { |
| | | return createConnection(null); |
| | | } |
| | |
| | | switch (device.getDeviceType()) { |
| | | case DeviceConfig.DeviceType.LOAD_VEHICLE: |
| | | context.setLoadedGlassIds(glassIds); |
| | | // 数据传递:上大车 -> 下一个设备 |
| | | // 数据传递:大车设备 -> 下一个设备 |
| | | if (!CollectionUtils.isEmpty(glassIds)) { |
| | | Map<String, Object> transferData = new HashMap<>(); |
| | | transferData.put("glassIds", glassIds); |
| | |
| | | import com.mes.task.service.TaskStatusNotificationService; |
| | | import lombok.RequiredArgsConstructor; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.scheduling.annotation.Async; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | import org.springframework.util.CollectionUtils; |
| | |
| | | throw new IllegalArgumentException("至少需要配置一条玻璃ID"); |
| | | } |
| | | |
| | | // 创建任务记录 |
| | | MultiDeviceTask task = new MultiDeviceTask(); |
| | | task.setTaskId(generateTaskId(groupConfig)); |
| | | task.setGroupId(String.valueOf(groupConfig.getId())); |
| | |
| | | task.setStartTime(new Date()); |
| | | save(task); |
| | | |
| | | // 异步执行任务,立即返回任务ID |
| | | executeTaskAsync(task, groupConfig, devices, parameters); |
| | | |
| | | log.info("设备组任务已启动(异步执行): taskId={}, groupId={}, groupName={}", |
| | | task.getTaskId(), groupConfig.getId(), groupConfig.getGroupName()); |
| | | |
| | | return task; |
| | | } |
| | | |
| | | /** |
| | | * 异步执行设备组任务 |
| | | * 每个设备组作为独立线程执行,互不阻塞 |
| | | */ |
| | | @Async("deviceGroupTaskExecutor") |
| | | public void executeTaskAsync(MultiDeviceTask task, |
| | | DeviceGroupConfig groupConfig, |
| | | List<DeviceConfig> devices, |
| | | TaskParameters parameters) { |
| | | try { |
| | | log.info("开始执行设备组任务: taskId={}, groupId={}, deviceCount={}", |
| | | task.getTaskId(), groupConfig.getId(), devices.size()); |
| | | |
| | | // 更新任务状态为运行中 |
| | | task.setStatus(MultiDeviceTask.Status.RUNNING.name()); |
| | | updateById(task); |
| | | |
| | | // 通知任务开始 |
| | | notificationService.notifyTaskStatus(task); |
| | | |
| | | // 执行任务 |
| | | TaskExecutionResult result = taskExecutionEngine.execute(task, groupConfig, devices, parameters); |
| | | |
| | | // 更新任务结果 |
| | | task.setStatus(result.isSuccess() ? MultiDeviceTask.Status.COMPLETED.name() : MultiDeviceTask.Status.FAILED.name()); |
| | | task.setErrorMessage(result.isSuccess() ? null : result.getMessage()); |
| | | task.setEndTime(new Date()); |
| | |
| | | // 通知任务完成 |
| | | notificationService.notifyTaskStatus(task); |
| | | |
| | | return task; |
| | | log.info("设备组任务执行完成: taskId={}, success={}, message={}", |
| | | task.getTaskId(), result.isSuccess(), result.getMessage()); |
| | | |
| | | } catch (Exception ex) { |
| | | log.error("多设备任务执行异常, taskId={}", task.getTaskId(), ex); |
| | | log.error("设备组任务执行异常: taskId={}, groupId={}", task.getTaskId(), groupConfig.getId(), ex); |
| | | |
| | | // 更新任务状态为失败 |
| | | task.setStatus(MultiDeviceTask.Status.FAILED.name()); |
| | | task.setErrorMessage(ex.getMessage()); |
| | | task.setEndTime(new Date()); |
| | | updateById(task); |
| | | throw new RuntimeException("多设备任务执行失败: " + ex.getMessage(), ex); |
| | | |
| | | // 通知任务失败 |
| | | notificationService.notifyTaskStatus(task); |
| | | } |
| | | } |
| | | |
| | |
| | | // export const WebSocketHost = "10.153.19.150"; |
| | | // export const WebSocketHost = "172.17.2.7"; |
| | | export const WebSocketHost = "10.153.19.49";//hxl |
| | | export const WebSocketHost = "10.153.19.225";//hxl |
| | | // export const WebSocketHost = "10.153.19.2";//zt |
| | | //export const WebSocketHost = "10.153.19.20";//wsx |
| | | // export const WebSocketHost = "127.0.0.1"; |
| | |
| | | pageNum: pagination.page, |
| | | pageSize: pagination.size, |
| | | deviceType: searchForm.deviceType || undefined, |
| | | status: searchForm.deviceStatus || undefined, |
| | | deviceCode: searchForm.keyword || undefined |
| | | deviceStatus: searchForm.deviceStatus || undefined, |
| | | keyword: searchForm.keyword?.trim() || undefined |
| | | } |
| | | |
| | | const response = await deviceConfigApi.getList(params) |
| | |
| | | |
| | | <el-form-item label="设备类型" prop="deviceType"> |
| | | <el-select v-model="deviceForm.deviceType" placeholder="选择设备类型" style="width: 100%;"> |
| | | <el-option label="上大车" value="上大车" /> |
| | | <el-option label="大理片" value="大理片" /> |
| | | <el-option label="玻璃存储" value="玻璃存储" /> |
| | | <el-option label="大车设备" value="大车设备" /> |
| | | <el-option label="大理片笼" value="大理片笼" /> |
| | | <el-option label="卧转立扫码" value="卧转立扫码" /> |
| | | <el-option label="卧转立" value="卧转立" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | |
| | |
| | | <el-select v-model="deviceForm.plcType" placeholder="选择PLC类型" style="width: 100%;" clearable> |
| | | <el-option label="西门子 S7-1200" value="S1200" /> |
| | | <el-option label="西门子 S7-1500" value="S1500" /> |
| | | <el-option label="西门子 S7-400" value="S400" /> |
| | | <el-option label="西门子 S7-300" value="S300" /> |
| | | <el-option label="西门子 S7-200" value="S200" /> |
| | | <el-option label="西门子 S7-200 SMART" value="S200_SMART" /> |
| | | <el-option label="Modbus 控制器" value="MODBUS" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | |
| | |
| | | <span class="form-tip">根据设备类型配置特定的业务逻辑参数</span> |
| | | </template> |
| | | |
| | | <!-- 上大车设备逻辑配置 --> |
| | | <div v-if="deviceForm.deviceType === '上大车'"> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="车辆容量"> |
| | | <el-input-number |
| | | v-model="deviceLogicParams.vehicleCapacity" |
| | | :min="1" |
| | | :max="10000" |
| | | :step="100" |
| | | style="width: 100%;" |
| | | <!-- 使用动态组件加载对应设备类型的配置组件 --> |
| | | <component |
| | | :is="deviceConfigComponent" |
| | | v-if="deviceConfigComponent" |
| | | v-model="deviceLogicParams" |
| | | /> |
| | | <span class="form-tip">车辆最大容量</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="玻璃间隔(ms)"> |
| | | <el-input-number |
| | | v-model="deviceLogicParams.glassIntervalMs" |
| | | :min="100" |
| | | :max="10000" |
| | | :step="100" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">玻璃上料间隔时间(毫秒)</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="默认玻璃长度(mm)"> |
| | | <el-input-number |
| | | v-model="deviceLogicParams.defaultGlassLength" |
| | | :min="100" |
| | | :max="10000" |
| | | :step="100" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">当玻璃未提供长度时使用的默认值</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="自动上料"> |
| | | <el-switch v-model="deviceLogicParams.autoFeed" /> |
| | | <span class="form-tip">是否自动触发上料请求</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="最大重试次数"> |
| | | <el-input-number |
| | | v-model="deviceLogicParams.maxRetryCount" |
| | | :min="0" |
| | | :max="10" |
| | | :step="1" |
| | | style="width: 100%;" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-form-item label="位置映射"> |
| | | <div class="position-mapping"> |
| | | <div |
| | | v-for="(value, key, index) in deviceLogicParams.positionMapping" |
| | | :key="index" |
| | | class="mapping-item" |
| | | > |
| | | <el-input |
| | | v-model="mappingKeys[index]" |
| | | placeholder="位置代码" |
| | | size="small" |
| | | style="width: 150px; margin-right: 10px;" |
| | | @input="updatePositionMapping(index, $event, value)" |
| | | /> |
| | | <el-input-number |
| | | v-model="deviceLogicParams.positionMapping[mappingKeys[index] || key]" |
| | | :min="0" |
| | | :max="100" |
| | | size="small" |
| | | style="width: 120px; margin-right: 10px;" |
| | | /> |
| | | <el-button |
| | | type="danger" |
| | | size="small" |
| | | @click="removePositionMapping(key)" |
| | | > |
| | | 删除 |
| | | </el-button> |
| | | </div> |
| | | <el-button type="primary" size="small" @click="addPositionMapping"> |
| | | 添加位置映射 |
| | | </el-button> |
| | | </div> |
| | | </el-form-item> |
| | | </div> |
| | | |
| | | <!-- 大理片设备逻辑配置 --> |
| | | <div v-if="deviceForm.deviceType === '大理片'"> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="玻璃尺寸"> |
| | | <el-input-number |
| | | v-model="deviceLogicParams.glassSize" |
| | | :min="100" |
| | | :max="5000" |
| | | :step="100" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">玻璃尺寸(mm)</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="处理时间(ms)"> |
| | | <el-input-number |
| | | v-model="deviceLogicParams.processingTime" |
| | | :min="1000" |
| | | :max="60000" |
| | | :step="1000" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">玻璃处理时间(毫秒)</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="自动处理"> |
| | | <el-switch v-model="deviceLogicParams.autoProcess" /> |
| | | <span class="form-tip">是否自动触发处理请求</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="最大重试次数"> |
| | | <el-input-number |
| | | v-model="deviceLogicParams.maxRetryCount" |
| | | :min="0" |
| | | :max="10" |
| | | :step="1" |
| | | style="width: 100%;" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </div> |
| | | |
| | | <!-- 玻璃存储设备逻辑配置 --> |
| | | <div v-if="deviceForm.deviceType === '玻璃存储'"> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="存储容量"> |
| | | <el-input-number |
| | | v-model="deviceLogicParams.storageCapacity" |
| | | :min="1" |
| | | :max="1000" |
| | | :step="1" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">最大存储数量</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="取货模式"> |
| | | <el-select v-model="deviceLogicParams.retrievalMode" style="width: 100%;"> |
| | | <el-option label="先进先出 (FIFO)" value="FIFO" /> |
| | | <el-option label="后进先出 (LIFO)" value="LIFO" /> |
| | | <el-option label="随机 (RANDOM)" value="RANDOM" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="自动存储"> |
| | | <el-switch v-model="deviceLogicParams.autoStore" /> |
| | | <span class="form-tip">是否自动触发存储请求</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="自动取货"> |
| | | <el-switch v-model="deviceLogicParams.autoRetrieve" /> |
| | | <span class="form-tip">是否自动触发取货请求</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="最大重试次数"> |
| | | <el-input-number |
| | | v-model="deviceLogicParams.maxRetryCount" |
| | | :min="0" |
| | | :max="10" |
| | | :step="1" |
| | | style="width: 100%;" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <div v-else class="no-config-tip"> |
| | | <el-alert |
| | | :title="`设备类型「${deviceForm.deviceType}」暂无配置组件`" |
| | | type="info" |
| | | :closable="false" |
| | | show-icon |
| | | /> |
| | | </div> |
| | | </el-card> |
| | | |
| | |
| | | import { ref, reactive, watch, computed } from 'vue' |
| | | import { ElMessage } from 'element-plus' |
| | | import { deviceConfigApi } from '@/api/device/deviceManagement' |
| | | import { getDeviceConfigComponent } from './components/DeviceLogicConfig' |
| | | |
| | | // Props定义 |
| | | const props = defineProps({ |
| | |
| | | const testResult = ref(null) |
| | | |
| | | // 设备逻辑参数(根据设备类型动态显示) |
| | | const deviceLogicParams = reactive({ |
| | | // 上大车参数 |
| | | vehicleCapacity: 6000, |
| | | glassIntervalMs: 1000, |
| | | defaultGlassLength: 2000, |
| | | autoFeed: true, |
| | | maxRetryCount: 5, |
| | | positionMapping: {}, |
| | | // 大理片参数 |
| | | glassSize: 2000, |
| | | processingTime: 5000, |
| | | autoProcess: true, |
| | | // 玻璃存储参数 |
| | | storageCapacity: 100, |
| | | retrievalMode: 'FIFO', |
| | | autoStore: true, |
| | | autoRetrieve: true |
| | | }) |
| | | const deviceLogicParams = reactive({}) |
| | | |
| | | // 位置映射的键数组(用于v-for) |
| | | const mappingKeys = ref([]) |
| | | const S7_PLC_TYPES = ['S1200', 'S1500'] |
| | | const MODBUS_PLC_TYPES = ['MODBUS'] |
| | | |
| | | // 计算属性:根据设备类型获取对应的配置组件 |
| | | const deviceConfigComponent = computed(() => { |
| | | if (!deviceForm.deviceType) { |
| | | return null |
| | | } |
| | | return getDeviceConfigComponent(deviceForm.deviceType) |
| | | }) |
| | | |
| | | // 设备表单数据 |
| | | const getDefaultForm = () => ({ |
| | |
| | | |
| | | // 监听PLC类型变化,自动设置通讯协议 |
| | | watch(() => deviceForm.plcType, (newPlcType) => { |
| | | // 如果选择的是S7系列PLC,自动设置通讯协议为S7 Communication |
| | | if (newPlcType && (newPlcType.startsWith('S') || newPlcType.includes('S7'))) { |
| | | if (!deviceForm.protocolType || deviceForm.protocolType === '其他') { |
| | | if (!newPlcType) { |
| | | return |
| | | } |
| | | |
| | | if (S7_PLC_TYPES.includes(newPlcType)) { |
| | | if (!deviceForm.protocolType || deviceForm.protocolType === '其他' || deviceForm.protocolType === 'Modbus TCP') { |
| | | deviceForm.protocolType = 'S7 Communication' |
| | | } |
| | | return |
| | | } |
| | | |
| | | if (MODBUS_PLC_TYPES.includes(newPlcType)) { |
| | | if (!deviceForm.protocolType || deviceForm.protocolType === '其他' || deviceForm.protocolType === 'S7 Communication') { |
| | | deviceForm.protocolType = 'Modbus TCP' |
| | | } |
| | | } |
| | | }) |
| | | |
| | | // 处理通讯协议变化 |
| | | const handleProtocolTypeChange = (value) => { |
| | | // 如果选择了非S7协议,但PLC类型是S7系列,给出提示 |
| | | if (value && value !== 'S7 Communication' && deviceForm.plcType) { |
| | | const s7Types = ['S1200', 'S1500', 'S400', 'S300', 'S200', 'S200_SMART'] |
| | | if (s7Types.includes(deviceForm.plcType)) { |
| | | ElMessage.warning('S7系列PLC通常使用S7 Communication协议,请确认协议选择是否正确') |
| | | } |
| | | if (!deviceForm.plcType || !value) { |
| | | return |
| | | } |
| | | |
| | | if (value !== 'S7 Communication' && S7_PLC_TYPES.includes(deviceForm.plcType)) { |
| | | ElMessage.warning('S7系列PLC通常使用S7 Communication协议,请确认协议选择是否正确') |
| | | return |
| | | } |
| | | |
| | | if (value !== 'Modbus TCP' && MODBUS_PLC_TYPES.includes(deviceForm.plcType)) { |
| | | ElMessage.warning('Modbus 类型PLC通常使用 Modbus TCP 协议,请确认协议选择是否正确') |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | // 加载设备逻辑参数 |
| | | const loadDeviceLogicParams = (deviceLogic, deviceType) => { |
| | | if (deviceType === '上大车') { |
| | | deviceLogicParams.vehicleCapacity = deviceLogic.vehicleCapacity ?? 6000 |
| | | deviceLogicParams.glassIntervalMs = deviceLogic.glassIntervalMs ?? 1000 |
| | | deviceLogicParams.defaultGlassLength = deviceLogic.defaultGlassLength ?? 2000 |
| | | deviceLogicParams.autoFeed = deviceLogic.autoFeed ?? true |
| | | deviceLogicParams.maxRetryCount = deviceLogic.maxRetryCount ?? 5 |
| | | deviceLogicParams.positionMapping = deviceLogic.positionMapping || {} |
| | | mappingKeys.value = Object.keys(deviceLogicParams.positionMapping) |
| | | } else if (deviceType === '大理片') { |
| | | deviceLogicParams.glassSize = deviceLogic.glassSize ?? 2000 |
| | | deviceLogicParams.processingTime = deviceLogic.processingTime ?? 5000 |
| | | deviceLogicParams.autoProcess = deviceLogic.autoProcess ?? true |
| | | deviceLogicParams.maxRetryCount = deviceLogic.maxRetryCount ?? 3 |
| | | } else if (deviceType === '玻璃存储') { |
| | | deviceLogicParams.storageCapacity = deviceLogic.storageCapacity ?? 100 |
| | | deviceLogicParams.retrievalMode = deviceLogic.retrievalMode || 'FIFO' |
| | | deviceLogicParams.autoStore = deviceLogic.autoStore ?? true |
| | | deviceLogicParams.autoRetrieve = deviceLogic.autoRetrieve ?? true |
| | | deviceLogicParams.maxRetryCount = deviceLogic.maxRetryCount ?? 3 |
| | | // 清空现有参数 |
| | | Object.keys(deviceLogicParams).forEach(key => { |
| | | delete deviceLogicParams[key] |
| | | }) |
| | | |
| | | // 根据设备类型加载对应的参数 |
| | | if (deviceLogic && Object.keys(deviceLogic).length > 0) { |
| | | Object.assign(deviceLogicParams, deviceLogic) |
| | | } |
| | | } |
| | | |
| | | // 位置映射相关方法 |
| | | const addPositionMapping = () => { |
| | | const newKey = `POS${Object.keys(deviceLogicParams.positionMapping).length + 1}` |
| | | deviceLogicParams.positionMapping[newKey] = 1 |
| | | mappingKeys.value.push(newKey) |
| | | } |
| | | |
| | | const removePositionMapping = (key) => { |
| | | delete deviceLogicParams.positionMapping[key] |
| | | mappingKeys.value = mappingKeys.value.filter(k => k !== key) |
| | | } |
| | | |
| | | const updatePositionMapping = (index, newKey, oldValue) => { |
| | | const oldKey = mappingKeys.value[index] |
| | | if (oldKey && oldKey !== newKey) { |
| | | delete deviceLogicParams.positionMapping[oldKey] |
| | | } |
| | | mappingKeys.value[index] = newKey |
| | | if (newKey) { |
| | | deviceLogicParams.positionMapping[newKey] = oldValue || 1 |
| | | } |
| | | } |
| | | |
| | | const resetForm = () => { |
| | | Object.assign(deviceForm, getDefaultForm()) |
| | | deviceFormRef.value?.clearValidate() |
| | | |
| | | // 重置设备逻辑参数 |
| | | deviceLogicParams.vehicleCapacity = 6000 |
| | | deviceLogicParams.glassIntervalMs = 1000 |
| | | deviceLogicParams.defaultGlassLength = 2000 |
| | | deviceLogicParams.autoFeed = true |
| | | deviceLogicParams.maxRetryCount = 5 |
| | | deviceLogicParams.positionMapping = {} |
| | | mappingKeys.value = [] |
| | | |
| | | deviceLogicParams.glassSize = 2000 |
| | | deviceLogicParams.processingTime = 5000 |
| | | deviceLogicParams.autoProcess = true |
| | | |
| | | deviceLogicParams.storageCapacity = 100 |
| | | deviceLogicParams.retrievalMode = 'FIFO' |
| | | deviceLogicParams.autoStore = true |
| | | deviceLogicParams.autoRetrieve = true |
| | | Object.keys(deviceLogicParams).forEach(key => { |
| | | delete deviceLogicParams[key] |
| | | }) |
| | | } |
| | | |
| | | const addConfigParam = () => { |
| | |
| | | plcType: deviceForm.plcType |
| | | } |
| | | |
| | | // 保存设备逻辑参数 |
| | | const deviceLogic = {} |
| | | if (deviceForm.deviceType === '上大车') { |
| | | deviceLogic.vehicleCapacity = deviceLogicParams.vehicleCapacity |
| | | deviceLogic.glassIntervalMs = deviceLogicParams.glassIntervalMs |
| | | deviceLogic.defaultGlassLength = deviceLogicParams.defaultGlassLength |
| | | deviceLogic.autoFeed = deviceLogicParams.autoFeed |
| | | deviceLogic.maxRetryCount = deviceLogicParams.maxRetryCount |
| | | deviceLogic.positionMapping = deviceLogicParams.positionMapping |
| | | } else if (deviceForm.deviceType === '大理片') { |
| | | deviceLogic.glassSize = deviceLogicParams.glassSize |
| | | deviceLogic.processingTime = deviceLogicParams.processingTime |
| | | deviceLogic.autoProcess = deviceLogicParams.autoProcess |
| | | deviceLogic.maxRetryCount = deviceLogicParams.maxRetryCount |
| | | } else if (deviceForm.deviceType === '玻璃存储') { |
| | | deviceLogic.storageCapacity = deviceLogicParams.storageCapacity |
| | | deviceLogic.retrievalMode = deviceLogicParams.retrievalMode |
| | | deviceLogic.autoStore = deviceLogicParams.autoStore |
| | | deviceLogic.autoRetrieve = deviceLogicParams.autoRetrieve |
| | | deviceLogic.maxRetryCount = deviceLogicParams.maxRetryCount |
| | | } |
| | | |
| | | if (Object.keys(deviceLogic).length > 0) { |
| | | extraObj.deviceLogic = deviceLogic |
| | | // 保存设备逻辑参数(直接使用deviceLogicParams,由各个配置组件管理) |
| | | if (deviceLogicParams && Object.keys(deviceLogicParams).length > 0) { |
| | | extraObj.deviceLogic = { ...deviceLogicParams } |
| | | } |
| | | |
| | | // 构建 configJson:将 configParams 数组转换为 JSON 字符串 |
| | |
| | | border-radius: 6px; |
| | | background-color: #fafafa; |
| | | } |
| | | |
| | | .no-config-tip { |
| | | padding: 20px; |
| | | } |
| | | </style> |
| New file |
| | |
| | | <template> |
| | | <div class="large-glass-config"> |
| | | <el-form-item label="格子范围配置"> |
| | | <div class="grid-ranges"> |
| | | <div |
| | | v-for="(range, index) in config.gridRanges" |
| | | :key="index" |
| | | class="grid-range-item" |
| | | > |
| | | <el-input-number |
| | | v-model="range.row" |
| | | :min="1" |
| | | :max="100" |
| | | :step="1" |
| | | style="width: 100px; margin-right: 10px;" |
| | | placeholder="行号" |
| | | /> |
| | | <span>行:</span> |
| | | <el-input-number |
| | | v-model="range.start" |
| | | :min="1" |
| | | :max="1000" |
| | | :step="1" |
| | | style="width: 120px; margin: 0 10px;" |
| | | placeholder="起始格子" |
| | | /> |
| | | <span>~</span> |
| | | <el-input-number |
| | | v-model="range.end" |
| | | :min="1" |
| | | :max="1000" |
| | | :step="1" |
| | | style="width: 120px; margin-left: 10px;" |
| | | placeholder="结束格子" |
| | | /> |
| | | <el-button |
| | | type="danger" |
| | | size="small" |
| | | style="margin-left: 10px;" |
| | | @click="removeGridRange(index)" |
| | | > |
| | | 删除 |
| | | </el-button> |
| | | </div> |
| | | <el-button type="primary" size="small" @click="addGridRange"> |
| | | 添加格子范围 |
| | | </el-button> |
| | | </div> |
| | | <span class="form-tip">配置每行的格子范围,例如:第一行1~52格,第二行53~101格</span> |
| | | </el-form-item> |
| | | |
| | | <el-row :gutter="20"> |
| | | <el-col :span="8"> |
| | | <el-form-item label="每格长度(mm)"> |
| | | <el-input-number |
| | | v-model="config.gridLength" |
| | | :min="100" |
| | | :max="10000" |
| | | :step="100" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">每格长度(毫米)</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="每格宽度(mm)"> |
| | | <el-input-number |
| | | v-model="config.gridWidth" |
| | | :min="100" |
| | | :max="10000" |
| | | :step="100" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">每格宽度(毫米)</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="每格厚度(mm)"> |
| | | <el-input-number |
| | | v-model="config.gridThickness" |
| | | :min="1" |
| | | :max="100" |
| | | :step="1" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">每格厚度(毫米)</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, watch } from 'vue' |
| | | |
| | | const props = defineProps({ |
| | | modelValue: { |
| | | type: Object, |
| | | default: () => ({}) |
| | | } |
| | | }) |
| | | |
| | | const emit = defineEmits(['update:modelValue']) |
| | | |
| | | // 配置数据 |
| | | const config = ref({ |
| | | gridRanges: [ |
| | | { row: 1, start: 1, end: 52 }, |
| | | { row: 2, start: 53, end: 101 } |
| | | ], |
| | | gridLength: 2000, |
| | | gridWidth: 1500, |
| | | gridThickness: 5 |
| | | }) |
| | | |
| | | // 监听props变化 |
| | | watch(() => props.modelValue, (newVal) => { |
| | | if (newVal && Object.keys(newVal).length > 0) { |
| | | config.value = { |
| | | gridRanges: newVal.gridRanges || [ |
| | | { row: 1, start: 1, end: 52 }, |
| | | { row: 2, start: 53, end: 101 } |
| | | ], |
| | | gridLength: newVal.gridLength ?? 2000, |
| | | gridWidth: newVal.gridWidth ?? 1500, |
| | | gridThickness: newVal.gridThickness ?? 5 |
| | | } |
| | | } |
| | | }, { immediate: true, deep: true }) |
| | | |
| | | // 监听config变化,同步到父组件 |
| | | watch(config, (newVal) => { |
| | | emit('update:modelValue', { ...newVal }) |
| | | }, { deep: true }) |
| | | |
| | | // 格子范围相关方法 |
| | | const addGridRange = () => { |
| | | const maxRow = config.value.gridRanges.length > 0 |
| | | ? Math.max(...config.value.gridRanges.map(r => r.row)) |
| | | : 0 |
| | | const lastEnd = config.value.gridRanges.length > 0 |
| | | ? Math.max(...config.value.gridRanges.map(r => r.end)) |
| | | : 0 |
| | | config.value.gridRanges.push({ |
| | | row: maxRow + 1, |
| | | start: lastEnd + 1, |
| | | end: lastEnd + 50 |
| | | }) |
| | | } |
| | | |
| | | const removeGridRange = (index) => { |
| | | config.value.gridRanges.splice(index, 1) |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .form-tip { |
| | | margin-left: 10px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .grid-ranges { |
| | | width: 100%; |
| | | } |
| | | |
| | | .grid-range-item { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-bottom: 12px; |
| | | padding: 12px; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 6px; |
| | | background-color: #fafafa; |
| | | } |
| | | </style> |
| | | |
| New file |
| | |
| | | <template> |
| | | <div class="load-vehicle-config"> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="车辆容量(mm)"> |
| | | <el-input-number |
| | | v-model="config.vehicleCapacity" |
| | | :min="1" |
| | | :max="10000" |
| | | :step="100" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">车辆最大容量</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="车辆速度(格/秒)"> |
| | | <el-input-number |
| | | v-model="config.vehicleSpeed" |
| | | :min="0.1" |
| | | :max="10" |
| | | :step="0.1" |
| | | :precision="1" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">车辆运动速度,默认1格/秒</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="玻璃间隔(ms)"> |
| | | <el-input-number |
| | | v-model="config.glassIntervalMs" |
| | | :min="100" |
| | | :max="10000" |
| | | :step="100" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">玻璃上料间隔时间(毫秒)</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="默认玻璃长度(mm)"> |
| | | <el-input-number |
| | | v-model="config.defaultGlassLength" |
| | | :min="100" |
| | | :max="10000" |
| | | :step="100" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">当玻璃未提供长度时使用的默认值</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="初始位置(格)"> |
| | | <el-input-number |
| | | v-model="config.homePosition" |
| | | :min="0" |
| | | :max="1000" |
| | | :step="1" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">车辆初始位置(格子)</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="运动距离范围"> |
| | | <el-input-number |
| | | v-model="config.minRange" |
| | | :min="1" |
| | | :max="1000" |
| | | :step="1" |
| | | style="width: 48%;" |
| | | placeholder="最小" |
| | | /> |
| | | <span style="margin: 0 2%;">~</span> |
| | | <el-input-number |
| | | v-model="config.maxRange" |
| | | :min="1" |
| | | :max="1000" |
| | | :step="1" |
| | | style="width: 48%;" |
| | | placeholder="最大" |
| | | /> |
| | | <span class="form-tip">运动距离范围(格子)</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="空闲监控间隔(ms)"> |
| | | <el-input-number |
| | | v-model="config.idleMonitorIntervalMs" |
| | | :min="500" |
| | | :max="10000" |
| | | :step="100" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">空闲状态监控间隔,默认2000ms</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="任务监控间隔(ms)"> |
| | | <el-input-number |
| | | v-model="config.taskMonitorIntervalMs" |
| | | :min="500" |
| | | :max="10000" |
| | | :step="100" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">任务执行监控间隔,默认1000ms</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="MES确认超时(ms)"> |
| | | <el-input-number |
| | | v-model="config.mesConfirmTimeoutMs" |
| | | :min="5000" |
| | | :max="300000" |
| | | :step="1000" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">等待MES确认的超时时间,默认30000ms</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="自动上料"> |
| | | <el-switch v-model="config.autoFeed" /> |
| | | <span class="form-tip">是否自动触发上料请求</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="最大重试次数"> |
| | | <el-input-number |
| | | v-model="config.maxRetryCount" |
| | | :min="0" |
| | | :max="10" |
| | | :step="1" |
| | | style="width: 100%;" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <el-form-item label="位置映射"> |
| | | <div class="position-mapping"> |
| | | <div |
| | | v-for="(value, key, index) in config.positionMapping" |
| | | :key="index" |
| | | class="mapping-item" |
| | | > |
| | | <el-input |
| | | v-model="mappingKeys[index]" |
| | | placeholder="位置代码(如900/901)" |
| | | size="small" |
| | | style="width: 150px; margin-right: 10px;" |
| | | @input="updatePositionMapping(index, $event, value)" |
| | | /> |
| | | <el-input-number |
| | | v-model="config.positionMapping[mappingKeys[index] || key]" |
| | | :min="0" |
| | | :max="1000" |
| | | :step="1" |
| | | size="small" |
| | | style="width: 120px; margin-right: 10px;" |
| | | placeholder="位置值(格)" |
| | | /> |
| | | <el-button |
| | | type="danger" |
| | | size="small" |
| | | @click="removePositionMapping(key)" |
| | | > |
| | | 删除 |
| | | </el-button> |
| | | </div> |
| | | <el-button type="primary" size="small" @click="addPositionMapping"> |
| | | 添加位置映射 |
| | | </el-button> |
| | | </div> |
| | | <span class="form-tip">将MES编号(如900/901)映射为实际位置值(格子)</span> |
| | | </el-form-item> |
| | | |
| | | <el-form-item label="出片任务格子范围"> |
| | | <el-input-number |
| | | v-model="config.outboundSlotRanges[0]" |
| | | :min="1" |
| | | :max="1000" |
| | | :step="1" |
| | | style="width: 48%;" |
| | | placeholder="最小格子编号" |
| | | /> |
| | | <span style="margin: 0 2%;">~</span> |
| | | <el-input-number |
| | | v-model="config.outboundSlotRanges[1]" |
| | | :min="1" |
| | | :max="1000" |
| | | :step="1" |
| | | style="width: 48%;" |
| | | placeholder="最大格子编号" |
| | | /> |
| | | <span class="form-tip">出片任务的startSlot范围,例如[1, 101]表示格子1~101都是出片任务</span> |
| | | </el-form-item> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, watch } from 'vue' |
| | | |
| | | const props = defineProps({ |
| | | modelValue: { |
| | | type: Object, |
| | | default: () => ({}) |
| | | } |
| | | }) |
| | | |
| | | const emit = defineEmits(['update:modelValue']) |
| | | |
| | | // 配置数据 |
| | | const config = ref({ |
| | | vehicleCapacity: 6000, |
| | | vehicleSpeed: 1.0, |
| | | glassIntervalMs: 1000, |
| | | defaultGlassLength: 2000, |
| | | homePosition: 0, |
| | | minRange: 1, |
| | | maxRange: 100, |
| | | idleMonitorIntervalMs: 2000, |
| | | taskMonitorIntervalMs: 1000, |
| | | mesConfirmTimeoutMs: 30000, |
| | | autoFeed: true, |
| | | maxRetryCount: 5, |
| | | positionMapping: {}, |
| | | outboundSlotRanges: [1, 101] |
| | | }) |
| | | |
| | | // 位置映射的键数组 |
| | | const mappingKeys = ref([]) |
| | | |
| | | // 监听props变化 |
| | | watch(() => props.modelValue, (newVal) => { |
| | | if (newVal && Object.keys(newVal).length > 0) { |
| | | config.value = { |
| | | vehicleCapacity: newVal.vehicleCapacity ?? 6000, |
| | | vehicleSpeed: newVal.vehicleSpeed ?? 1.0, |
| | | glassIntervalMs: newVal.glassIntervalMs ?? 1000, |
| | | defaultGlassLength: newVal.defaultGlassLength ?? 2000, |
| | | homePosition: newVal.homePosition ?? 0, |
| | | minRange: newVal.minRange ?? 1, |
| | | maxRange: newVal.maxRange ?? 100, |
| | | idleMonitorIntervalMs: newVal.idleMonitorIntervalMs ?? 2000, |
| | | taskMonitorIntervalMs: newVal.taskMonitorIntervalMs ?? 1000, |
| | | mesConfirmTimeoutMs: newVal.mesConfirmTimeoutMs ?? 30000, |
| | | autoFeed: newVal.autoFeed ?? true, |
| | | maxRetryCount: newVal.maxRetryCount ?? 5, |
| | | positionMapping: newVal.positionMapping || {}, |
| | | outboundSlotRanges: newVal.outboundSlotRanges || [1, 101] |
| | | } |
| | | mappingKeys.value = Object.keys(config.value.positionMapping) |
| | | } |
| | | }, { immediate: true, deep: true }) |
| | | |
| | | // 监听config变化,同步到父组件 |
| | | watch(config, (newVal) => { |
| | | emit('update:modelValue', { ...newVal }) |
| | | }, { deep: true }) |
| | | |
| | | // 位置映射相关方法 |
| | | const addPositionMapping = () => { |
| | | const newKey = `POS${Object.keys(config.value.positionMapping).length + 1}` |
| | | config.value.positionMapping[newKey] = 1 |
| | | mappingKeys.value.push(newKey) |
| | | } |
| | | |
| | | const removePositionMapping = (key) => { |
| | | delete config.value.positionMapping[key] |
| | | mappingKeys.value = mappingKeys.value.filter(k => k !== key) |
| | | } |
| | | |
| | | const updatePositionMapping = (index, newKey, oldValue) => { |
| | | const oldKey = mappingKeys.value[index] |
| | | if (oldKey && oldKey !== newKey) { |
| | | delete config.value.positionMapping[oldKey] |
| | | } |
| | | mappingKeys.value[index] = newKey |
| | | if (newKey) { |
| | | config.value.positionMapping[newKey] = oldValue || 1 |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .form-tip { |
| | | margin-left: 10px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .position-mapping { |
| | | width: 100%; |
| | | } |
| | | |
| | | .mapping-item { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-bottom: 12px; |
| | | padding: 12px; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 6px; |
| | | background-color: #fafafa; |
| | | } |
| | | </style> |
| | | |
| New file |
| | |
| | | <template> |
| | | <div class="workstation-scanner-config"> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="扫码间隔(ms)"> |
| | | <el-input-number |
| | | v-model="config.scanIntervalMs" |
| | | :min="1000" |
| | | :max="60000" |
| | | :step="1000" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">定时扫描MES写区的时间间隔,默认10000ms(10秒)</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="产线编号"> |
| | | <el-input-number |
| | | v-model="config.workLine" |
| | | :min="1" |
| | | :max="100" |
| | | :step="1" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">产线编号,用于过滤玻璃信息</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="自动确认"> |
| | | <el-switch v-model="config.autoAck" /> |
| | | <span class="form-tip">是否自动确认MES发送的玻璃信息(回写mesSend=0)</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, watch } from 'vue' |
| | | |
| | | const props = defineProps({ |
| | | modelValue: { |
| | | type: Object, |
| | | default: () => ({}) |
| | | } |
| | | }) |
| | | |
| | | const emit = defineEmits(['update:modelValue']) |
| | | |
| | | // 配置数据 |
| | | const config = ref({ |
| | | scanIntervalMs: 10000, |
| | | workLine: null, |
| | | autoAck: true |
| | | }) |
| | | |
| | | // 监听props变化 |
| | | watch(() => props.modelValue, (newVal) => { |
| | | if (newVal && Object.keys(newVal).length > 0) { |
| | | config.value = { |
| | | scanIntervalMs: newVal.scanIntervalMs ?? 10000, |
| | | workLine: newVal.workLine ?? null, |
| | | autoAck: newVal.autoAck ?? true |
| | | } |
| | | } |
| | | }, { immediate: true, deep: true }) |
| | | |
| | | // 监听config变化,同步到父组件 |
| | | watch(config, (newVal) => { |
| | | emit('update:modelValue', { ...newVal }) |
| | | }, { deep: true }) |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .form-tip { |
| | | margin-left: 10px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | </style> |
| | | |
| New file |
| | |
| | | <template> |
| | | <div class="workstation-transfer-config"> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="扫码间隔(ms)"> |
| | | <el-input-number |
| | | v-model="config.scanIntervalMs" |
| | | :min="1000" |
| | | :max="60000" |
| | | :step="1000" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">定时查询最近扫码玻璃的时间间隔,默认10000ms(10秒)</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="缓冲判定时间(ms)"> |
| | | <el-input-number |
| | | v-model="config.transferDelayMs" |
| | | :min="5000" |
| | | :max="120000" |
| | | :step="1000" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">30秒内无新玻璃扫码则判定为最后一片,默认30000ms(30秒)</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="车辆容量(mm)"> |
| | | <el-input-number |
| | | v-model="config.vehicleCapacity" |
| | | :min="1000" |
| | | :max="20000" |
| | | :step="100" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">可装载的最大宽度(毫米),默认6000mm</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="监控间隔(ms)"> |
| | | <el-input-number |
| | | v-model="config.monitorIntervalMs" |
| | | :min="1000" |
| | | :max="60000" |
| | | :step="1000" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">批次处理监控间隔,默认使用scanIntervalMs</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="产线编号"> |
| | | <el-input-number |
| | | v-model="config.workLine" |
| | | :min="1" |
| | | :max="100" |
| | | :step="1" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">产线编号,用于过滤玻璃信息</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="位置值(格)"> |
| | | <el-input-number |
| | | v-model="config.inPosition" |
| | | :min="0" |
| | | :max="1000" |
| | | :step="1" |
| | | style="width: 100%;" |
| | | /> |
| | | <span class="form-tip">写入PLC的inPosition值(格子)</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="自动确认"> |
| | | <el-switch v-model="config.autoAck" /> |
| | | <span class="form-tip">是否自动确认MES发送的玻璃信息</span> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, watch } from 'vue' |
| | | |
| | | const props = defineProps({ |
| | | modelValue: { |
| | | type: Object, |
| | | default: () => ({}) |
| | | } |
| | | }) |
| | | |
| | | const emit = defineEmits(['update:modelValue']) |
| | | |
| | | // 配置数据 |
| | | const config = ref({ |
| | | scanIntervalMs: 10000, |
| | | transferDelayMs: 30000, |
| | | vehicleCapacity: 6000, |
| | | monitorIntervalMs: 10000, |
| | | workLine: null, |
| | | inPosition: null, |
| | | autoAck: true |
| | | }) |
| | | |
| | | // 监听props变化 |
| | | watch(() => props.modelValue, (newVal) => { |
| | | if (newVal && Object.keys(newVal).length > 0) { |
| | | config.value = { |
| | | scanIntervalMs: newVal.scanIntervalMs ?? 10000, |
| | | transferDelayMs: newVal.transferDelayMs ?? 30000, |
| | | vehicleCapacity: newVal.vehicleCapacity ?? 6000, |
| | | monitorIntervalMs: newVal.monitorIntervalMs ?? newVal.scanIntervalMs ?? 10000, |
| | | workLine: newVal.workLine ?? null, |
| | | inPosition: newVal.inPosition ?? null, |
| | | autoAck: newVal.autoAck ?? true |
| | | } |
| | | } |
| | | }, { immediate: true, deep: true }) |
| | | |
| | | // 监听config变化,同步到父组件 |
| | | watch(config, (newVal) => { |
| | | emit('update:modelValue', { ...newVal }) |
| | | }, { deep: true }) |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .form-tip { |
| | | margin-left: 10px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | </style> |
| | | |
| New file |
| | |
| | | /** |
| | | * 设备逻辑配置组件导出 |
| | | * 统一管理所有设备类型的配置组件 |
| | | */ |
| | | |
| | | import LoadVehicleConfig from './LoadVehicleConfig.vue' |
| | | import LargeGlassConfig from './LargeGlassConfig.vue' |
| | | import WorkstationScannerConfig from './WorkstationScannerConfig.vue' |
| | | import WorkstationTransferConfig from './WorkstationTransferConfig.vue' |
| | | |
| | | // 设备类型到组件的映射 |
| | | export const deviceTypeComponentMap = { |
| | | '大车设备': LoadVehicleConfig, |
| | | '大理片笼': LargeGlassConfig, |
| | | '卧转立扫码': WorkstationScannerConfig, |
| | | '卧转立': WorkstationTransferConfig, |
| | | // 兼容旧名称 |
| | | '上大车': LoadVehicleConfig, |
| | | '大理片': LargeGlassConfig |
| | | } |
| | | |
| | | // 导出所有组件 |
| | | export { |
| | | LoadVehicleConfig, |
| | | LargeGlassConfig, |
| | | WorkstationScannerConfig, |
| | | WorkstationTransferConfig |
| | | } |
| | | |
| | | // 根据设备类型获取对应的配置组件 |
| | | export function getDeviceConfigComponent(deviceType) { |
| | | return deviceTypeComponentMap[deviceType] || null |
| | | } |
| | | |
| | |
| | | <div class="main-grid"> |
| | | <div class="left-panel"> |
| | | <GroupList @select="handleGroupSelect" /> |
| | | <GroupTopology |
| | | v-if="selectedGroup" |
| | | :group="selectedGroup" |
| | | class="topology-panel" |
| | | /> |
| | | </div> |
| | | <div class="right-panel"> |
| | | <TaskOrchestration :group="selectedGroup" @task-started="refreshMonitor" /> |
| | | <ExecutionMonitor ref="monitorRef" :group-id="selectedGroupId" class="monitor-panel" /> |
| | | <el-tabs v-model="activeTab" type="card" class="workbench-tabs"> |
| | | <el-tab-pane label="任务编排" name="orchestration"> |
| | | <TaskOrchestration |
| | | :group="selectedGroup" |
| | | @task-started="handleTaskStarted" |
| | | /> |
| | | </el-tab-pane> |
| | | <el-tab-pane label="执行监控" name="monitor"> |
| | | <ExecutionMonitor |
| | | ref="monitorRef" |
| | | :group-id="selectedGroupId" |
| | | :task-id="selectedTaskId" |
| | | class="monitor-panel" |
| | | @task-selected="handleTaskSelected" |
| | | /> |
| | | </el-tab-pane> |
| | | <el-tab-pane label="结果分析" name="analysis"> |
| | | <ResultAnalysis |
| | | ref="analysisRef" |
| | | :task="selectedTask" |
| | | class="analysis-panel" |
| | | /> |
| | | </el-tab-pane> |
| | | </el-tabs> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, ref } from 'vue' |
| | | import { computed, ref, watch } from 'vue' |
| | | import GroupList from './components/DeviceGroup/GroupList.vue' |
| | | import GroupTopology from './components/DeviceGroup/GroupTopology.vue' |
| | | import TaskOrchestration from './components/MultiDeviceTest/TaskOrchestration.vue' |
| | | import ExecutionMonitor from './components/MultiDeviceTest/ExecutionMonitor.vue' |
| | | import ResultAnalysis from './components/MultiDeviceTest/ResultAnalysis.vue' |
| | | |
| | | const selectedGroup = ref(null) |
| | | const monitorRef = ref(null) |
| | | const analysisRef = ref(null) |
| | | const activeTab = ref('orchestration') |
| | | const selectedTaskId = ref(null) |
| | | const selectedTask = ref(null) |
| | | |
| | | const selectedGroupId = computed(() => { |
| | | if (!selectedGroup.value) return null |
| | |
| | | |
| | | const handleGroupSelect = (group) => { |
| | | selectedGroup.value = group |
| | | selectedTask.value = null |
| | | selectedTaskId.value = null |
| | | // 切换到编排标签页 |
| | | activeTab.value = 'orchestration' |
| | | } |
| | | |
| | | const refreshMonitor = () => { |
| | | monitorRef.value?.fetchTasks?.() |
| | | const handleTaskStarted = (task) => { |
| | | // 任务启动后,切换到监控标签页(如果当前不在监控页) |
| | | if (activeTab.value !== 'monitor') { |
| | | activeTab.value = 'monitor' |
| | | } |
| | | // 立即刷新监控列表,显示新启动的任务 |
| | | setTimeout(() => { |
| | | monitorRef.value?.fetchTasks?.() |
| | | }, 300) |
| | | |
| | | // 如果传入了任务信息,可以自动选中 |
| | | if (task && task.taskId) { |
| | | selectedTaskId.value = task.taskId |
| | | } |
| | | } |
| | | |
| | | const handleTaskSelected = (task) => { |
| | | selectedTask.value = task |
| | | selectedTaskId.value = task?.taskId || null |
| | | // 如果任务已完成或失败,切换到结果分析标签页 |
| | | if (task && (task.status === 'COMPLETED' || task.status === 'FAILED')) { |
| | | activeTab.value = 'analysis' |
| | | // 刷新分析数据 |
| | | setTimeout(() => { |
| | | analysisRef.value?.fetchSteps?.() |
| | | }, 100) |
| | | } |
| | | } |
| | | </script> |
| | | |
| | |
| | | gap: 24px; |
| | | } |
| | | |
| | | .right-panel { |
| | | .left-panel { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 24px; |
| | | } |
| | | |
| | | .monitor-panel { |
| | | .topology-panel { |
| | | flex: 1; |
| | | min-height: 300px; |
| | | } |
| | | |
| | | .right-panel { |
| | | display: flex; |
| | | flex-direction: column; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .workbench-tabs { |
| | | flex: 1; |
| | | display: flex; |
| | | flex-direction: column; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .workbench-tabs :deep(.el-tabs__content) { |
| | | flex: 1; |
| | | overflow: auto; |
| | | } |
| | | |
| | | .workbench-tabs :deep(.el-tab-pane) { |
| | | height: 100%; |
| | | } |
| | | |
| | | .monitor-panel, |
| | | .analysis-panel { |
| | | flex: 1; |
| | | min-height: 500px; |
| | | } |
| | | |
| | | @media (max-width: 1200px) { |
| New file |
| | |
| | | <template> |
| | | <div class="group-topology"> |
| | | <div class="panel-header"> |
| | | <div> |
| | | <h3>设备组拓扑图</h3> |
| | | <p v-if="group">{{ group.groupName }} - 设备执行流程可视化</p> |
| | | <p v-else class="warning">请先选择一个设备组</p> |
| | | </div> |
| | | <div class="action-buttons"> |
| | | <el-button :loading="loading" @click="handleRefresh"> |
| | | <el-icon><Refresh /></el-icon> |
| | | 刷新 |
| | | </el-button> |
| | | <el-button @click="toggleLayout"> |
| | | <el-icon><Grid /></el-icon> |
| | | {{ layoutMode === 'horizontal' ? '垂直布局' : '水平布局' }} |
| | | </el-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-if="!group" class="empty-state"> |
| | | <el-empty description="请选择设备组查看拓扑图" /> |
| | | </div> |
| | | |
| | | <div v-else class="topology-container" :class="`layout-${layoutMode}`"> |
| | | <template v-for="(device, index) in devices" :key="device.id || device.deviceId"> |
| | | <div |
| | | class="topology-node-wrapper" |
| | | :class="`layout-${layoutMode}`" |
| | | > |
| | | <div |
| | | class="topology-node" |
| | | :class="getDeviceTypeClass(device.deviceType)" |
| | | @click="handleNodeClick(device)" |
| | | :title="`点击查看设备详情 | 执行顺序: ${index + 1}`" |
| | | > |
| | | <div class="node-content"> |
| | | <div class="node-icon"> |
| | | <el-icon :size="24"> |
| | | <component :is="getDeviceIcon(device.deviceType)" /> |
| | | </el-icon> |
| | | </div> |
| | | <div class="node-info"> |
| | | <div class="node-name">{{ device.deviceName || device.deviceCode }}</div> |
| | | <div class="node-type">{{ getDeviceTypeLabel(device.deviceType) }}</div> |
| | | <div class="node-status"> |
| | | <el-tag :type="getStatusType(device.status)" size="small"> |
| | | {{ getStatusLabel(device.status) }} |
| | | </el-tag> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <!-- 执行顺序标识:右上角的数字圆圈 --> |
| | | <div class="node-order" :title="`执行顺序: 第 ${index + 1} 步`"> |
| | | {{ index + 1 }} |
| | | </div> |
| | | </div> |
| | | <!-- 流程方向箭头:表示设备执行顺序和数据流向 --> |
| | | <div |
| | | v-if="index < devices.length - 1" |
| | | class="node-arrow" |
| | | :title="`数据流向: ${device.deviceName || device.deviceCode} → ${devices[index + 1]?.deviceName || devices[index + 1]?.deviceCode}`" |
| | | > |
| | | <el-icon :size="20"> |
| | | <ArrowRight v-if="layoutMode === 'horizontal'" /> |
| | | <ArrowDown v-else /> |
| | | </el-icon> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </div> |
| | | |
| | | <!-- 设备详情卡片 --> |
| | | <el-card v-if="selectedDevice" class="device-detail-card" shadow="never"> |
| | | <template #header> |
| | | <div class="card-header"> |
| | | <span>设备详情</span> |
| | | <el-button link @click="selectedDevice = null"> |
| | | <el-icon><Close /></el-icon> |
| | | </el-button> |
| | | </div> |
| | | </template> |
| | | <el-descriptions :column="2" border> |
| | | <el-descriptions-item label="设备名称"> |
| | | {{ selectedDevice.deviceName }} |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="设备编码"> |
| | | {{ selectedDevice.deviceCode }} |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="设备类型"> |
| | | {{ getDeviceTypeLabel(selectedDevice.deviceType) }} |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="状态"> |
| | | <el-tag :type="getStatusType(selectedDevice.status)"> |
| | | {{ getStatusLabel(selectedDevice.status) }} |
| | | </el-tag> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="PLC IP" v-if="selectedDevice.plcIp"> |
| | | {{ selectedDevice.plcIp }} |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="PLC类型" v-if="selectedDevice.plcType"> |
| | | {{ selectedDevice.plcType }} |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="模块名称" v-if="selectedDevice.moduleName"> |
| | | {{ selectedDevice.moduleName }} |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="是否启用"> |
| | | <el-tag :type="selectedDevice.enabled ? 'success' : 'info'"> |
| | | {{ selectedDevice.enabled ? '启用' : '停用' }} |
| | | </el-tag> |
| | | </el-descriptions-item> |
| | | </el-descriptions> |
| | | </el-card> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, ref, watch } from 'vue' |
| | | import { ElMessage } from 'element-plus' |
| | | import { |
| | | Refresh, |
| | | Grid, |
| | | ArrowRight, |
| | | ArrowDown, |
| | | Close, |
| | | Files, |
| | | Box, |
| | | Folder |
| | | } from '@element-plus/icons-vue' |
| | | import { deviceGroupApi } from '@/api/device/deviceManagement' |
| | | |
| | | const props = defineProps({ |
| | | group: { |
| | | type: Object, |
| | | default: null |
| | | } |
| | | }) |
| | | |
| | | const loading = ref(false) |
| | | const devices = ref([]) |
| | | const layoutMode = ref('horizontal') // 'horizontal' | 'vertical' |
| | | const selectedDevice = ref(null) |
| | | |
| | | const fetchDevices = async () => { |
| | | if (!props.group) { |
| | | devices.value = [] |
| | | return |
| | | } |
| | | const groupId = props.group.id || props.group.groupId |
| | | if (!groupId) { |
| | | devices.value = [] |
| | | return |
| | | } |
| | | try { |
| | | loading.value = true |
| | | const response = await deviceGroupApi.getGroupDevices(groupId) |
| | | const rawList = response?.data |
| | | const deviceList = Array.isArray(rawList) |
| | | ? rawList |
| | | : Array.isArray(rawList?.records) |
| | | ? rawList.records |
| | | : Array.isArray(rawList?.data) |
| | | ? rawList.data |
| | | : [] |
| | | // 按执行顺序排序 |
| | | devices.value = deviceList.sort((a, b) => { |
| | | const orderA = a.executionOrder || a.order || 0 |
| | | const orderB = b.executionOrder || b.order || 0 |
| | | return orderA - orderB |
| | | }) |
| | | } catch (error) { |
| | | ElMessage.error(error?.message || '加载设备列表失败') |
| | | devices.value = [] |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | const handleRefresh = () => { |
| | | fetchDevices() |
| | | } |
| | | |
| | | const toggleLayout = () => { |
| | | layoutMode.value = layoutMode.value === 'horizontal' ? 'vertical' : 'horizontal' |
| | | } |
| | | |
| | | const getDeviceTypeClass = (deviceType) => { |
| | | if (!deviceType) return 'type-unknown' |
| | | const type = deviceType.toUpperCase() |
| | | if (type.includes('VEHICLE') || type.includes('大车')) return 'type-vehicle' |
| | | if (type.includes('GLASS') || type.includes('大理片')) return 'type-glass' |
| | | if (type.includes('STORAGE') || type.includes('存储')) return 'type-storage' |
| | | return 'type-unknown' |
| | | } |
| | | |
| | | const getDeviceIcon = (deviceType) => { |
| | | if (!deviceType) return Box |
| | | const type = deviceType.toUpperCase() |
| | | if (type.includes('VEHICLE') || type.includes('大车')) return Files |
| | | if (type.includes('GLASS') || type.includes('大理片')) return Box |
| | | if (type.includes('STORAGE') || type.includes('存储')) return Folder |
| | | return Box |
| | | } |
| | | |
| | | const getDeviceTypeLabel = (deviceType) => { |
| | | if (!deviceType) return '未知设备' |
| | | const type = deviceType.toUpperCase() |
| | | if (type.includes('VEHICLE') || type.includes('大车')) return '上大车设备' |
| | | if (type.includes('GLASS') || type.includes('大理片')) return '大理片设备' |
| | | if (type.includes('STORAGE') || type.includes('存储')) return '玻璃存储设备' |
| | | return deviceType |
| | | } |
| | | |
| | | const getStatusType = (status) => { |
| | | if (!status) return 'info' |
| | | const s = String(status).toUpperCase() |
| | | if (s === '1' || s === '启用' || s === 'ENABLED' || s === 'ONLINE') return 'success' |
| | | if (s === '0' || s === '停用' || s === 'DISABLED' || s === 'OFFLINE') return 'danger' |
| | | if (s === '2' || s === '维护' || s === 'MAINTENANCE') return 'warning' |
| | | return 'info' |
| | | } |
| | | |
| | | const getStatusLabel = (status) => { |
| | | if (!status) return '未知' |
| | | const s = String(status).toUpperCase() |
| | | if (s === '1' || s === '启用' || s === 'ENABLED' || s === 'ONLINE') return '在线' |
| | | if (s === '0' || s === '停用' || s === 'DISABLED' || s === 'OFFLINE') return '离线' |
| | | if (s === '2' || s === '维护' || s === 'MAINTENANCE') return '维护中' |
| | | return String(status) |
| | | } |
| | | |
| | | watch( |
| | | () => props.group, |
| | | () => { |
| | | fetchDevices() |
| | | selectedDevice.value = null |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | |
| | | // 点击节点选择设备 |
| | | const handleNodeClick = (device) => { |
| | | selectedDevice.value = device |
| | | } |
| | | |
| | | defineExpose({ |
| | | fetchDevices |
| | | }) |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .group-topology { |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | padding: 20px; |
| | | box-shadow: 0 8px 32px rgba(15, 18, 63, 0.08); |
| | | } |
| | | |
| | | .panel-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | margin-bottom: 20px; |
| | | } |
| | | |
| | | .panel-header h3 { |
| | | margin: 0; |
| | | } |
| | | |
| | | .panel-header p { |
| | | margin: 4px 0 0; |
| | | color: #909399; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .panel-header .warning { |
| | | color: #f56c6c; |
| | | } |
| | | |
| | | .action-buttons { |
| | | display: flex; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .empty-state { |
| | | padding: 60px 0; |
| | | } |
| | | |
| | | .topology-container { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 20px 0; |
| | | min-height: 200px; |
| | | overflow-x: auto; |
| | | /* 平滑滚动 */ |
| | | scroll-behavior: smooth; |
| | | } |
| | | |
| | | .topology-container.layout-horizontal { |
| | | flex-direction: row; |
| | | justify-content: flex-start; |
| | | align-items: center; |
| | | /* 添加左右内边距,确保第一个和最后一个节点完全可见 */ |
| | | padding-left: 20px; |
| | | padding-right: 20px; |
| | | } |
| | | |
| | | .topology-container.layout-vertical { |
| | | flex-direction: column; |
| | | align-items: center; |
| | | } |
| | | |
| | | /* 节点包装器:水平布局时横向排列,垂直布局时纵向排列 */ |
| | | .topology-node-wrapper { |
| | | display: flex; |
| | | align-items: center; |
| | | } |
| | | |
| | | .topology-node-wrapper.layout-horizontal { |
| | | flex-direction: row; |
| | | align-items: center; |
| | | } |
| | | |
| | | .topology-node-wrapper.layout-vertical { |
| | | flex-direction: column; |
| | | align-items: center; |
| | | } |
| | | |
| | | .topology-node { |
| | | position: relative; |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | cursor: pointer; |
| | | transition: transform 0.2s; |
| | | } |
| | | |
| | | .topology-node:hover { |
| | | transform: translateY(-4px); |
| | | } |
| | | |
| | | .node-content { |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | padding: 20px; |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); |
| | | min-width: 160px; |
| | | transition: all 0.3s; |
| | | } |
| | | |
| | | .topology-node:hover .node-content { |
| | | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); |
| | | } |
| | | |
| | | .node-icon { |
| | | width: 56px; |
| | | height: 56px; |
| | | border-radius: 50%; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | margin-bottom: 12px; |
| | | color: #fff; |
| | | } |
| | | |
| | | .type-vehicle .node-icon { |
| | | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| | | } |
| | | |
| | | .type-glass .node-icon { |
| | | background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); |
| | | } |
| | | |
| | | .type-storage .node-icon { |
| | | background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); |
| | | } |
| | | |
| | | .type-unknown .node-icon { |
| | | background: linear-gradient(135deg, #909399 0%, #b1b3b8 100%); |
| | | } |
| | | |
| | | .node-info { |
| | | text-align: center; |
| | | width: 100%; |
| | | } |
| | | |
| | | .node-name { |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | margin-bottom: 4px; |
| | | word-break: break-all; |
| | | } |
| | | |
| | | .node-type { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .node-status { |
| | | display: flex; |
| | | justify-content: center; |
| | | } |
| | | |
| | | .node-order { |
| | | position: absolute; |
| | | top: -8px; |
| | | right: -8px; |
| | | width: 24px; |
| | | height: 24px; |
| | | border-radius: 50%; |
| | | background: #409eff; |
| | | color: #fff; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | font-size: 12px; |
| | | font-weight: 600; |
| | | box-shadow: 0 2px 8px rgba(64, 158, 255, 0.4); |
| | | } |
| | | |
| | | .node-arrow { |
| | | color: #c0c4cc; |
| | | flex-shrink: 0; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | } |
| | | |
| | | /* 水平布局:箭头在节点右侧 */ |
| | | .topology-node-wrapper.layout-horizontal .node-arrow { |
| | | margin-left: 20px; |
| | | margin-right: 20px; |
| | | } |
| | | |
| | | /* 垂直布局:箭头在节点下方 */ |
| | | .topology-node-wrapper.layout-vertical .node-arrow { |
| | | margin-top: 15px; |
| | | margin-bottom: 15px; |
| | | } |
| | | |
| | | .device-detail-card { |
| | | margin-top: 20px; |
| | | } |
| | | |
| | | .device-detail-card .card-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | } |
| | | |
| | | @media (max-width: 768px) { |
| | | .panel-header { |
| | | flex-direction: column; |
| | | align-items: flex-start; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .action-buttons { |
| | | width: 100%; |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | .topology-container.layout-horizontal { |
| | | flex-direction: column; |
| | | gap: 30px; |
| | | } |
| | | |
| | | .node-arrow { |
| | | transform: rotate(90deg); |
| | | } |
| | | } |
| | | </style> |
| | | |
| | |
| | | <div> |
| | | <h3>任务执行监控</h3> |
| | | <p>实时查看最新的多设备任务</p> |
| | | <p v-if="sseConnected" class="sse-status connected"> |
| | | <el-icon><Connection /></el-icon> |
| | | 实时监控已连接 |
| | | </p> |
| | | <p v-else class="sse-status disconnected"> |
| | | <el-icon><Close /></el-icon> |
| | | 实时监控未连接 |
| | | </p> |
| | | </div> |
| | | <el-button :loading="loading" @click="fetchTasks"> |
| | | <el-icon><Refresh /></el-icon> |
| | | 刷新 |
| | | </el-button> |
| | | <div class="action-buttons"> |
| | | <el-button :loading="loading" @click="fetchTasks"> |
| | | <el-icon><Refresh /></el-icon> |
| | | 刷新 |
| | | </el-button> |
| | | <el-button |
| | | v-if="!sseConnected" |
| | | type="success" |
| | | @click="connectSSE" |
| | | :loading="sseConnecting" |
| | | > |
| | | <el-icon><VideoPlay /></el-icon> |
| | | 开启实时监控 |
| | | </el-button> |
| | | <el-button v-else type="danger" @click="disconnectSSE"> |
| | | <el-icon><VideoPause /></el-icon> |
| | | 关闭实时监控 |
| | | </el-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <el-table |
| | |
| | | height="300" |
| | | stripe |
| | | @row-click="handleRowClick" |
| | | row-key="taskId" |
| | | > |
| | | <el-table-column prop="taskId" label="任务编号" min-width="160" /> |
| | | <el-table-column prop="groupId" label="设备组ID" width="120" /> |
| | | <el-table-column prop="status" label="状态" width="120"> |
| | | <template #default="{ row }"> |
| | | <el-tag :type="statusType(row.status)">{{ row.status }}</el-tag> |
| | | <el-tag :type="statusType(row.status)"> |
| | | {{ getStatusLabel(row.status) }} |
| | | </el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="currentStep" label="进度" width="120"> |
| | | <el-table-column prop="currentStep" label="进度" width="140"> |
| | | <template #default="{ row }"> |
| | | {{ row.currentStep || 0 }} / {{ row.totalSteps || 0 }} |
| | | <div class="progress-cell"> |
| | | <span>{{ row.currentStep || 0 }} / {{ row.totalSteps || 0 }}</span> |
| | | <el-progress |
| | | :percentage="getProgressPercentage(row)" |
| | | :status="getProgressStatus(row.status)" |
| | | :stroke-width="6" |
| | | :show-text="false" |
| | | style="margin-top: 4px;" |
| | | /> |
| | | </div> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="开始时间" min-width="160"> |
| | |
| | | {{ formatDateTime(row.endTime) }} |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" width="120" fixed="right"> |
| | | <template #default="{ row }"> |
| | | <el-button |
| | | link |
| | | type="primary" |
| | | size="small" |
| | | @click.stop="handleRowClick(row)" |
| | | > |
| | | 查看详情 |
| | | </el-button> |
| | | <el-button |
| | | v-if="row.status === 'RUNNING'" |
| | | link |
| | | type="danger" |
| | | size="small" |
| | | @click.stop="handleCancelTask(row)" |
| | | > |
| | | 取消 |
| | | </el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | |
| | | <el-drawer v-model="drawerVisible" size="40%" title="任务步骤详情"> |
| | | <el-timeline v-loading="stepsLoading" :reverse="false"> |
| | | <el-drawer v-model="drawerVisible" size="40%" :title="`任务步骤详情 - ${currentTaskId || ''}`"> |
| | | <div class="drawer-header" v-if="currentTask"> |
| | | <el-descriptions :column="2" border size="small"> |
| | | <el-descriptions-item label="任务状态"> |
| | | <el-tag :type="statusType(currentTask.status)"> |
| | | {{ getStatusLabel(currentTask.status) }} |
| | | </el-tag> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="进度"> |
| | | {{ currentTask.currentStep || 0 }} / {{ currentTask.totalSteps || 0 }} |
| | | </el-descriptions-item> |
| | | </el-descriptions> |
| | | </div> |
| | | <el-timeline v-loading="stepsLoading" :reverse="false" style="margin-top: 20px;"> |
| | | <el-timeline-item |
| | | v-for="step in steps" |
| | | :key="step.id" |
| | | :timestamp="formatDateTime(step.startTime) || '-'" |
| | | :type="step.status === 'COMPLETED' ? 'success' : step.status === 'FAILED' ? 'danger' : 'primary'" |
| | | :type="getStepTimelineType(step.status)" |
| | | > |
| | | <div class="step-title">{{ step.stepName }}</div> |
| | | <div class="step-desc">状态:{{ step.status }}</div> |
| | | <div class="step-desc"> |
| | | <el-tag :type="getStepStatusType(step.status)" size="small"> |
| | | {{ getStepStatusLabel(step.status) }} |
| | | </el-tag> |
| | | </div> |
| | | <div class="step-desc">耗时:{{ formatDuration(step.durationMs) }}</div> |
| | | <div class="step-desc" v-if="step.errorMessage"> |
| | | <div class="step-desc" v-if="step.retryCount > 0"> |
| | | 重试次数:{{ step.retryCount }} |
| | | </div> |
| | | <div class="step-desc error-message" v-if="step.errorMessage"> |
| | | <el-icon><Warning /></el-icon> |
| | | 错误:{{ step.errorMessage }} |
| | | </div> |
| | | </el-timeline-item> |
| | |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { onMounted, ref, watch } from 'vue' |
| | | import { ElMessage } from 'element-plus' |
| | | import { Refresh } from '@element-plus/icons-vue' |
| | | import { onMounted, onUnmounted, ref, watch } from 'vue' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { |
| | | Refresh, |
| | | Connection, |
| | | Close, |
| | | VideoPlay, |
| | | VideoPause, |
| | | Warning |
| | | } from '@element-plus/icons-vue' |
| | | import { multiDeviceTaskApi } from '@/api/device/multiDeviceTask' |
| | | |
| | | const props = defineProps({ |
| | | groupId: { |
| | | type: [String, Number], |
| | | default: null |
| | | }, |
| | | taskId: { |
| | | type: String, |
| | | default: null |
| | | } |
| | | }) |
| | |
| | | const stepsLoading = ref(false) |
| | | const steps = ref([]) |
| | | const currentTaskId = ref(null) |
| | | const currentTask = ref(null) |
| | | |
| | | // SSE相关 |
| | | const sseConnected = ref(false) |
| | | const sseConnecting = ref(false) |
| | | let eventSource = null |
| | | const baseURL = import.meta.env.VITE_API_BASE_URL || '' |
| | | |
| | | const fetchTasks = async () => { |
| | | try { |
| | |
| | | } |
| | | } |
| | | |
| | | // SSE连接 |
| | | const connectSSE = () => { |
| | | if (eventSource) { |
| | | disconnectSSE() |
| | | } |
| | | |
| | | sseConnecting.value = true |
| | | try { |
| | | // 构建SSE URL - 后端只支持 taskId 参数,不支持 groupId |
| | | let url = `${baseURL}/api/plcSend/task/notification/sse` |
| | | // 如果没有指定 taskId,则监听所有任务(不传参数) |
| | | if (props.taskId) { |
| | | url += `?taskId=${encodeURIComponent(props.taskId)}` |
| | | } |
| | | // 注意:后端不支持 groupId 参数,如果需要监听某个组的所有任务, |
| | | // 需要在前端根据 groupId 获取任务列表,然后为每个任务创建连接 |
| | | // 或者使用不传参数的方式监听所有任务,然后在前端过滤 |
| | | |
| | | eventSource = new EventSource(url) |
| | | |
| | | eventSource.onopen = () => { |
| | | sseConnected.value = true |
| | | sseConnecting.value = false |
| | | ElMessage.success('实时监控已连接') |
| | | } |
| | | |
| | | eventSource.onerror = (error) => { |
| | | console.error('SSE连接错误:', error) |
| | | sseConnected.value = false |
| | | sseConnecting.value = false |
| | | if (eventSource?.readyState === EventSource.CLOSED) { |
| | | ElMessage.warning('实时监控连接已断开') |
| | | // 尝试重连 |
| | | setTimeout(() => { |
| | | if (!sseConnected.value) { |
| | | connectSSE() |
| | | } |
| | | }, 3000) |
| | | } |
| | | } |
| | | |
| | | // 监听连接成功事件 |
| | | eventSource.addEventListener('connected', (event) => { |
| | | try { |
| | | const data = JSON.parse(event.data) |
| | | console.log('SSE连接成功:', data) |
| | | } catch (error) { |
| | | console.error('解析连接消息失败:', error) |
| | | } |
| | | }) |
| | | |
| | | // 监听任务状态更新 |
| | | eventSource.addEventListener('taskStatus', (event) => { |
| | | try { |
| | | const data = JSON.parse(event.data) |
| | | // 如果指定了 groupId,只更新该组的任务 |
| | | if (!props.groupId || !data.groupId || String(data.groupId) === String(props.groupId)) { |
| | | updateTaskFromSSE(data) |
| | | } |
| | | } catch (error) { |
| | | console.error('解析任务状态失败:', error) |
| | | } |
| | | }) |
| | | |
| | | // 监听步骤更新 |
| | | eventSource.addEventListener('stepUpdate', (event) => { |
| | | try { |
| | | const data = JSON.parse(event.data) |
| | | // 如果数据中包含 taskId,检查是否匹配当前查看的任务 |
| | | if (data.taskId && data.taskId === currentTaskId.value) { |
| | | updateStepFromSSE(data) |
| | | } else if (!data.taskId) { |
| | | // 如果没有 taskId,可能是步骤数据直接传递 |
| | | updateStepFromSSE(data) |
| | | } |
| | | } catch (error) { |
| | | console.error('解析步骤更新失败:', error) |
| | | } |
| | | }) |
| | | |
| | | // 监听步骤列表更新 |
| | | eventSource.addEventListener('stepsUpdate', (event) => { |
| | | try { |
| | | const data = JSON.parse(event.data) |
| | | if (data.taskId === currentTaskId.value && Array.isArray(data.steps)) { |
| | | steps.value = data.steps |
| | | } |
| | | } catch (error) { |
| | | console.error('解析步骤列表失败:', error) |
| | | } |
| | | }) |
| | | } catch (error) { |
| | | console.error('创建SSE连接失败:', error) |
| | | ElMessage.error('连接实时监控失败: ' + error.message) |
| | | sseConnecting.value = false |
| | | } |
| | | } |
| | | |
| | | const disconnectSSE = () => { |
| | | if (eventSource) { |
| | | eventSource.close() |
| | | eventSource = null |
| | | } |
| | | sseConnected.value = false |
| | | sseConnecting.value = false |
| | | } |
| | | |
| | | // 从SSE更新任务状态 |
| | | const updateTaskFromSSE = (data) => { |
| | | if (!data || !data.taskId) return |
| | | |
| | | // 如果指定了 groupId,只处理该组的任务 |
| | | if (props.groupId && data.groupId && String(data.groupId) !== String(props.groupId)) { |
| | | return |
| | | } |
| | | |
| | | const taskIndex = tasks.value.findIndex(t => t.taskId === data.taskId) |
| | | if (taskIndex >= 0) { |
| | | // 更新任务 - 保留原有字段,只更新SSE传来的字段 |
| | | const existingTask = tasks.value[taskIndex] |
| | | tasks.value[taskIndex] = { |
| | | ...existingTask, |
| | | status: data.status || existingTask.status, |
| | | currentStep: data.currentStep !== undefined ? data.currentStep : existingTask.currentStep, |
| | | totalSteps: data.totalSteps !== undefined ? data.totalSteps : existingTask.totalSteps, |
| | | startTime: data.startTime ? new Date(data.startTime) : existingTask.startTime, |
| | | endTime: data.endTime ? new Date(data.endTime) : existingTask.endTime, |
| | | errorMessage: data.errorMessage || existingTask.errorMessage |
| | | } |
| | | // 如果当前查看的是这个任务,也更新 |
| | | if (currentTaskId.value === data.taskId) { |
| | | currentTask.value = tasks.value[taskIndex] |
| | | } |
| | | } else { |
| | | // 新任务,添加到列表(需要转换时间戳为Date对象) |
| | | const newTask = { |
| | | ...data, |
| | | startTime: data.startTime ? new Date(data.startTime) : null, |
| | | endTime: data.endTime ? new Date(data.endTime) : null |
| | | } |
| | | tasks.value.unshift(newTask) |
| | | } |
| | | } |
| | | |
| | | // 从SSE更新步骤 |
| | | const updateStepFromSSE = (data) => { |
| | | if (!data) return |
| | | |
| | | // 如果数据中包含 taskId,检查是否匹配当前查看的任务 |
| | | if (data.taskId && data.taskId !== currentTaskId.value) { |
| | | return |
| | | } |
| | | |
| | | // 如果当前没有打开任务详情,不更新步骤 |
| | | if (!currentTaskId.value) { |
| | | return |
| | | } |
| | | |
| | | // 使用 id 或 stepOrder 来查找步骤 |
| | | const stepIndex = data.id |
| | | ? steps.value.findIndex(s => s.id === data.id) |
| | | : data.stepOrder !== undefined |
| | | ? steps.value.findIndex(s => s.stepOrder === data.stepOrder) |
| | | : -1 |
| | | |
| | | if (stepIndex >= 0) { |
| | | // 更新步骤 - 保留原有字段,只更新SSE传来的字段 |
| | | const existingStep = steps.value[stepIndex] |
| | | steps.value[stepIndex] = { |
| | | ...existingStep, |
| | | status: data.status || existingStep.status, |
| | | startTime: data.startTime ? new Date(data.startTime) : existingStep.startTime, |
| | | endTime: data.endTime ? new Date(data.endTime) : existingStep.endTime, |
| | | durationMs: data.durationMs !== undefined ? data.durationMs : existingStep.durationMs, |
| | | retryCount: data.retryCount !== undefined ? data.retryCount : existingStep.retryCount, |
| | | errorMessage: data.errorMessage || existingStep.errorMessage |
| | | } |
| | | } else if (data.stepOrder !== undefined) { |
| | | // 新步骤,添加到列表(需要转换时间戳为Date对象) |
| | | const newStep = { |
| | | ...data, |
| | | startTime: data.startTime ? new Date(data.startTime) : null, |
| | | endTime: data.endTime ? new Date(data.endTime) : null |
| | | } |
| | | steps.value.push(newStep) |
| | | // 按 stepOrder 排序 |
| | | steps.value.sort((a, b) => (a.stepOrder || 0) - (b.stepOrder || 0)) |
| | | } |
| | | } |
| | | |
| | | const handleCancelTask = async (row) => { |
| | | try { |
| | | await ElMessageBox.confirm( |
| | | `确定要取消任务 ${row.taskId} 吗?`, |
| | | '确认取消', |
| | | { |
| | | confirmButtonText: '确定', |
| | | cancelButtonText: '取消', |
| | | type: 'warning' |
| | | } |
| | | ) |
| | | await multiDeviceTaskApi.cancelTask(row.taskId) |
| | | ElMessage.success('任务已取消') |
| | | fetchTasks() |
| | | } catch (error) { |
| | | if (error !== 'cancel') { |
| | | ElMessage.error(error?.message || '取消任务失败') |
| | | } |
| | | } |
| | | } |
| | | |
| | | const emit = defineEmits(['task-selected']) |
| | | |
| | | const handleRowClick = async (row) => { |
| | | currentTaskId.value = row.taskId |
| | | currentTask.value = row |
| | | emit('task-selected', row) |
| | | drawerVisible.value = true |
| | | stepsLoading.value = true |
| | | try { |
| | |
| | | return 'danger' |
| | | case 'RUNNING': |
| | | return 'warning' |
| | | case 'PENDING': |
| | | return 'info' |
| | | case 'CANCELLED': |
| | | return 'info' |
| | | default: |
| | | return 'info' |
| | | } |
| | | } |
| | | |
| | | const getStatusLabel = (status) => { |
| | | const s = (status || '').toUpperCase() |
| | | const statusMap = { |
| | | 'COMPLETED': '已完成', |
| | | 'FAILED': '失败', |
| | | 'RUNNING': '执行中', |
| | | 'PENDING': '等待中', |
| | | 'CANCELLED': '已取消' |
| | | } |
| | | return statusMap[s] || s || '未知' |
| | | } |
| | | |
| | | const getProgressPercentage = (row) => { |
| | | if (!row.totalSteps || row.totalSteps === 0) return 0 |
| | | return Math.round(((row.currentStep || 0) / row.totalSteps) * 100) |
| | | } |
| | | |
| | | const getProgressStatus = (status) => { |
| | | const s = (status || '').toUpperCase() |
| | | if (s === 'COMPLETED') return 'success' |
| | | if (s === 'FAILED') return 'exception' |
| | | if (s === 'RUNNING') return 'active' |
| | | return null |
| | | } |
| | | |
| | | const getStepTimelineType = (status) => { |
| | | const s = (status || '').toUpperCase() |
| | | if (s === 'COMPLETED') return 'success' |
| | | if (s === 'FAILED') return 'danger' |
| | | if (s === 'RUNNING') return 'primary' |
| | | return 'info' |
| | | } |
| | | |
| | | const getStepStatusType = (status) => { |
| | | const s = (status || '').toUpperCase() |
| | | if (s === 'COMPLETED') return 'success' |
| | | if (s === 'FAILED') return 'danger' |
| | | if (s === 'RUNNING') return 'warning' |
| | | if (s === 'PENDING') return 'info' |
| | | return 'default' |
| | | } |
| | | |
| | | const getStepStatusLabel = (status) => { |
| | | const s = (status || '').toUpperCase() |
| | | const statusMap = { |
| | | 'COMPLETED': '已完成', |
| | | 'FAILED': '失败', |
| | | 'RUNNING': '执行中', |
| | | 'PENDING': '等待中', |
| | | 'SKIPPED': '已跳过' |
| | | } |
| | | return statusMap[s] || s || '未知' |
| | | } |
| | | |
| | | const formatDuration = (ms) => { |
| | |
| | | () => props.groupId, |
| | | () => { |
| | | fetchTasks() |
| | | // 如果SSE已连接,重新连接(因为监听所有任务,前端会过滤) |
| | | if (sseConnected.value) { |
| | | disconnectSSE() |
| | | // 延迟重连,避免频繁连接 |
| | | setTimeout(() => { |
| | | connectSSE() |
| | | }, 500) |
| | | } |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | |
| | | onMounted(fetchTasks) |
| | | watch( |
| | | () => props.taskId, |
| | | () => { |
| | | // 如果指定了 taskId,重新连接以监听特定任务 |
| | | if (sseConnected.value) { |
| | | disconnectSSE() |
| | | setTimeout(() => { |
| | | connectSSE() |
| | | }, 500) |
| | | } |
| | | } |
| | | ) |
| | | |
| | | onMounted(() => { |
| | | fetchTasks() |
| | | // 自动连接SSE |
| | | connectSSE() |
| | | }) |
| | | |
| | | onUnmounted(() => { |
| | | disconnectSSE() |
| | | }) |
| | | |
| | | defineExpose({ |
| | | fetchTasks |
| | | fetchTasks, |
| | | connectSSE, |
| | | disconnectSSE |
| | | }) |
| | | </script> |
| | | |
| | |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .sse-status { |
| | | margin-top: 4px; |
| | | font-size: 12px; |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 4px; |
| | | } |
| | | |
| | | .sse-status.connected { |
| | | color: #67c23a; |
| | | } |
| | | |
| | | .sse-status.disconnected { |
| | | color: #f56c6c; |
| | | } |
| | | |
| | | .action-buttons { |
| | | display: flex; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .progress-cell { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 4px; |
| | | } |
| | | |
| | | .drawer-header { |
| | | margin-bottom: 20px; |
| | | } |
| | | |
| | | .step-title { |
| | | font-weight: 600; |
| | | margin-bottom: 4px; |
| | | } |
| | | |
| | | .step-desc { |
| | | font-size: 13px; |
| | | color: #606266; |
| | | margin-top: 4px; |
| | | } |
| | | |
| | | .step-desc.error-message { |
| | | color: #f56c6c; |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 4px; |
| | | margin-top: 8px; |
| | | } |
| | | |
| | | .step-title { |
| | | font-weight: 600; |
| | | margin-bottom: 4px; |
| New file |
| | |
| | | <template> |
| | | <div class="result-analysis"> |
| | | <div class="panel-header"> |
| | | <div> |
| | | <h3>测试结果分析</h3> |
| | | <p v-if="task">任务编号:{{ task.taskId }}</p> |
| | | <p v-else class="warning">请选择一个任务查看分析结果</p> |
| | | </div> |
| | | <div class="action-buttons"> |
| | | <el-button :loading="loading" @click="handleRefresh"> |
| | | <el-icon><Refresh /></el-icon> |
| | | 刷新 |
| | | </el-button> |
| | | <el-button type="primary" :disabled="!task" @click="handleExport('json')"> |
| | | <el-icon><Download /></el-icon> |
| | | 导出JSON |
| | | </el-button> |
| | | <el-button type="success" :disabled="!task" @click="handleExport('excel')"> |
| | | <el-icon><Document /></el-icon> |
| | | 导出Excel |
| | | </el-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-if="!task" class="empty-state"> |
| | | <el-empty description="暂无任务数据" /> |
| | | </div> |
| | | |
| | | <div v-else class="analysis-content"> |
| | | <!-- 总体结果 --> |
| | | <el-card class="overall-result-card" shadow="never"> |
| | | <template #header> |
| | | <div class="card-header"> |
| | | <span>总体结果</span> |
| | | <el-tag :type="getOverallStatusType()" size="large"> |
| | | {{ getOverallStatusLabel() }} |
| | | </el-tag> |
| | | </div> |
| | | </template> |
| | | <div class="result-stats"> |
| | | <div class="stat-item"> |
| | | <div class="stat-label">执行时间</div> |
| | | <div class="stat-value">{{ formatDuration(taskDuration) }}</div> |
| | | </div> |
| | | <div class="stat-item"> |
| | | <div class="stat-label">总步骤数</div> |
| | | <div class="stat-value">{{ task.totalSteps || 0 }}</div> |
| | | </div> |
| | | <div class="stat-item"> |
| | | <div class="stat-label">完成步骤</div> |
| | | <div class="stat-value success">{{ completedSteps }}</div> |
| | | </div> |
| | | <div class="stat-item"> |
| | | <div class="stat-label">失败步骤</div> |
| | | <div class="stat-value danger">{{ failedSteps }}</div> |
| | | </div> |
| | | <div class="stat-item"> |
| | | <div class="stat-label">成功率</div> |
| | | <div class="stat-value" :class="successRateClass"> |
| | | {{ successRate }}% |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </el-card> |
| | | |
| | | <!-- 进度条 --> |
| | | <el-card class="progress-card" shadow="never"> |
| | | <template #header> |
| | | <span>执行进度</span> |
| | | </template> |
| | | <el-progress |
| | | :percentage="progressPercentage" |
| | | :status="progressStatus" |
| | | :stroke-width="20" |
| | | :format="() => `${completedSteps}/${task.totalSteps || 0}`" |
| | | /> |
| | | </el-card> |
| | | |
| | | <!-- 步骤详情 --> |
| | | <el-card class="steps-card" shadow="never"> |
| | | <template #header> |
| | | <span>步骤执行详情</span> |
| | | </template> |
| | | <el-table |
| | | v-loading="stepsLoading" |
| | | :data="steps" |
| | | stripe |
| | | style="width: 100%" |
| | | > |
| | | <el-table-column type="index" label="序号" width="60" /> |
| | | <el-table-column prop="stepName" label="步骤名称" min-width="150" /> |
| | | <el-table-column prop="deviceId" label="设备ID" width="120" /> |
| | | <el-table-column prop="status" label="状态" width="100"> |
| | | <template #default="{ row }"> |
| | | <el-tag :type="getStepStatusType(row.status)"> |
| | | {{ getStepStatusLabel(row.status) }} |
| | | </el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="耗时" width="100"> |
| | | <template #default="{ row }"> |
| | | {{ formatDuration(row.durationMs) }} |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="重试次数" width="100"> |
| | | <template #default="{ row }"> |
| | | {{ row.retryCount || 0 }} |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="开始时间" min-width="160"> |
| | | <template #default="{ row }"> |
| | | {{ formatDateTime(row.startTime) }} |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="结束时间" min-width="160"> |
| | | <template #default="{ row }"> |
| | | {{ formatDateTime(row.endTime) }} |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="errorMessage" label="错误信息" min-width="200" show-overflow-tooltip /> |
| | | <el-table-column label="操作" width="120" fixed="right"> |
| | | <template #default="{ row }"> |
| | | <el-button |
| | | link |
| | | type="primary" |
| | | size="small" |
| | | @click="viewStepDetail(row)" |
| | | > |
| | | 查看详情 |
| | | </el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </el-card> |
| | | |
| | | <!-- 数据统计图表 --> |
| | | <el-card class="chart-card" shadow="never" v-if="steps.length > 0"> |
| | | <template #header> |
| | | <span>执行时间分布</span> |
| | | </template> |
| | | <div class="chart-container"> |
| | | <div class="chart-item" v-for="(step, index) in steps" :key="step.id"> |
| | | <div class="chart-bar"> |
| | | <div |
| | | class="bar-fill" |
| | | :class="getStepStatusClass(step.status)" |
| | | :style="{ width: getBarWidth(step.durationMs) + '%' }" |
| | | > |
| | | <span class="bar-label">{{ formatDuration(step.durationMs) }}</span> |
| | | </div> |
| | | </div> |
| | | <div class="chart-label">{{ step.stepName }}</div> |
| | | </div> |
| | | </div> |
| | | </el-card> |
| | | </div> |
| | | |
| | | <!-- 步骤详情对话框 --> |
| | | <el-dialog |
| | | v-model="detailDialogVisible" |
| | | :title="`步骤详情 - ${selectedStep?.stepName || ''}`" |
| | | width="60%" |
| | | > |
| | | <div v-if="selectedStep" class="step-detail-content"> |
| | | <el-descriptions :column="2" border> |
| | | <el-descriptions-item label="步骤名称"> |
| | | {{ selectedStep.stepName }} |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="设备ID"> |
| | | {{ selectedStep.deviceId }} |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="状态"> |
| | | <el-tag :type="getStepStatusType(selectedStep.status)"> |
| | | {{ getStepStatusLabel(selectedStep.status) }} |
| | | </el-tag> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="重试次数"> |
| | | {{ selectedStep.retryCount || 0 }} |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="开始时间"> |
| | | {{ formatDateTime(selectedStep.startTime) }} |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="结束时间"> |
| | | {{ formatDateTime(selectedStep.endTime) }} |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="耗时"> |
| | | {{ formatDuration(selectedStep.durationMs) }} |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="错误信息" v-if="selectedStep.errorMessage"> |
| | | {{ selectedStep.errorMessage }} |
| | | </el-descriptions-item> |
| | | </el-descriptions> |
| | | |
| | | <el-divider>输入数据</el-divider> |
| | | <el-input |
| | | v-model="selectedStepInputData" |
| | | type="textarea" |
| | | :rows="6" |
| | | readonly |
| | | /> |
| | | |
| | | <el-divider>输出数据</el-divider> |
| | | <el-input |
| | | v-model="selectedStepOutputData" |
| | | type="textarea" |
| | | :rows="6" |
| | | readonly |
| | | /> |
| | | </div> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, ref, watch } from 'vue' |
| | | import { ElMessage } from 'element-plus' |
| | | import { Refresh, Download, Document } from '@element-plus/icons-vue' |
| | | import { multiDeviceTaskApi } from '@/api/device/multiDeviceTask' |
| | | |
| | | const props = defineProps({ |
| | | task: { |
| | | type: Object, |
| | | default: null |
| | | } |
| | | }) |
| | | |
| | | const loading = ref(false) |
| | | const stepsLoading = ref(false) |
| | | const steps = ref([]) |
| | | const detailDialogVisible = ref(false) |
| | | const selectedStep = ref(null) |
| | | |
| | | const selectedStepInputData = computed(() => { |
| | | if (!selectedStep.value?.inputData) return '' |
| | | try { |
| | | return JSON.stringify(JSON.parse(selectedStep.value.inputData), null, 2) |
| | | } catch { |
| | | return selectedStep.value.inputData |
| | | } |
| | | }) |
| | | |
| | | const selectedStepOutputData = computed(() => { |
| | | if (!selectedStep.value?.outputData) return '' |
| | | try { |
| | | return JSON.stringify(JSON.parse(selectedStep.value.outputData), null, 2) |
| | | } catch { |
| | | return selectedStep.value.outputData |
| | | } |
| | | }) |
| | | |
| | | const taskDuration = computed(() => { |
| | | if (!props.task?.startTime || !props.task?.endTime) return 0 |
| | | try { |
| | | const start = new Date(props.task.startTime) |
| | | const end = new Date(props.task.endTime) |
| | | return end.getTime() - start.getTime() |
| | | } catch { |
| | | return 0 |
| | | } |
| | | }) |
| | | |
| | | const completedSteps = computed(() => { |
| | | return steps.value.filter(s => s.status === 'COMPLETED').length |
| | | }) |
| | | |
| | | const failedSteps = computed(() => { |
| | | return steps.value.filter(s => s.status === 'FAILED').length |
| | | }) |
| | | |
| | | const successRate = computed(() => { |
| | | if (steps.value.length === 0) return 0 |
| | | return Math.round((completedSteps.value / steps.value.length) * 100) |
| | | }) |
| | | |
| | | const successRateClass = computed(() => { |
| | | if (successRate.value >= 90) return 'success' |
| | | if (successRate.value >= 70) return 'warning' |
| | | return 'danger' |
| | | }) |
| | | |
| | | const progressPercentage = computed(() => { |
| | | if (!props.task?.totalSteps || props.task.totalSteps === 0) return 0 |
| | | return Math.round((completedSteps.value / props.task.totalSteps) * 100) |
| | | }) |
| | | |
| | | const progressStatus = computed(() => { |
| | | if (props.task?.status === 'COMPLETED') return 'success' |
| | | if (props.task?.status === 'FAILED') return 'exception' |
| | | return 'active' |
| | | }) |
| | | |
| | | const fetchSteps = async () => { |
| | | if (!props.task?.taskId) { |
| | | steps.value = [] |
| | | return |
| | | } |
| | | try { |
| | | stepsLoading.value = true |
| | | const { data } = await multiDeviceTaskApi.getTaskSteps(props.task.taskId) |
| | | steps.value = Array.isArray(data) ? data : (data?.data || []) |
| | | } catch (error) { |
| | | ElMessage.error(error?.message || '加载步骤详情失败') |
| | | steps.value = [] |
| | | } finally { |
| | | stepsLoading.value = false |
| | | } |
| | | } |
| | | |
| | | const handleRefresh = () => { |
| | | fetchSteps() |
| | | } |
| | | |
| | | const viewStepDetail = (step) => { |
| | | selectedStep.value = step |
| | | detailDialogVisible.value = true |
| | | } |
| | | |
| | | const handleExport = async (format) => { |
| | | if (!props.task) { |
| | | ElMessage.warning('请先选择任务') |
| | | return |
| | | } |
| | | try { |
| | | loading.value = true |
| | | // 构建导出数据 |
| | | const exportData = { |
| | | task: props.task, |
| | | steps: steps.value, |
| | | statistics: { |
| | | totalSteps: props.task.totalSteps || 0, |
| | | completedSteps: completedSteps.value, |
| | | failedSteps: failedSteps.value, |
| | | successRate: successRate.value, |
| | | duration: taskDuration.value |
| | | } |
| | | } |
| | | |
| | | if (format === 'json') { |
| | | const blob = new Blob([JSON.stringify(exportData, null, 2)], { |
| | | type: 'application/json' |
| | | }) |
| | | const url = URL.createObjectURL(blob) |
| | | const a = document.createElement('a') |
| | | a.href = url |
| | | a.download = `task_${props.task.taskId}_${Date.now()}.json` |
| | | a.click() |
| | | URL.revokeObjectURL(url) |
| | | ElMessage.success('导出成功') |
| | | } else if (format === 'excel') { |
| | | // TODO: 实现Excel导出 |
| | | ElMessage.info('Excel导出功能开发中') |
| | | } |
| | | } catch (error) { |
| | | ElMessage.error('导出失败: ' + error.message) |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | const getOverallStatusType = () => { |
| | | const status = props.task?.status?.toUpperCase() |
| | | if (status === 'COMPLETED') return 'success' |
| | | if (status === 'FAILED') return 'danger' |
| | | if (status === 'RUNNING') return 'warning' |
| | | return 'info' |
| | | } |
| | | |
| | | const getOverallStatusLabel = () => { |
| | | const status = props.task?.status?.toUpperCase() |
| | | const statusMap = { |
| | | 'COMPLETED': '已完成', |
| | | 'FAILED': '失败', |
| | | 'RUNNING': '执行中', |
| | | 'PENDING': '等待中', |
| | | 'CANCELLED': '已取消' |
| | | } |
| | | return statusMap[status] || status || '未知' |
| | | } |
| | | |
| | | const getStepStatusType = (status) => { |
| | | const s = (status || '').toUpperCase() |
| | | if (s === 'COMPLETED') return 'success' |
| | | if (s === 'FAILED') return 'danger' |
| | | if (s === 'RUNNING') return 'warning' |
| | | if (s === 'PENDING') return 'info' |
| | | return 'default' |
| | | } |
| | | |
| | | const getStepStatusLabel = (status) => { |
| | | const s = (status || '').toUpperCase() |
| | | const statusMap = { |
| | | 'COMPLETED': '已完成', |
| | | 'FAILED': '失败', |
| | | 'RUNNING': '执行中', |
| | | 'PENDING': '等待中', |
| | | 'SKIPPED': '已跳过' |
| | | } |
| | | return statusMap[s] || s || '未知' |
| | | } |
| | | |
| | | const getStepStatusClass = (status) => { |
| | | const s = (status || '').toUpperCase() |
| | | if (s === 'COMPLETED') return 'status-success' |
| | | if (s === 'FAILED') return 'status-danger' |
| | | if (s === 'RUNNING') return 'status-warning' |
| | | return 'status-info' |
| | | } |
| | | |
| | | const getBarWidth = (durationMs) => { |
| | | if (!durationMs || steps.value.length === 0) return 0 |
| | | const maxDuration = Math.max(...steps.value.map(s => s.durationMs || 0)) |
| | | if (maxDuration === 0) return 0 |
| | | return Math.min((durationMs / maxDuration) * 100, 100) |
| | | } |
| | | |
| | | const formatDuration = (ms) => { |
| | | if (!ms) return '-' |
| | | if (ms < 1000) return `${ms} ms` |
| | | if (ms < 60000) return `${(ms / 1000).toFixed(1)} s` |
| | | const minutes = Math.floor(ms / 60000) |
| | | const seconds = ((ms % 60000) / 1000).toFixed(0) |
| | | return `${minutes}分${seconds}秒` |
| | | } |
| | | |
| | | const formatDateTime = (dateTime) => { |
| | | if (!dateTime) return '-' |
| | | try { |
| | | const date = new Date(dateTime) |
| | | if (isNaN(date.getTime())) return dateTime |
| | | const year = date.getFullYear() |
| | | const month = String(date.getMonth() + 1).padStart(2, '0') |
| | | const day = String(date.getDate()).padStart(2, '0') |
| | | const hours = String(date.getHours()).padStart(2, '0') |
| | | const minutes = String(date.getMinutes()).padStart(2, '0') |
| | | const seconds = String(date.getSeconds()).padStart(2, '0') |
| | | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` |
| | | } catch { |
| | | return dateTime |
| | | } |
| | | } |
| | | |
| | | watch( |
| | | () => props.task, |
| | | () => { |
| | | fetchSteps() |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | |
| | | defineExpose({ |
| | | fetchSteps |
| | | }) |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .result-analysis { |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | padding: 20px; |
| | | box-shadow: 0 8px 32px rgba(15, 18, 63, 0.08); |
| | | } |
| | | |
| | | .panel-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | margin-bottom: 20px; |
| | | } |
| | | |
| | | .panel-header h3 { |
| | | margin: 0; |
| | | } |
| | | |
| | | .panel-header p { |
| | | margin: 4px 0 0; |
| | | color: #909399; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .panel-header .warning { |
| | | color: #f56c6c; |
| | | } |
| | | |
| | | .action-buttons { |
| | | display: flex; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .empty-state { |
| | | padding: 60px 0; |
| | | } |
| | | |
| | | .analysis-content { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 20px; |
| | | } |
| | | |
| | | .overall-result-card .card-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | } |
| | | |
| | | .result-stats { |
| | | display: grid; |
| | | grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); |
| | | gap: 20px; |
| | | } |
| | | |
| | | .stat-item { |
| | | text-align: center; |
| | | } |
| | | |
| | | .stat-label { |
| | | font-size: 13px; |
| | | color: #909399; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .stat-value { |
| | | font-size: 24px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .stat-value.success { |
| | | color: #67c23a; |
| | | } |
| | | |
| | | .stat-value.danger { |
| | | color: #f56c6c; |
| | | } |
| | | |
| | | .stat-value.warning { |
| | | color: #e6a23c; |
| | | } |
| | | |
| | | .progress-card { |
| | | margin-top: 0; |
| | | } |
| | | |
| | | .steps-card { |
| | | margin-top: 0; |
| | | } |
| | | |
| | | .chart-card { |
| | | margin-top: 0; |
| | | } |
| | | |
| | | .chart-container { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 12px; |
| | | padding: 20px 0; |
| | | } |
| | | |
| | | .chart-item { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .chart-bar { |
| | | flex: 1; |
| | | height: 32px; |
| | | background: #f0f2f5; |
| | | border-radius: 4px; |
| | | position: relative; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .bar-fill { |
| | | height: 100%; |
| | | border-radius: 4px; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: flex-end; |
| | | padding: 0 8px; |
| | | transition: width 0.3s; |
| | | min-width: 60px; |
| | | } |
| | | |
| | | .bar-fill.status-success { |
| | | background: linear-gradient(90deg, #67c23a 0%, #85ce61 100%); |
| | | } |
| | | |
| | | .bar-fill.status-danger { |
| | | background: linear-gradient(90deg, #f56c6c 0%, #f78989 100%); |
| | | } |
| | | |
| | | .bar-fill.status-warning { |
| | | background: linear-gradient(90deg, #e6a23c 0%, #ebb563 100%); |
| | | } |
| | | |
| | | .bar-fill.status-info { |
| | | background: linear-gradient(90deg, #909399 0%, #b1b3b8 100%); |
| | | } |
| | | |
| | | .bar-label { |
| | | color: #fff; |
| | | font-size: 12px; |
| | | font-weight: 500; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .chart-label { |
| | | width: 150px; |
| | | font-size: 13px; |
| | | color: #606266; |
| | | text-align: right; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .step-detail-content { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 20px; |
| | | } |
| | | |
| | | @media (max-width: 768px) { |
| | | .panel-header { |
| | | flex-direction: column; |
| | | align-items: flex-start; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .action-buttons { |
| | | width: 100%; |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | .result-stats { |
| | | grid-template-columns: repeat(2, 1fr); |
| | | } |
| | | } |
| | | </style> |
| | | |
| | |
| | | <h3>多设备测试编排</h3> |
| | | <p v-if="group">当前设备组:{{ group.groupName }}({{ group.deviceCount || '-' }} 台设备)</p> |
| | | <p v-else class="warning">请先在左侧选择一个设备组</p> |
| | | <p v-if="group && loadDeviceName" class="sub-info">上大车设备:{{ loadDeviceName }}</p> |
| | | <p v-if="group && loadDeviceName" class="sub-info">当前设备:{{ loadDeviceName }}</p> |
| | | </div> |
| | | <div class="action-buttons"> |
| | | <el-button |
| | |
| | | </div> |
| | | </div> |
| | | |
| | | <el-form :model="form" label-width="120px"> |
| | | <el-form-item label="玻璃ID列表"> |
| | | <el-form :model="form" label-width="120px" :rules="rules" ref="formRef"> |
| | | <el-form-item label="玻璃ID列表" prop="glassIds" required> |
| | | <el-input |
| | | v-model="glassIdsInput" |
| | | type="textarea" |
| | | :rows="4" |
| | | placeholder="请输入玻璃条码,支持多行或逗号分隔" |
| | | placeholder="请输入玻璃条码,支持多行或逗号分隔,每行一个或逗号分隔" |
| | | show-word-limit |
| | | :maxlength="5000" |
| | | /> |
| | | <div class="form-tip"> |
| | | 已输入 {{ glassIds.length }} 个玻璃ID |
| | | </div> |
| | | </el-form-item> |
| | | |
| | | <el-divider content-position="left">设备特定配置</el-divider> |
| | | |
| | | <el-form-item label="位置编码"> |
| | | <el-input v-model="form.positionCode" placeholder="例如:POS1" /> |
| | | <el-input |
| | | v-model="form.positionCode" |
| | | placeholder="例如:POS1" |
| | | clearable |
| | | /> |
| | | <div class="form-tip">上大车设备的位置编码</div> |
| | | </el-form-item> |
| | | |
| | | <el-form-item label="位置值"> |
| | | <el-input-number |
| | | v-model="form.positionValue" |
| | | :min="0" |
| | | :max="9999" |
| | | placeholder="位置数值" |
| | | /> |
| | | <div class="form-tip">上大车设备的位置数值</div> |
| | | </el-form-item> |
| | | |
| | | <el-form-item label="存储位置"> |
| | | <el-input-number v-model="form.storagePosition" :min="1" :max="200" /> |
| | | <el-input-number |
| | | v-model="form.storagePosition" |
| | | :min="1" |
| | | :max="200" |
| | | placeholder="存储位置编号" |
| | | /> |
| | | <div class="form-tip">玻璃存储设备的存储位置</div> |
| | | </el-form-item> |
| | | |
| | | <el-form-item label="处理类型"> |
| | | <el-select v-model="form.processType" placeholder="选择处理类型" clearable> |
| | | <el-option label="标准处理" :value="1" /> |
| | | <el-option label="快速处理" :value="2" /> |
| | | <el-option label="慢速处理" :value="3" /> |
| | | </el-select> |
| | | <div class="form-tip">大理片设备的处理类型</div> |
| | | </el-form-item> |
| | | |
| | | <el-divider content-position="left">执行配置</el-divider> |
| | | |
| | | <el-form-item label="执行间隔 (ms)"> |
| | | <el-input-number v-model="form.executionInterval" :min="100" :max="10000" /> |
| | | <el-input-number |
| | | v-model="form.executionInterval" |
| | | :min="100" |
| | | :max="10000" |
| | | :step="100" |
| | | placeholder="设备操作间隔时间" |
| | | /> |
| | | <div class="form-tip">每个设备操作之间的间隔时间(毫秒)</div> |
| | | </el-form-item> |
| | | |
| | | <el-form-item label="超时时间 (分钟)"> |
| | | <el-input-number |
| | | v-model="form.timeoutMinutes" |
| | | :min="1" |
| | | :max="60" |
| | | :step="1" |
| | | placeholder="任务超时时间" |
| | | /> |
| | | <div class="form-tip">任务执行的最大超时时间</div> |
| | | </el-form-item> |
| | | |
| | | <el-form-item label="重试次数"> |
| | | <el-input-number |
| | | v-model="form.retryCount" |
| | | :min="0" |
| | | :max="10" |
| | | :step="1" |
| | | placeholder="失败重试次数" |
| | | /> |
| | | <div class="form-tip">设备操作失败时的最大重试次数</div> |
| | | </el-form-item> |
| | | </el-form> |
| | | </div> |
| | |
| | | |
| | | const form = reactive({ |
| | | positionCode: '', |
| | | positionValue: null, |
| | | storagePosition: null, |
| | | executionInterval: 1000 |
| | | processType: null, |
| | | executionInterval: 1000, |
| | | timeoutMinutes: 30, |
| | | retryCount: 3 |
| | | }) |
| | | |
| | | const formRef = ref(null) |
| | | |
| | | const rules = { |
| | | glassIds: [ |
| | | { |
| | | validator: (rule, value, callback) => { |
| | | if (glassIds.value.length === 0) { |
| | | callback(new Error('请至少输入一个玻璃ID')) |
| | | } else if (glassIds.value.length > 100) { |
| | | callback(new Error('玻璃ID数量不能超过100个')) |
| | | } else { |
| | | // 验证玻璃ID格式 |
| | | const invalidIds = glassIds.value.filter(id => { |
| | | // 简单的格式验证:不能为空,长度在1-50之间 |
| | | return !id || id.length === 0 || id.length > 50 |
| | | }) |
| | | if (invalidIds.length > 0) { |
| | | callback(new Error(`存在无效的玻璃ID格式,请检查`)) |
| | | } else { |
| | | callback() |
| | | } |
| | | } |
| | | }, |
| | | trigger: 'blur' |
| | | } |
| | | ] |
| | | } |
| | | |
| | | const glassIdsInput = ref('') |
| | | const loading = ref(false) |
| | |
| | | ElMessage.warning('请先选择设备组') |
| | | return |
| | | } |
| | | |
| | | // 表单验证 |
| | | if (!formRef.value) return |
| | | try { |
| | | await formRef.value.validate() |
| | | } catch (error) { |
| | | ElMessage.warning('请检查表单输入') |
| | | return |
| | | } |
| | | |
| | | if (glassIds.value.length === 0) { |
| | | ElMessage.warning('请至少输入一个玻璃ID') |
| | | return |
| | | } |
| | | |
| | | try { |
| | | loading.value = true |
| | | await multiDeviceTaskApi.startTask({ |
| | | |
| | | // 构建任务参数 |
| | | const parameters = { |
| | | glassIds: glassIds.value, |
| | | executionInterval: form.executionInterval || 1000 |
| | | } |
| | | |
| | | // 添加可选参数 |
| | | if (form.positionCode) { |
| | | parameters.positionCode = form.positionCode |
| | | } |
| | | if (form.positionValue !== null) { |
| | | parameters.positionValue = form.positionValue |
| | | } |
| | | if (form.storagePosition !== null) { |
| | | parameters.storagePosition = form.storagePosition |
| | | } |
| | | if (form.processType !== null) { |
| | | parameters.processType = form.processType |
| | | } |
| | | if (form.timeoutMinutes) { |
| | | parameters.timeoutMinutes = form.timeoutMinutes |
| | | } |
| | | if (form.retryCount !== null) { |
| | | parameters.retryCount = form.retryCount |
| | | } |
| | | |
| | | // 异步启动任务,立即返回,不阻塞 |
| | | const response = await multiDeviceTaskApi.startTask({ |
| | | groupId: props.group.id || props.group.groupId, |
| | | parameters: { |
| | | glassIds: glassIds.value, |
| | | positionCode: form.positionCode || null, |
| | | storagePosition: form.storagePosition, |
| | | executionInterval: form.executionInterval |
| | | } |
| | | parameters |
| | | }) |
| | | ElMessage.success('任务已启动') |
| | | emit('task-started') |
| | | |
| | | const task = response?.data |
| | | if (task && task.taskId) { |
| | | ElMessage.success(`任务已启动(异步执行): ${task.taskId}`) |
| | | emit('task-started', task) |
| | | |
| | | // 立即刷新监控列表,显示新启动的任务 |
| | | setTimeout(() => { |
| | | emit('task-started') |
| | | }, 500) |
| | | |
| | | // 重置表单(保留部分配置),方便继续启动其他设备组 |
| | | glassIdsInput.value = '' |
| | | form.positionCode = '' |
| | | form.positionValue = null |
| | | form.storagePosition = null |
| | | form.processType = null |
| | | |
| | | // 提示用户可以继续启动其他设备组 |
| | | ElMessage.info('可以继续选择其他设备组启动测试,多个设备组将并行执行') |
| | | } else { |
| | | ElMessage.warning('任务启动响应异常') |
| | | } |
| | | } catch (error) { |
| | | ElMessage.error(error?.message || '任务启动失败') |
| | | } finally { |
| | |
| | | gap: 12px; |
| | | align-items: center; |
| | | } |
| | | |
| | | .form-tip { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | margin-top: 4px; |
| | | line-height: 1.4; |
| | | } |
| | | </style> |
| | | |