feat(mill): 添加UDP调试工具功能
- 在路由配置中新增tool模块和udp-debug页面 - 添加UDP通信相关依赖到ruoyi-mill模块 - 实现UdpProperties配置类并添加超时和重试参数 - 重构UdpSender实现重试机制和超时控制 - 创建application-mill.properties配置文件 - 定义IUdpService接口提供UDP通信服务 - 添加系统菜单初始化SQL脚本 - 实现前端API接口用于UDP配置和报文发送 - 开发UDP调试工具Vue组件界面 - 编写UDP调试工具快速启动指南文档
This commit is contained in:
@@ -24,6 +24,32 @@
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Swagger 注解 -->
|
||||
<dependency>
|
||||
<groupId>io.swagger</groupId>
|
||||
<artifactId>swagger-annotations</artifactId>
|
||||
<version>1.6.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JUnit 测试 -->
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Boot Test -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
package com.ruoyi.mill.controller;
|
||||
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.mill.udp.UdpProperties;
|
||||
import com.ruoyi.mill.udp.UdpServer;
|
||||
import com.ruoyi.mill.udp.UdpSender;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* UDP 通信控制器
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@Api(tags = "冷轧 - UDP通信")
|
||||
@RestController
|
||||
@RequestMapping("/mill/udp")
|
||||
public class UdpController extends BaseController {
|
||||
private static final Logger log = LoggerFactory.getLogger(UdpController.class);
|
||||
|
||||
@Autowired
|
||||
private UdpProperties udpProperties;
|
||||
|
||||
@Autowired
|
||||
private UdpServer udpServer;
|
||||
|
||||
@Autowired
|
||||
private UdpSender udpSender;
|
||||
|
||||
/**
|
||||
* 获取UDP配置
|
||||
*/
|
||||
@ApiOperation("获取UDP配置")
|
||||
@GetMapping("/config")
|
||||
public AjaxResult config() {
|
||||
return success(udpProperties);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新UDP配置
|
||||
*/
|
||||
@ApiOperation("更新UDP配置")
|
||||
@PutMapping("/config")
|
||||
public AjaxResult updateConfig(@RequestBody UdpProperties properties) {
|
||||
// 验证端口范围
|
||||
if (properties.getLocalPort() < 1024 || properties.getLocalPort() > 65535) {
|
||||
return error("本地端口必须在1024-65535之间");
|
||||
}
|
||||
if (properties.getRemotePort() < 1024 || properties.getRemotePort() > 65535) {
|
||||
return error("目标端口必须在1024-65535之间");
|
||||
}
|
||||
if (StringUtils.isBlank(properties.getRemoteHost())) {
|
||||
return error("目标IP不能为空");
|
||||
}
|
||||
|
||||
// 这里应该调用服务层更新配置
|
||||
// udpPropertiesService.updateConfig(properties);
|
||||
|
||||
return success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送UDP报文
|
||||
*/
|
||||
@ApiOperation("发送UDP报文")
|
||||
@PostMapping("/send")
|
||||
public AjaxResult sendTelegram(@RequestBody Map<String, Object> requestData) {
|
||||
|
||||
try {
|
||||
String tcNo = (String) requestData.get("tcNo");
|
||||
Object payloadObj = requestData.get("payload");
|
||||
|
||||
byte[] payload;
|
||||
if (payloadObj instanceof java.util.List) {
|
||||
java.util.List<?> list = (java.util.List<?>) payloadObj;
|
||||
payload = new byte[list.size()];
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
payload[i] = ((Number) list.get(i)).byteValue();
|
||||
}
|
||||
} else if (payloadObj instanceof int[]) {
|
||||
int[] intArray = (int[]) payloadObj;
|
||||
payload = new byte[intArray.length];
|
||||
for (int i = 0; i < intArray.length; i++) {
|
||||
payload[i] = (byte) intArray[i];
|
||||
}
|
||||
} else if (payloadObj instanceof byte[]) {
|
||||
payload = (byte[]) payloadObj;
|
||||
} else if (payloadObj instanceof String) {
|
||||
payload = ((String) payloadObj).getBytes(StandardCharsets.UTF_8);
|
||||
} else {
|
||||
return error("无效的payload类型: " + (payloadObj == null ? "null" : payloadObj.getClass().getName()));
|
||||
}
|
||||
|
||||
boolean result = udpSender.send(tcNo, payload);
|
||||
|
||||
if (result) {
|
||||
log.info("[UDP-SEND] 成功发送电文 {},长度: {} bytes", tcNo, payload.length);
|
||||
return success();
|
||||
} else {
|
||||
log.warn("[UDP-SEND] 发送电文失败 {},长度: {} bytes", tcNo, payload.length);
|
||||
return error("发送失败,请检查网络连接");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[UDP-SEND] 发送电文异常: {}", e.getMessage());
|
||||
return error("发送异常: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析电文数据
|
||||
*/
|
||||
@ApiOperation("解析电文数据")
|
||||
@PostMapping("/parse")
|
||||
public AjaxResult parseTelegram(@RequestBody Map<String, Object> requestData) {
|
||||
|
||||
try {
|
||||
String tcNo = (String) requestData.get("tcNo");
|
||||
Object payloadObj = requestData.get("payload");
|
||||
|
||||
byte[] payload;
|
||||
if (payloadObj instanceof java.util.List) {
|
||||
java.util.List<?> list = (java.util.List<?>) payloadObj;
|
||||
payload = new byte[list.size()];
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
payload[i] = ((Number) list.get(i)).byteValue();
|
||||
}
|
||||
} else if (payloadObj instanceof int[]) {
|
||||
int[] intArray = (int[]) payloadObj;
|
||||
payload = new byte[intArray.length];
|
||||
for (int i = 0; i < intArray.length; i++) {
|
||||
payload[i] = (byte) intArray[i];
|
||||
}
|
||||
} else if (payloadObj instanceof byte[]) {
|
||||
payload = (byte[]) payloadObj;
|
||||
} else if (payloadObj instanceof String) {
|
||||
payload = ((String) payloadObj).getBytes(StandardCharsets.UTF_8);
|
||||
} else {
|
||||
return error("无效的payload类型: " + (payloadObj == null ? "null" : payloadObj.getClass().getName()));
|
||||
}
|
||||
|
||||
// 根据电文号进行解析
|
||||
String parsedResult = parseTelegramData(tcNo, payload);
|
||||
|
||||
return success(parsedResult);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[TELEGRAM-PARSE] 解析异常: {}", e.getMessage());
|
||||
return error("解析失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取电文历史记录
|
||||
*/
|
||||
@ApiOperation("获取电文历史记录")
|
||||
@GetMapping("/history")
|
||||
public AjaxResult telegramHistory(
|
||||
@RequestParam(defaultValue = "1") Integer pageNum,
|
||||
@RequestParam(defaultValue = "50") Integer pageSize) {
|
||||
|
||||
// TODO: 从数据库或缓存中获取历史记录
|
||||
// 这里返回模拟数据
|
||||
return success(getMockHistory(pageNum, pageSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取电文统计信息
|
||||
*/
|
||||
@ApiOperation("获取电文统计信息")
|
||||
@GetMapping("/stats")
|
||||
public AjaxResult telegramStats() {
|
||||
// TODO: 从数据库或缓存中获取统计数据
|
||||
// 这里返回模拟数据
|
||||
return success(getMockStats());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析电文数据(内部方法)
|
||||
*/
|
||||
private String parseTelegramData(String tcNo, byte[] payload) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
|
||||
switch (tcNo) {
|
||||
case "2FK101":
|
||||
result.append("电文类型: 作业命令信息\n");
|
||||
result.append("电文号: ").append(tcNo).append("\n");
|
||||
result.append("长度: ").append(payload.length).append(" bytes\n");
|
||||
result.append("时间: ").append(java.time.LocalDateTime.now()).append("\n");
|
||||
break;
|
||||
|
||||
case "K12F03":
|
||||
result.append("电文类型: 生产信息电文\n");
|
||||
result.append("电文号: ").append(tcNo).append("\n");
|
||||
result.append("长度: ").append(payload.length).append(" bytes\n");
|
||||
result.append("时间: ").append(java.time.LocalDateTime.now()).append("\n");
|
||||
break;
|
||||
|
||||
default:
|
||||
result.append("未知电文号: ").append(tcNo).append("\n");
|
||||
result.append("长度: ").append(payload.length).append(" bytes\n");
|
||||
result.append("原始数据: ");
|
||||
for (int i = 0; i < Math.min(payload.length, 32); i++) {
|
||||
result.append(String.format("%02X ", payload[i]));
|
||||
}
|
||||
if (payload.length > 32) {
|
||||
result.append("...");
|
||||
}
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成模拟历史记录数据
|
||||
*/
|
||||
private Object getMockHistory(int pageNum, int pageSize) {
|
||||
// 这里应该从数据库查询真实数据
|
||||
// 返回模拟数据用于演示
|
||||
|
||||
java.util.List<Object> mockData = java.util.Arrays.asList(
|
||||
createMockRecord(1L, "2FK101", "IN", "2024-04-30 10:30:15", 128),
|
||||
createMockRecord(2L, "K12F03", "OUT", "2024-04-30 10:30:20", 96),
|
||||
createMockRecord(3L, "2FK102", "IN", "2024-04-30 10:30:25", 64),
|
||||
createMockRecord(4L, "K12F01", "OUT", "2024-04-30 10:30:30", 80)
|
||||
);
|
||||
|
||||
int total = mockData.size();
|
||||
int fromIndex = (pageNum - 1) * pageSize;
|
||||
int toIndex = Math.min(fromIndex + pageSize, total);
|
||||
|
||||
if (fromIndex >= total) {
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
|
||||
java.util.Map<String, Object> result = new java.util.HashMap<>();
|
||||
result.put("rows", mockData.subList(fromIndex, toIndex));
|
||||
result.put("total", total);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建模拟记录
|
||||
*/
|
||||
private java.util.Map<String, Object> createMockRecord(
|
||||
Long id, String tcNo, String direction,
|
||||
String timestamp, Integer payloadLength) {
|
||||
|
||||
java.util.Map<String, Object> record = new java.util.HashMap<>();
|
||||
record.put("id", id);
|
||||
record.put("tcNo", tcNo);
|
||||
record.put("direction", direction);
|
||||
record.put("timestamp", timestamp);
|
||||
record.put("payloadLength", payloadLength);
|
||||
record.put("status", "成功");
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成模拟统计数据
|
||||
*/
|
||||
private java.util.Map<String, Object> getMockStats() {
|
||||
java.util.Map<String, Object> stats = new java.util.HashMap<>();
|
||||
stats.put("todayReceived", 25);
|
||||
stats.put("totalReceived", 1247);
|
||||
stats.put("successRate", 98);
|
||||
stats.put("avgDelay", 150);
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ruoyi.mill.service;
|
||||
|
||||
import com.ruoyi.mill.udp.UdpProperties;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* UDP 通信服务接口
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public interface IUdpService {
|
||||
|
||||
/**
|
||||
* 获取UDP配置
|
||||
*/
|
||||
UdpProperties getUdpConfig();
|
||||
|
||||
/**
|
||||
* 更新UDP配置
|
||||
*/
|
||||
AjaxResult updateUdpConfig(UdpProperties properties);
|
||||
|
||||
/**
|
||||
* 发送UDP报文
|
||||
*/
|
||||
boolean sendTelegram(String tcNo, byte[] payload);
|
||||
|
||||
/**
|
||||
* 获取电文历史记录
|
||||
*/
|
||||
List<Map<String, Object>> getTelegramHistory(int pageNum, int pageSize);
|
||||
|
||||
/**
|
||||
* 获取电文统计数据
|
||||
*/
|
||||
Map<String, Object> getTelegramStats();
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.ruoyi.mill.service.impl;
|
||||
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.mill.udp.UdpProperties;
|
||||
import com.ruoyi.mill.udp.UdpServer;
|
||||
import com.ruoyi.mill.udp.UdpSender;
|
||||
import com.ruoyi.mill.service.IUdpService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* UDP 通信服务实现
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@Service
|
||||
public class UdpServiceImpl implements IUdpService {
|
||||
private static final Logger log = LoggerFactory.getLogger(UdpServiceImpl.class);
|
||||
|
||||
@Autowired
|
||||
private UdpProperties udpProperties;
|
||||
|
||||
@Autowired
|
||||
private UdpServer udpServer;
|
||||
|
||||
@Autowired
|
||||
private UdpSender udpSender;
|
||||
|
||||
/**
|
||||
* 获取UDP配置
|
||||
*/
|
||||
@Override
|
||||
public UdpProperties getUdpConfig() {
|
||||
return udpProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新UDP配置
|
||||
*/
|
||||
@Override
|
||||
public AjaxResult updateUdpConfig(UdpProperties properties) {
|
||||
try {
|
||||
// 这里应该验证配置并保存到数据库
|
||||
// 暂时只是返回成功
|
||||
|
||||
log.info("[UDP-CONFIG] 更新UDP配置: 本地端口={}, 目标IP={}:{}",
|
||||
properties.getLocalPort(),
|
||||
properties.getRemoteHost(),
|
||||
properties.getRemotePort());
|
||||
|
||||
return AjaxResult.success();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[UDP-CONFIG] 更新配置失败", e);
|
||||
return AjaxResult.error("更新配置失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送UDP报文
|
||||
*/
|
||||
@Override
|
||||
public boolean sendTelegram(String tcNo, byte[] payload) {
|
||||
try {
|
||||
boolean result = udpSender.send(tcNo, payload);
|
||||
if (result) {
|
||||
log.info("[UDP-SEND] 成功发送电文 {},长度: {} bytes", tcNo, payload.length);
|
||||
} else {
|
||||
log.warn("[UDP-SEND] 发送电文失败 {},长度: {} bytes", tcNo, payload.length);
|
||||
}
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[UDP-SEND] 发送电文异常 {}: {}", tcNo, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取电文历史记录
|
||||
*/
|
||||
@Override
|
||||
public List<Map<String, Object>> getTelegramHistory(int pageNum, int pageSize) {
|
||||
// TODO: 从数据库查询真实的历史记录
|
||||
// 这里返回模拟数据用于演示
|
||||
|
||||
List<Map<String, Object>> mockData = new ArrayList<>();
|
||||
|
||||
// 生成一些模拟的历史记录
|
||||
for (int i = 1; i <= 20; i++) {
|
||||
Map<String, Object> record = new HashMap<>();
|
||||
record.put("id", i);
|
||||
record.put("tcNo", getRandomTcNo());
|
||||
record.put("direction", Math.random() > 0.5 ? "IN" : "OUT");
|
||||
record.put("timestamp", generateMockTimestamp(i));
|
||||
record.put("payloadLength", new Random().nextInt(200) + 32);
|
||||
record.put("status", "成功");
|
||||
|
||||
mockData.add(record);
|
||||
}
|
||||
|
||||
// 简单的分页逻辑
|
||||
int fromIndex = (pageNum - 1) * pageSize;
|
||||
int toIndex = Math.min(fromIndex + pageSize, mockData.size());
|
||||
|
||||
if (fromIndex >= mockData.size()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return mockData.subList(fromIndex, toIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取电文统计数据
|
||||
*/
|
||||
@Override
|
||||
public Map<String, Object> getTelegramStats() {
|
||||
// TODO: 从数据库查询真实的统计数据
|
||||
// 这里返回模拟数据用于演示
|
||||
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.put("todayReceived", 25 + new Random().nextInt(10));
|
||||
stats.put("totalReceived", 1247 + new Random().nextInt(100));
|
||||
stats.put("successRate", 95 + new Random().nextInt(5));
|
||||
stats.put("avgDelay", 120 + new Random().nextInt(60));
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取随机电文号(用于模拟)
|
||||
*/
|
||||
private String getRandomTcNo() {
|
||||
String[] tcNos = {"2FK101", "2FK102", "2FK103", "2FK104", "K12F01", "K12F02", "K12F03"};
|
||||
return tcNos[new Random().nextInt(tcNos.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成模拟时间戳
|
||||
*/
|
||||
private String generateMockTimestamp(int index) {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.add(Calendar.MINUTE, -index * 5); // 每5分钟一条记录
|
||||
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||
return sdf.format(calendar.getTime());
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,12 @@ public class UdpProperties {
|
||||
/** 接收缓冲区大小(字节) */
|
||||
private int bufferSize = 4096;
|
||||
|
||||
/** 超时时间(毫秒) */
|
||||
private int timeout = 5000;
|
||||
|
||||
/** 重试次数 */
|
||||
private int retryCount = 3;
|
||||
|
||||
public int getLocalPort() { return localPort; }
|
||||
public void setLocalPort(int p) { this.localPort = p; }
|
||||
public String getRemoteHost() { return remoteHost; }
|
||||
@@ -30,4 +36,8 @@ public class UdpProperties {
|
||||
public void setRemotePort(int p) { this.remotePort = p; }
|
||||
public int getBufferSize() { return bufferSize; }
|
||||
public void setBufferSize(int s) { this.bufferSize = s; }
|
||||
public int getTimeout() { return timeout; }
|
||||
public void setTimeout(int t) { this.timeout = t; }
|
||||
public int getRetryCount() { return retryCount; }
|
||||
public void setRetryCount(int r) { this.retryCount = r; }
|
||||
}
|
||||
|
||||
@@ -41,22 +41,50 @@ public class UdpSender {
|
||||
send(TelegramSchema.ID_K12F03, TelegramCodec.encode(TelegramSchema.SCHEMA_K12F03, data));
|
||||
}
|
||||
|
||||
private void send(String tcNo, byte[] payload) {
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
// 帧 = 6字节电文号 + payload
|
||||
byte[] tcNoBytes = Arrays.copyOf(
|
||||
tcNo.getBytes(StandardCharsets.US_ASCII), 6);
|
||||
byte[] frame = new byte[6 + payload.length];
|
||||
System.arraycopy(tcNoBytes, 0, frame, 0, 6);
|
||||
System.arraycopy(payload, 0, frame, 6, payload.length);
|
||||
/**
|
||||
* 发送UDP报文(供控制器调用)
|
||||
*/
|
||||
public boolean send(String tcNo, byte[] payload) {
|
||||
try {
|
||||
int retryCount = props.getRetryCount();
|
||||
int timeout = props.getTimeout();
|
||||
|
||||
for (int i = 0; i < retryCount; i++) {
|
||||
try {
|
||||
DatagramSocket socket = new DatagramSocket();
|
||||
socket.setSoTimeout(timeout);
|
||||
|
||||
// 帧 = 6字节电文号 + payload
|
||||
byte[] tcNoBytes = Arrays.copyOf(
|
||||
tcNo.getBytes(StandardCharsets.US_ASCII), 6);
|
||||
byte[] frame = new byte[6 + payload.length];
|
||||
System.arraycopy(tcNoBytes, 0, frame, 0, 6);
|
||||
System.arraycopy(payload, 0, frame, 6, payload.length);
|
||||
|
||||
InetAddress addr = InetAddress.getByName(props.getRemoteHost());
|
||||
DatagramPacket pkt = new DatagramPacket(frame, frame.length, addr, props.getRemotePort());
|
||||
socket.send(pkt);
|
||||
socket.close();
|
||||
|
||||
log.info("[UDP-SEND] tcNo={} -> {}:{} frameLen={}", tcNo,
|
||||
props.getRemoteHost(), props.getRemotePort(), frame.length);
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("[UDP-SEND] 第{}次重试失败 tcNo={}: {}", i + 1, tcNo, e.getMessage());
|
||||
if (i == retryCount - 1) {
|
||||
throw e;
|
||||
}
|
||||
Thread.sleep(100); // 短暂延迟后重试
|
||||
}
|
||||
}
|
||||
|
||||
InetAddress addr = InetAddress.getByName(props.getRemoteHost());
|
||||
DatagramPacket pkt = new DatagramPacket(frame, frame.length, addr, props.getRemotePort());
|
||||
socket.send(pkt);
|
||||
log.info("[UDP-SEND] tcNo={} -> {}:{} frameLen={}", tcNo,
|
||||
props.getRemoteHost(), props.getRemotePort(), frame.length);
|
||||
} catch (Exception e) {
|
||||
log.error("[UDP-SEND] 发送失败 tcNo={}", tcNo, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
10
ruoyi-mill/src/main/resources/application-mill.properties
Normal file
10
ruoyi-mill/src/main/resources/application-mill.properties
Normal file
@@ -0,0 +1,10 @@
|
||||
# UDP 通信配置
|
||||
mill.udp.local-port=9001
|
||||
mill.udp.remote-host=127.0.0.1
|
||||
mill.udp.remote-port=9000
|
||||
mill.udp.buffer-size=8192
|
||||
mill.udp.timeout=5000
|
||||
mill.udp.retry-count=3
|
||||
|
||||
# 日志级别
|
||||
logging.level.com.ruoyi.mill=DEBUG
|
||||
82
ruoyi-mill/src/test/java/com/ruoyi/mill/UdpDebugTest.java
Normal file
82
ruoyi-mill/src/test/java/com/ruoyi/mill/UdpDebugTest.java
Normal file
@@ -0,0 +1,82 @@
|
||||
package com.ruoyi.mill;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/**
|
||||
* UDP 调试工具单元测试
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@SpringBootTest
|
||||
public class UdpDebugTest {
|
||||
|
||||
/**
|
||||
* 测试电文数据格式
|
||||
*/
|
||||
@Test
|
||||
public void testTelegramDataFormat() {
|
||||
// 测试2FK101电文数据
|
||||
String sample2FK101 = "{\n" +
|
||||
" \"PLAN_NO\": \"P20240001\",\n" +
|
||||
" \"MAT_SEQ_NO\": \"001\",\n" +
|
||||
" \"UNIT_CODE\": \"L2\",\n" +
|
||||
" \"PLAN_TYPE\": \"N\",\n" +
|
||||
" \"IN_MAT_NO\": \"COIL_001\",\n" +
|
||||
" \"IN_MAT_THICK\": 2.500,\n" +
|
||||
" \"IN_MAT_WIDTH\": 1250.000,\n" +
|
||||
" \"REMARK\": \"冷轧产品\"\n" +
|
||||
"}";
|
||||
|
||||
System.out.println("2FK101 示例数据:");
|
||||
System.out.println(sample2FK101);
|
||||
|
||||
// 测试K12F03电文数据
|
||||
String sampleK12F03 = "{\n" +
|
||||
" \"FLAG\": \"A\",\n" +
|
||||
" \"PLAN_NO\": \"P20240001\",\n" +
|
||||
" \"SEQ_NO\": 1,\n" +
|
||||
" \"UNIT_CODE\": \"L2\",\n" +
|
||||
" \"IN_MAT_NO\": \"COIL_001\",\n" +
|
||||
" \"OUT_MAT_NO\": \"COIL_001_OUT\",\n" +
|
||||
" \"OUT_MAT_ACT_THICK\": 1.200,\n" +
|
||||
" \"START_PROD_TIME\": \"20240430103000\",\n" +
|
||||
" \"END_PROD_TIME\": \"20240430114530\"\n" +
|
||||
"}";
|
||||
|
||||
System.out.println("\nK12F03 示例数据:");
|
||||
System.out.println(sampleK12F03);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试十六进制数据转换
|
||||
*/
|
||||
@Test
|
||||
public void testHexConversion() {
|
||||
// 模拟电文号 2FK101 (6字节)
|
||||
String tcNo = "2FK101";
|
||||
byte[] tcNoBytes = new byte[6];
|
||||
System.arraycopy(tcNo.getBytes(), 0, tcNoBytes, 0, Math.min(tcNo.length(), 6));
|
||||
|
||||
// 模拟负载数据 (JSON字符串)
|
||||
String payloadJson = "{\"PLAN_NO\":\"P20240001\",\"IN_MAT_NO\":\"COIL_001\"}";
|
||||
byte[] payloadBytes = payloadJson.getBytes();
|
||||
|
||||
// 构造完整帧
|
||||
byte[] frame = new byte[6 + payloadBytes.length];
|
||||
System.arraycopy(tcNoBytes, 0, frame, 0, 6);
|
||||
System.arraycopy(payloadBytes, 0, frame, 6, payloadBytes.length);
|
||||
|
||||
System.out.println("电文帧结构:");
|
||||
System.out.println("电文号: " + new String(tcNoBytes).trim());
|
||||
System.out.println("负载长度: " + payloadBytes.length + " bytes");
|
||||
System.out.println("总帧长: " + frame.length + " bytes");
|
||||
|
||||
// 显示十六进制
|
||||
System.out.print("帧内容(HEX): ");
|
||||
for (byte b : frame) {
|
||||
System.out.printf("%02X ", b);
|
||||
}
|
||||
System.out.println();
|
||||
}
|
||||
}
|
||||
42
ruoyi-ui/src/api/mill/udp.js
Normal file
42
ruoyi-ui/src/api/mill/udp.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// UDP 服务器配置
|
||||
export function getUdpConfig() {
|
||||
return request({ url: '/mill/udp/config', method: 'get' })
|
||||
}
|
||||
|
||||
export function updateUdpConfig(data) {
|
||||
return request({ url: '/mill/udp/config', method: 'put', data })
|
||||
}
|
||||
|
||||
// 发送 UDP 报文
|
||||
export function sendTelegram(data) {
|
||||
return request({
|
||||
url: '/mill/udp/send',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 获取电文历史记录
|
||||
export function getTelegramHistory(query) {
|
||||
return request({
|
||||
url: '/mill/udp/history',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 获取当前电文统计
|
||||
export function getTelegramStats() {
|
||||
return request({ url: '/mill/udp/stats', method: 'get' })
|
||||
}
|
||||
|
||||
// 解析电文数据
|
||||
export function parseTelegram(tcNo, payload) {
|
||||
return request({
|
||||
url: '/mill/udp/parse',
|
||||
method: 'post',
|
||||
data: { tcNo, payload }
|
||||
})
|
||||
}
|
||||
@@ -205,19 +205,32 @@ export const constantRoutes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/mill',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
children: [
|
||||
{
|
||||
path: 'roll',
|
||||
component: () => import('@/views/mill/roll'),
|
||||
name: 'MillRoll',
|
||||
meta: { title: '轧辊管理', icon: '' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/mill',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
children: [
|
||||
{
|
||||
path: 'roll',
|
||||
component: () => import('@/views/mill/roll'),
|
||||
name: 'MillRoll',
|
||||
meta: { title: '轧辊管理', icon: '' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/tool',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
children: [
|
||||
{
|
||||
path: 'udp-debug',
|
||||
component: () => import('@/views/tool/udp-debug'),
|
||||
name: 'UdpDebug',
|
||||
meta: { title: 'UDP调试', icon: '' }
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
// 动态路由,基于用户权限动态去加载
|
||||
|
||||
703
ruoyi-ui/src/views/tool/udp-debug.vue
Normal file
703
ruoyi-ui/src/views/tool/udp-debug.vue
Normal file
@@ -0,0 +1,703 @@
|
||||
<template>
|
||||
<div class="udp-debug-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2>UDP 报文调试工具</h2>
|
||||
<p class="subtitle">L3-L2 电文收发测试与解析工具</p>
|
||||
</div>
|
||||
|
||||
<!-- 配置面板 -->
|
||||
<el-card class="config-panel">
|
||||
<div slot="header" class="clearfix">
|
||||
<span>服务器配置</span>
|
||||
</div>
|
||||
<el-form :model="configForm" label-width="120px" size="small">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="本地端口">
|
||||
<el-input-number v-model="configForm.localPort" :min="1024" :max="65535" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="目标端口">
|
||||
<el-input-number v-model="configForm.targetPort" :min="1024" :max="65535" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="目标IP">
|
||||
<el-input v-model="configForm.targetIp" placeholder="192.168.1.100" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="缓冲区大小">
|
||||
<el-input-number v-model="configForm.bufferSize" :min="1024" :max="65536" :step="1024" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="超时时间(ms)">
|
||||
<el-input-number v-model="configForm.timeout" :min="100" :max="10000" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="重试次数">
|
||||
<el-input-number v-model="configForm.retryCount" :min="0" :max="10" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div style="text-align: right;">
|
||||
<el-button type="primary" size="mini" @click="saveConfig">保存配置</el-button>
|
||||
<el-button size="mini" @click="loadConfig">加载配置</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 主操作区域 -->
|
||||
<el-row :gutter="20" class="main-area">
|
||||
<!-- 左侧:报文发送 -->
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<div slot="header" class="clearfix">
|
||||
<span>报文发送</span>
|
||||
<el-tag size="mini" :type="connectionStatus === 'connected' ? 'success' : 'danger'" style="float: right;">
|
||||
{{ connectionStatusText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 电文选单 -->
|
||||
<div class="section">
|
||||
<label class="section-label">电文号选择:</label>
|
||||
<el-select v-model="selectedTcNo" placeholder="请选择电文号" style="width: 200px;">
|
||||
<el-option label="2FK101 - 作业命令信息" value="2FK101" />
|
||||
<el-option label="2FK102 - 作业命令撤销" value="2FK102" />
|
||||
<el-option label="2FK103 - 作业命令应答" value="2FK103" />
|
||||
<el-option label="2FK104 - 产出信息应答" value="2FK104" />
|
||||
<el-option label="K12F01 - 计划信息应答" value="K12F01" />
|
||||
<el-option label="K12F02 - 计划钢卷删除" value="K12F02" />
|
||||
<el-option label="K12F03 - 生产信息电文" value="K12F03" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 数据输入 -->
|
||||
<div class="data-input-section">
|
||||
<el-tabs v-model="activeTab" type="card">
|
||||
<el-tab-pane label="JSON格式" name="json">
|
||||
<el-input
|
||||
v-model="telegramData.json"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder='请输入JSON格式的报文数据'
|
||||
style="font-family: monospace; font-size: 12px;"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="十六进制" name="hex">
|
||||
<el-input
|
||||
v-model="telegramData.hex"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder='请输入十六进制格式的报文数据 (如: 32464B313031...)'
|
||||
style="font-family: monospace; font-size: 12px;"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="表单填写" name="form">
|
||||
<el-form :model="telegramForm" size="small" label-width="100px">
|
||||
<!-- 这里可以根据选择的电文号动态生成表单字段 -->
|
||||
<div v-for="field in getFieldsForTcNo(selectedTcNo)" :key="field.name">
|
||||
<el-form-item :label="field.label">
|
||||
<el-input
|
||||
v-if="field.type === 'string'"
|
||||
v-model="telegramForm[field.name]"
|
||||
:placeholder="field.placeholder || ''"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="telegramForm[field.name]"
|
||||
:precision="field.precision || 0"
|
||||
:step="field.step || 1"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="field.type === 'float'"
|
||||
v-model="telegramForm[field.name]"
|
||||
:placeholder="field.placeholder || ''"
|
||||
@blur="formatFloat(field.name, telegramForm[field.name])"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 发送按钮 -->
|
||||
<div class="send-section">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="sending"
|
||||
:disabled="!canSend"
|
||||
@click="sendTelegram"
|
||||
>
|
||||
{{ sending ? '发送中...' : '发送报文' }}
|
||||
</el-button>
|
||||
<el-button size="mini" @click="generateSample">生成示例数据</el-button>
|
||||
<el-button size="mini" @click="clearData">清空数据</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右侧:接收记录 -->
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<div slot="header" class="clearfix">
|
||||
<span>接收记录</span>
|
||||
<div style="float: right;">
|
||||
<el-button-group>
|
||||
<el-button size="mini" icon="el-icon-refresh" @click="refreshHistory">刷新</el-button>
|
||||
<el-button size="mini" icon="el-icon-delete" @click="clearHistory">清空</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-section">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="6">
|
||||
<el-statistic title="今日接收" :value="stats.todayReceived" />
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-statistic title="总接收数" :value="stats.totalReceived" />
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-statistic title="成功率" :value="stats.successRate + '%'" />
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-statistic title="平均延迟" :value="stats.avgDelay + 'ms'" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 历史记录列表 -->
|
||||
<el-table
|
||||
:data="historyList"
|
||||
border
|
||||
size="small"
|
||||
height="400"
|
||||
style="margin-top: 10px;"
|
||||
>
|
||||
<el-table-column prop="id" label="#" width="40" align="center" />
|
||||
<el-table-column prop="tcNo" label="电文号" width="80" align="center" />
|
||||
<el-table-column prop="direction" label="方向" width="60" align="center">
|
||||
<template slot-scope="{ row }">
|
||||
<el-tag :type="row.direction === 'IN' ? 'success' : 'warning'" size="mini">
|
||||
{{ row.direction === 'IN' ? '接收' : '发送' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timestamp" label="时间" width="140" />
|
||||
<el-table-column prop="payloadLength" label="长度" width="60" align="center" />
|
||||
<el-table-column prop="status" label="状态" width="70" align="center">
|
||||
<template slot-scope="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" size="mini">
|
||||
{{ row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template slot-scope="{ row }">
|
||||
<el-button size="mini" type="text" @click="viewDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog title="报文详情" :visible.sync="detailDialogVisible" width="80%">
|
||||
<div v-if="currentDetail" class="detail-content">
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="电文号">{{ currentDetail.tcNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="方向">{{ currentDetail.direction === 'IN' ? '接收' : '发送' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="时间">{{ currentDetail.timestamp }}</el-descriptions-item>
|
||||
<el-descriptions-item label="长度">{{ currentDetail.payloadLength }} bytes</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ currentDetail.status }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="payload-section">
|
||||
<h4>原始数据:</h4>
|
||||
<el-input
|
||||
v-model="currentDetail.rawPayload"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
readonly
|
||||
style="font-family: monospace;"
|
||||
/>
|
||||
|
||||
<h4>解析结果:</h4>
|
||||
<el-table :data="currentDetail.parsedFields" border size="small" height="200">
|
||||
<el-table-column prop="name" label="字段名" width="120" />
|
||||
<el-table-column prop="value" label="值" />
|
||||
<el-table-column prop="type" label="类型" width="80" />
|
||||
<el-table-column prop="length" label="长度" width="60" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getUdpConfig,
|
||||
updateUdpConfig,
|
||||
sendTelegram,
|
||||
getTelegramHistory,
|
||||
getTelegramStats,
|
||||
parseTelegram
|
||||
} from '@/api/mill/udp'
|
||||
|
||||
export default {
|
||||
name: 'UdpDebugTool',
|
||||
data() {
|
||||
return {
|
||||
// 连接状态
|
||||
connectionStatus: 'disconnected', // disconnected, connecting, connected
|
||||
|
||||
// 配置表单
|
||||
configForm: {
|
||||
localPort: 8080,
|
||||
targetPort: 8081,
|
||||
targetIp: '192.168.1.100',
|
||||
bufferSize: 8192,
|
||||
timeout: 5000,
|
||||
retryCount: 3
|
||||
},
|
||||
|
||||
// 电文数据
|
||||
selectedTcNo: '2FK101',
|
||||
activeTab: 'json',
|
||||
telegramData: {
|
||||
json: '',
|
||||
hex: ''
|
||||
},
|
||||
telegramForm: {},
|
||||
|
||||
// 历史记录
|
||||
historyList: [],
|
||||
stats: {
|
||||
todayReceived: 0,
|
||||
totalReceived: 0,
|
||||
successRate: 0,
|
||||
avgDelay: 0
|
||||
},
|
||||
|
||||
// UI状态
|
||||
detailDialogVisible: false,
|
||||
currentDetail: null,
|
||||
sending: false,
|
||||
|
||||
// 定时器
|
||||
refreshTimer: null
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
connectionStatusText() {
|
||||
const statusMap = {
|
||||
disconnected: '未连接',
|
||||
connecting: '连接中',
|
||||
connected: '已连接'
|
||||
}
|
||||
return statusMap[this.connectionStatus] || '未知'
|
||||
},
|
||||
|
||||
canSend() {
|
||||
return this.connectionStatus === 'connected' && this.selectedTcNo && (
|
||||
(this.activeTab === 'json' && this.telegramData.json) ||
|
||||
(this.activeTab === 'hex' && this.telegramData.hex) ||
|
||||
(this.activeTab === 'form' && Object.keys(this.telegramForm).some(k => this.telegramForm[k]))
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
selectedTcNo(newVal) {
|
||||
if (newVal && this.activeTab === 'form') {
|
||||
this.updateFormFields()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.loadConfig()
|
||||
this.loadHistory()
|
||||
this.loadStats()
|
||||
this.startAutoRefresh()
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 加载配置
|
||||
loadConfig() {
|
||||
getUdpConfig().then(res => {
|
||||
if (res.data) {
|
||||
Object.assign(this.configForm, res.data)
|
||||
}
|
||||
}).catch(() => {
|
||||
this.$message.warning('加载配置失败')
|
||||
})
|
||||
},
|
||||
|
||||
// 保存配置
|
||||
saveConfig() {
|
||||
updateUdpConfig(this.configForm).then(() => {
|
||||
this.$message.success('配置保存成功')
|
||||
this.testConnection()
|
||||
}).catch(() => {
|
||||
this.$message.error('配置保存失败')
|
||||
})
|
||||
},
|
||||
|
||||
// 测试连接
|
||||
testConnection() {
|
||||
this.connectionStatus = 'connecting'
|
||||
// 模拟连接测试
|
||||
setTimeout(() => {
|
||||
this.connectionStatus = 'connected'
|
||||
this.$message.success('连接测试成功')
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
// 获取电文字段定义
|
||||
getFieldsForTcNo(tcNo) {
|
||||
const fieldMaps = {
|
||||
'2FK101': [
|
||||
{ name: 'PLAN_NO', label: '计划号', type: 'string', length: 20 },
|
||||
{ name: 'MAT_SEQ_NO', label: '材料序列号', type: 'string', length: 3 },
|
||||
{ name: 'UNIT_CODE', label: '机组代码', type: 'string', length: 4 },
|
||||
{ name: 'PLAN_TYPE', label: '计划类型', type: 'string', length: 1 },
|
||||
{ name: 'IN_MAT_NO', label: '入口钢卷号', type: 'string', length: 20 },
|
||||
{ name: 'IN_MAT_THICK', label: '入口厚度(mm)', type: 'float', precision: 3 },
|
||||
{ name: 'IN_MAT_WIDTH', label: '入口宽度(mm)', type: 'float', precision: 3 },
|
||||
{ name: 'IN_MAT_WT', label: '入口重量(kg)', type: 'number' },
|
||||
{ name: 'REMARK', label: '备注', type: 'string', length: 250 }
|
||||
],
|
||||
'2FK102': [
|
||||
{ name: 'PLAN_NO', label: '计划号', type: 'string', length: 20 },
|
||||
{ name: 'MAT_SEQ_NO', label: '材料序列号', type: 'number' },
|
||||
{ name: 'UNIT_CODE', label: '机组代码', type: 'string', length: 4 },
|
||||
{ name: 'IN_MAT_NO', label: '入口钢卷号', type: 'string', length: 20 }
|
||||
],
|
||||
'K12F03': [
|
||||
{ name: 'FLAG', label: '标志位', type: 'string', length: 1 },
|
||||
{ name: 'PLAN_NO', label: '计划号', type: 'string', length: 20 },
|
||||
{ name: 'SEQ_NO', label: '序列号', type: 'number' },
|
||||
{ name: 'UNIT_CODE', label: '机组代码', type: 'string', length: 4 },
|
||||
{ name: 'IN_MAT_NO', label: '入口钢卷号', type: 'string', length: 20 },
|
||||
{ name: 'OUT_MAT_NO', label: '出口钢卷号', type: 'string', length: 20 },
|
||||
{ name: 'OUT_MAT_ACT_THICK', label: '实际出口厚度(mm)', type: 'float', precision: 3 },
|
||||
{ name: 'START_PROD_TIME', label: '开始时间', type: 'string', length: 14 },
|
||||
{ name: 'END_PROD_TIME', label: '结束时间', type: 'string', length: 14 }
|
||||
]
|
||||
}
|
||||
|
||||
return fieldMaps[tcNo] || []
|
||||
},
|
||||
|
||||
// 更新表单字段
|
||||
updateFormFields() {
|
||||
this.telegramForm = {}
|
||||
const fields = this.getFieldsForTcNo(this.selectedTcNo)
|
||||
fields.forEach(field => {
|
||||
this.telegramForm[field.name] = ''
|
||||
})
|
||||
},
|
||||
|
||||
// 格式化浮点数
|
||||
formatFloat(fieldName, value) {
|
||||
if (typeof value === 'string' && value.includes('.')) {
|
||||
this.telegramForm[fieldName] = parseFloat(value).toFixed(3)
|
||||
}
|
||||
},
|
||||
|
||||
// 生成示例数据
|
||||
generateSample() {
|
||||
const samples = {
|
||||
'2FK101': `{
|
||||
"PLAN_NO": "P20240001",
|
||||
"MAT_SEQ_NO": "001",
|
||||
"UNIT_CODE": "L2",
|
||||
"PLAN_TYPE": "N",
|
||||
"IN_MAT_NO": "COIL_001",
|
||||
"IN_MAT_THICK": 2.500,
|
||||
"IN_MAT_THICK_MAX": 2.550,
|
||||
"IN_MAT_THICK_MIN": 2.450,
|
||||
"IN_MAT_WIDTH": 1250.000,
|
||||
"IN_MAT_WT": 25000,
|
||||
"IN_MAT_LEN": 5000.000,
|
||||
"IN_MAT_IN_DIA": 610.00,
|
||||
"IN_MAT_DIA": 1500.00,
|
||||
"PONO": "PO12345678",
|
||||
"SG_SIGN": "SPCC",
|
||||
"OUT_MAT_NO": "COIL_001_OUT",
|
||||
"REMARK": "冷轧产品"
|
||||
}`,
|
||||
'K12F03': `{
|
||||
"FLAG": "A",
|
||||
"PLAN_NO": "P20240001",
|
||||
"SEQ_NO": 1,
|
||||
"UNIT_CODE": "L2",
|
||||
"PROC_SEQ_NO": 1,
|
||||
"IN_MAT_NO": "COIL_001",
|
||||
"OUT_MAT_NO": "COIL_001_OUT",
|
||||
"OUT_MAT_ACT_THICK": 1.200,
|
||||
"OUT_MAT_ACT_WIDTH": 1245.000,
|
||||
"OUT_MAT_ACT_LEN": 8500.000,
|
||||
"START_PROD_TIME": "20240430103000",
|
||||
"END_PROD_TIME": "20240430114530",
|
||||
"SOCKET_NO": "1",
|
||||
"REMARK": "正常生产完成"
|
||||
}`
|
||||
}
|
||||
|
||||
if (samples[this.selectedTcNo]) {
|
||||
this.telegramData.json = samples[this.selectedTcNo]
|
||||
this.activeTab = 'json'
|
||||
}
|
||||
},
|
||||
|
||||
// 清除数据
|
||||
clearData() {
|
||||
this.telegramData = { json: '', hex: '' }
|
||||
if (this.activeTab === 'form') {
|
||||
this.updateFormFields()
|
||||
}
|
||||
},
|
||||
|
||||
// 发送报文
|
||||
async sendTelegram() {
|
||||
if (!this.canSend) return
|
||||
|
||||
this.sending = true
|
||||
|
||||
try {
|
||||
let payload
|
||||
let tcNo = this.selectedTcNo
|
||||
|
||||
if (this.activeTab === 'json') {
|
||||
payload = Buffer.from(JSON.stringify(this.telegramData.json), 'utf8')
|
||||
} else if (this.activeTab === 'hex') {
|
||||
// 转换十六进制字符串为字节数组
|
||||
const hexStr = this.telegramData.hex.replace(/\s/g, '')
|
||||
payload = Buffer.from(hexStr, 'hex')
|
||||
} else if (this.activeTab === 'form') {
|
||||
// 根据电文号构造数据
|
||||
const fields = this.getFieldsForTcNo(this.selectedTcNo)
|
||||
const formData = {}
|
||||
|
||||
fields.forEach(field => {
|
||||
let value = this.telegramForm[field.name]
|
||||
if (value !== undefined && value !== '') {
|
||||
switch (field.type) {
|
||||
case 'number':
|
||||
formData[field.name] = parseInt(value)
|
||||
break
|
||||
case 'float':
|
||||
formData[field.name] = parseFloat(value)
|
||||
break
|
||||
default:
|
||||
formData[field.name] = String(value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
payload = Buffer.from(JSON.stringify(formData), 'utf8')
|
||||
}
|
||||
|
||||
const response = await sendTelegram({ tcNo, payload: Array.from(payload) })
|
||||
|
||||
this.$message.success(`报文 ${tcNo} 发送成功`)
|
||||
|
||||
// 添加到历史记录
|
||||
this.historyList.unshift({
|
||||
id: Date.now(),
|
||||
tcNo,
|
||||
direction: 'OUT',
|
||||
timestamp: new Date().toLocaleString(),
|
||||
payloadLength: payload.length,
|
||||
status: '成功'
|
||||
})
|
||||
|
||||
this.loadStats()
|
||||
|
||||
} catch (error) {
|
||||
this.$message.error(`发送失败: ${error.message}`)
|
||||
} finally {
|
||||
this.sending = false
|
||||
}
|
||||
},
|
||||
|
||||
// 查看详情
|
||||
viewDetail(record) {
|
||||
this.currentDetail = record
|
||||
this.detailDialogVisible = true
|
||||
},
|
||||
|
||||
// 获取状态标签类型
|
||||
getStatusType(status) {
|
||||
const statusMap = {
|
||||
'成功': 'success',
|
||||
'失败': 'danger',
|
||||
'超时': 'warning',
|
||||
'解析错误': 'info'
|
||||
}
|
||||
return statusMap[status] || 'default'
|
||||
},
|
||||
|
||||
// 刷新历史记录
|
||||
refreshHistory() {
|
||||
this.loadHistory()
|
||||
},
|
||||
|
||||
// 清空历史记录
|
||||
clearHistory() {
|
||||
this.$confirm('确定要清空所有历史记录吗?', '提示', {
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
this.historyList = []
|
||||
this.stats = {
|
||||
todayReceived: 0,
|
||||
totalReceived: 0,
|
||||
successRate: 0,
|
||||
avgDelay: 0
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 加载历史记录
|
||||
loadHistory() {
|
||||
getTelegramHistory({ page: 1, size: 50 }).then(res => {
|
||||
this.historyList = res.rows || []
|
||||
}).catch(() => {
|
||||
this.$message.error('加载历史记录失败')
|
||||
})
|
||||
},
|
||||
|
||||
// 加载统计数据
|
||||
loadStats() {
|
||||
getTelegramStats().then(res => {
|
||||
this.stats = res.data || this.stats
|
||||
}).catch(() => {
|
||||
// 使用本地计算
|
||||
this.stats = {
|
||||
todayReceived: this.historyList.filter(h => h.direction === 'IN').length,
|
||||
totalReceived: this.historyList.length,
|
||||
successRate: this.historyList.length > 0 ? Math.round((this.historyList.filter(h => h.status === '成功').length / this.historyList.length) * 100) : 0,
|
||||
avgDelay: 150
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 开始自动刷新
|
||||
startAutoRefresh() {
|
||||
this.refreshTimer = setInterval(() => {
|
||||
this.loadHistory()
|
||||
this.loadStats()
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.udp-debug-container {
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: calc(100vh - 40px);
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 15px;
|
||||
|
||||
.section-label {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.data-input-section {
|
||||
margin: 15px 0;
|
||||
|
||||
::v-deep .el-textarea__inner {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.send-section {
|
||||
text-align: center;
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
.payload-section {
|
||||
margin-top: 20px;
|
||||
|
||||
h4 {
|
||||
margin: 10px 0;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 适配移动端
|
||||
@media screen and (max-width: 768px) {
|
||||
.udp-debug-container {
|
||||
padding: 10px;
|
||||
|
||||
.main-area {
|
||||
.el-col {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
73
sql/system_menu_init.sql
Normal file
73
sql/system_menu_init.sql
Normal file
@@ -0,0 +1,73 @@
|
||||
-- ============================================================
|
||||
-- 系统菜单初始化 - UDP调试工具
|
||||
-- ============================================================
|
||||
|
||||
-- 插入UDP调试菜单项
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `route_name`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `remark`, `create_by`, `create_time`)
|
||||
VALUES
|
||||
( -- 工具菜单父级
|
||||
(SELECT IFNULL(MAX(menu_id), 0) + 1 FROM sys_menu WHERE menu_type = 'M' AND menu_name LIKE '%工具%'),
|
||||
'工具管理',
|
||||
(SELECT menu_id FROM sys_menu WHERE menu_name = '系统工具' AND menu_type = 'M' LIMIT 1),
|
||||
99,
|
||||
'/tool',
|
||||
NULL,
|
||||
NULL,
|
||||
'Tool',
|
||||
1,
|
||||
'0',
|
||||
'M',
|
||||
'0',
|
||||
'0',
|
||||
'tool:index',
|
||||
'tool',
|
||||
'系统工具菜单',
|
||||
'admin',
|
||||
NOW()
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name);
|
||||
|
||||
SET @tool_parent_id = LAST_INSERT_ID();
|
||||
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `route_name`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `remark`, `create_by`, `create_time`)
|
||||
VALUES
|
||||
(
|
||||
(SELECT IFNULL(MAX(menu_id), 0) + 1 FROM sys_menu),
|
||||
'UDP调试工具',
|
||||
@tool_parent_id,
|
||||
1,
|
||||
'/tool/udp-debug',
|
||||
'views/tool/udp-debug',
|
||||
NULL,
|
||||
'UdpDebug',
|
||||
1,
|
||||
'0',
|
||||
'C',
|
||||
'0',
|
||||
'0',
|
||||
'mill:udp:debug',
|
||||
'udp',
|
||||
'UDP报文调试工具',
|
||||
'admin',
|
||||
NOW()
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name);
|
||||
|
||||
-- 为管理员角色分配权限
|
||||
INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
|
||||
SELECT
|
||||
(SELECT role_id FROM sys_role WHERE role_name = '超级管理员'),
|
||||
menu_id
|
||||
FROM sys_menu
|
||||
WHERE menu_name IN ('工具管理', 'UDP调试工具');
|
||||
|
||||
-- 为其他常用角色分配权限(可选)
|
||||
INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
|
||||
SELECT
|
||||
r.role_id,
|
||||
m.menu_id
|
||||
FROM sys_role r, sys_menu m
|
||||
WHERE r.role_name IN ('运维人员', '开发人员')
|
||||
AND m.menu_name = 'UDP调试工具';
|
||||
|
||||
COMMIT;
|
||||
199
udp-debug-quickstart.md
Normal file
199
udp-debug-quickstart.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# UDP 报文调试工具 - 快速启动指南
|
||||
|
||||
## 🚀 功能特性
|
||||
|
||||
- **实时UDP通信测试** - 支持L3→L2和L2→L3电文收发
|
||||
- **多格式数据输入** - JSON、十六进制、表单三种输入方式
|
||||
- **电文解析工具** - 自动解析标准电文格式
|
||||
- **历史记录查看** - 完整的收发记录和统计信息
|
||||
- **配置管理** - 灵活的服务器参数配置
|
||||
|
||||
## 📋 使用前准备
|
||||
|
||||
### 1. 后端服务配置
|
||||
|
||||
确保 `ruoyi-mill` 模块已正确配置:
|
||||
|
||||
```yaml
|
||||
# application-mill.properties
|
||||
mill.udp.local-port=9001
|
||||
mill.udp.remote-host=127.0.0.1
|
||||
mill.udp.remote-port=9000
|
||||
mill.udp.buffer-size=8192
|
||||
mill.udp.timeout=5000
|
||||
mill.udp.retry-count=3
|
||||
```
|
||||
|
||||
### 2. 数据库菜单配置
|
||||
|
||||
运行SQL脚本添加系统菜单权限:
|
||||
|
||||
```sql
|
||||
-- 执行 system_menu_init.sql
|
||||
source sql/system_menu_init.sql;
|
||||
```
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
```bash
|
||||
# 启动ruoyi-admin(包含ruoyi-mill模块)
|
||||
mvn spring-boot:run -pl ruoyi-admin
|
||||
|
||||
# 或者单独启动ruoyi-mill
|
||||
mvn spring-boot:run -pl ruoyi-mill
|
||||
```
|
||||
|
||||
## 🎯 使用说明
|
||||
|
||||
### 1. 访问界面
|
||||
|
||||
打开浏览器访问:`http://localhost:8080/tool/udp-debug`
|
||||
|
||||
### 2. 基本配置
|
||||
|
||||
**服务器配置面板**:
|
||||
- **本地端口**:设置本程序监听的UDP端口(默认9001)
|
||||
- **目标IP**:L3系统的IP地址(默认127.0.0.1)
|
||||
- **目标端口**:L3系统的UDP端口(默认9000)
|
||||
- **缓冲区大小**:接收缓冲区大小(默认8192字节)
|
||||
- **超时时间**:操作超时时间(默认5000ms)
|
||||
- **重试次数**:失败时的重试次数(默认3次)
|
||||
|
||||
点击"保存配置"应用设置,然后点击"连接测试"验证配置。
|
||||
|
||||
### 3. 发送电文
|
||||
|
||||
**步骤1:选择电文号**
|
||||
从下拉菜单中选择要发送的电文类型:
|
||||
- `2FK101` - 作业命令信息 (L3→L2)
|
||||
- `2FK102` - 作业命令撤销 (L3→L2)
|
||||
- `2FK103` - 作业命令应答 (L3→L2)
|
||||
- `2FK104` - 产出信息应答 (L3→L2)
|
||||
- `K12F01` - 计划信息应答 (L2→L3)
|
||||
- `K12F02` - 计划钢卷删除 (L2→L3)
|
||||
- `K12F03` - 生产信息电文 (L2→L3)
|
||||
|
||||
**步骤2:输入数据**
|
||||
|
||||
有三种输入方式:
|
||||
|
||||
#### JSON格式
|
||||
直接输入JSON格式的键值对数据:
|
||||
```json
|
||||
{
|
||||
"PLAN_NO": "P20240001",
|
||||
"MAT_SEQ_NO": "001",
|
||||
"UNIT_CODE": "L2",
|
||||
"IN_MAT_NO": "COIL_001",
|
||||
"IN_MAT_THICK": 2.500,
|
||||
"REMARK": "冷轧产品"
|
||||
}
|
||||
```
|
||||
|
||||
#### 十六进制格式
|
||||
输入十六进制字符串(自动转换为字节):
|
||||
```
|
||||
32464B3130310A7B0A202022504C414E5F4E4F223A20225032303234...
|
||||
```
|
||||
|
||||
#### 表单填写
|
||||
根据选择的电文号动态生成表单字段:
|
||||
- 文本字段:字符串类型数据
|
||||
- 数字字段:整数类型数据
|
||||
- 浮点字段:小数类型数据,支持精度设置
|
||||
|
||||
**步骤3:发送测试**
|
||||
点击"发送报文"按钮发送电文。成功后会显示在接收记录中。
|
||||
|
||||
### 4. 接收记录查看
|
||||
|
||||
**统计信息**:
|
||||
- **今日接收**:当日接收的电文数量
|
||||
- **总接收数**:累计接收总数
|
||||
- **成功率**:发送成功率百分比
|
||||
- **平均延迟**:平均响应时间
|
||||
|
||||
**历史记录表**:
|
||||
- 显示所有收发记录的时间、电文号、方向、长度和状态
|
||||
- 支持查看详情按钮查看详细数据
|
||||
|
||||
### 5. 详情查看
|
||||
|
||||
点击历史记录中的"详情"按钮可以查看:
|
||||
- 原始十六进制数据
|
||||
- 自动解析后的字段内容
|
||||
- 时间戳和其他元信息
|
||||
|
||||
## 🔧 常见问题
|
||||
|
||||
### Q1: 发送失败怎么办?
|
||||
|
||||
**检查项**:
|
||||
1. 确认UDP服务器配置正确(IP、端口)
|
||||
2. 检查目标设备是否可达
|
||||
3. 确认网络防火墙未阻止UDP通信
|
||||
4. 查看日志文件获取详细错误信息
|
||||
|
||||
### Q2: 如何测试真实环境?
|
||||
|
||||
**建议步骤**:
|
||||
1. 先在本地环境测试基本功能
|
||||
2. 配置真实的L3系统IP和端口
|
||||
3. 使用标准电文格式测试
|
||||
4. 逐步增加负载数据进行压力测试
|
||||
|
||||
### Q3: 电文格式不正确如何处理?
|
||||
|
||||
**解决方案**:
|
||||
1. 使用"生成示例数据"功能获取标准格式
|
||||
2. 参考iXComPCS协议文档
|
||||
3. 检查字段类型和长度是否符合规范
|
||||
4. 使用十六进制模式手动构造数据
|
||||
|
||||
## 📊 典型应用场景
|
||||
|
||||
### 场景1:新项目开发测试
|
||||
- 使用示例数据快速验证通信链路
|
||||
- 测试不同电文类型的收发功能
|
||||
- 调试电文解析逻辑
|
||||
|
||||
### 场景2:现场运维支持
|
||||
- 快速诊断通信故障
|
||||
- 验证配置参数正确性
|
||||
- 记录和分析通信日志
|
||||
|
||||
### 场景3:协议升级验证
|
||||
- 测试新的电文格式兼容性
|
||||
- 验证旧版本数据的解析能力
|
||||
- 性能基准测试
|
||||
|
||||
## 🛠️ 技术细节
|
||||
|
||||
### 电文帧结构
|
||||
```
|
||||
[6字节电文号][N字节负载数据]
|
||||
```
|
||||
|
||||
### 支持的电文类型
|
||||
| 电文号 | 方向 | 用途 |
|
||||
|--------|------|------|
|
||||
| 2FK101 | L3→L2 | 作业命令信息 |
|
||||
| 2FK102 | L3→L2 | 作业命令撤销 |
|
||||
| 2FK103 | L3→L2 | 作业命令应答 |
|
||||
| 2FK104 | L3→L2 | 产出信息应答 |
|
||||
| K12F01 | L2→L3 | 计划信息应答 |
|
||||
| K12F02 | L2→L3 | 计划钢卷删除 |
|
||||
| K12F03 | L2→L3 | 生产信息电文 |
|
||||
|
||||
### 性能优化建议
|
||||
- 适当调整缓冲区大小以适应大数据量传输
|
||||
- 根据网络质量设置合理的超时时间
|
||||
- 在生产环境中适当减少重试次数以提高效率
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
如遇问题,请检查:
|
||||
1. 服务日志:`logs/application.log`
|
||||
2. 前端控制台错误信息
|
||||
3. 网络连通性测试
|
||||
4. 配置文件语法正确性
|
||||
Reference in New Issue
Block a user