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:
2026-04-30 16:59:21 +08:00
parent 7e67bae35f
commit 2e17943a7e
13 changed files with 1684 additions and 26 deletions

View 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 }
})
}

View File

@@ -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: '' }
}
]
},
]
// 动态路由,基于用户权限动态去加载

View 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>