You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

733 lines
21 KiB

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Modbus RS485扫描工具</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
background: linear-gradient(135deg, #1a2a6c, #2c3e50);
color: #333;
min-height: 100vh;
padding: 20px;
}
#app {
max-width: 1800px;
margin: 0 auto;
}
header {
text-align: center;
padding: 20px 0;
color: white;
margin-bottom: 20px;
}
header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
header p {
font-size: 1.1rem;
max-width: 800px;
margin: 0 auto;
opacity: 0.9;
}
.app-container {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 20px;
margin-bottom: 30px;
}
.panel {
background: rgba(255, 255, 255, 0.93);
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
padding: 25px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-title {
font-size: 1.4rem;
color: #2c3e50;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 2px solid #3498db;
display: flex;
align-items: center;
}
.panel-title i {
margin-right: 10px;
color: #3498db;
}
.config-group {
margin-bottom: 25px;
}
.config-group h3 {
font-size: 1.1rem;
color: #2c3e50;
margin-bottom: 15px;
display: flex;
align-items: center;
}
.config-group h3 i {
margin-right: 8px;
color: #3498db;
font-size: 0.9rem;
}
.form-row {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 15px;
}
.form-item {
flex: 1;
min-width: 200px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #34495e;
font-size: 0.95rem;
}
select, input {
width: 100%;
padding: 12px 15px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 1rem;
background: #f8f9fa;
transition: all 0.3s;
}
select:focus, input:focus {
border-color: #3498db;
outline: none;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
background: white;
}
.log-container {
flex: 1;
overflow-y: auto;
background: #1e1f2a;
border-radius: 8px;
padding: 20px;
font-family: 'Courier New', monospace;
color: #e0e0e0;
height: 500px;
margin-top: 10px;
}
.log-entry {
margin-bottom: 12px;
line-height: 1.5;
font-size: 0.95rem;
}
.log-send {
color: #64b5f6;
}
.log-receive {
color: #81c784;
}
.log-valid {
color: #4caf50;
}
.log-error {
color: #e57373;
}
.device-list {
list-style-type: none;
max-height: 400px;
overflow-y: auto;
}
.device-item {
background: #f8f9fa;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
border-left: 4px solid #3498db;
transition: all 0.3s;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
.device-item:hover {
transform: translateY(-3px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.device-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.device-address {
font-weight: bold;
font-size: 1.2rem;
color: #2c3e50;
}
.device-status {
padding: 5px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: bold;
}
.status-active {
background: #e8f5e9;
color: #2e7d32;
}
.status-inactive {
background: #ffebee;
color: #c62828;
}
.device-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.device-detail {
background: white;
padding: 10px;
border-radius: 6px;
font-size: 0.9rem;
}
.control-bar {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 30px;
}
.btn {
padding: 15px 35px;
border: none;
border-radius: 8px;
font-size: 1.1rem;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.btn-primary {
background: linear-gradient(to right, #3498db, #2980b9);
color: white;
}
.btn-primary:hover {
background: linear-gradient(to right, #2980b9, #2573a7);
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.btn-stop {
background: linear-gradient(to right, #e74c3c, #c0392b);
color: white;
}
.btn-stop:hover {
background: linear-gradient(to right, #c0392b, #a93226);
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.stats-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
margin-bottom: 25px;
}
.stat-card {
background: #f8f9fa;
border-radius: 10px;
padding: 20px;
text-align: center;
box-shadow: 0 3px 6px rgba(0,0,0,0.05);
}
.stat-value {
font-size: 2.2rem;
font-weight: bold;
color: #3498db;
margin: 10px 0;
}
.stat-label {
font-size: 0.9rem;
color: #7f8c8d;
}
.progress-container {
margin-top: 20px;
background: #e0e0e0;
border-radius: 10px;
height: 15px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(to right, #3498db, #2ecc71);
border-radius: 10px;
transition: width 0.5s;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.app-container {
grid-template-columns: 1fr 1fr;
}
.panel:last-child {
grid-column: span 2;
}
}
@media (max-width: 768px) {
.app-container {
grid-template-columns: 1fr;
}
.panel:last-child {
grid-column: span 1;
}
.form-row {
flex-direction: column;
}
.stats-container {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div id="app">
<header>
<h1><i class="fas fa-microchip"></i> Modbus RS485设备扫描工具</h1>
<p>配置RS485参数,扫描Modbus设备并查看通信状态</p>
</header>
<div class="app-container">
<!-- 参数配置面板 -->
<div class="panel">
<h2 class="panel-title"><i class="fas fa-cog"></i> 通信参数配置</h2>
<div class="config-group">
<h3><i class="fas fa-portrait"></i> 串口设置</h3>
<div class="form-row">
<div class="form-item">
<label for="port">串口号</label>
<select id="port" v-model="config.port">
<option>COM1</option>
<option>COM2</option>
<option>COM3</option>
<option>COM4</option>
<option>/dev/ttyUSB0</option>
<option>/dev/ttyUSB1</option>
</select>
</div>
<div class="form-item">
<label for="baudrate">波特率</label>
<select id="baudrate" v-model="config.baudrate">
<option>1200</option>
<option>2400</option>
<option>4800</option>
<option>9600</option>
<option>19200</option>
<option>38400</option>
<option>57600</option>
<option>115200</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-item">
<label for="bytesize">数据位</label>
<select id="bytesize" v-model="config.bytesize">
<option>8</option>
<option>7</option>
<option>6</option>
<option>5</option>
</select>
</div>
<div class="form-item">
<label for="stopbits">停止位</label>
<select id="stopbits" v-model="config.stopbits">
<option>1</option>
<option>1.5</option>
<option>2</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-item">
<label for="parity">校验位</label>
<select id="parity" v-model="config.parity">
<option>无校验</option>
<option>偶校验</option>
<option>奇校验</option>
</select>
</div>
</div>
</div>
<div class="config-group">
<h3><i class="fas fa-broadcast-tower"></i> Modbus协议设置</h3>
<div class="form-row">
<div class="form-item">
<label for="functionCode">功能码</label>
<select id="functionCode" v-model="config.functionCode">
<option value="03">03 - 读保持寄存器</option>
<option value="04">04 - 读输入寄存器</option>
<option value="01">01 - 读线圈状态</option>
<option value="02">02 - 读离散输入</option>
<option value="05">05 - 写单个线圈</option>
<option value="06">06 - 写单个寄存器</option>
</select>
</div>
<div class="form-item">
<label for="registerAddress">寄存器地址</label>
<input type="number" id="registerAddress" v-model.number="config.registerAddress" min="0" max="65535">
</div>
</div>
</div>
<div class="config-group">
<h3><i class="fas fa-search"></i> 扫描设置</h3>
<div class="form-row">
<div class="form-item">
<label for="startAddr">起始地址</label>
<input type="number" id="startAddr" v-model.number="config.startAddr" min="1" max="247">
</div>
<div class="form-item">
<label for="endAddr">结束地址</label>
<input type="number" id="endAddr" v-model.number="config.endAddr" min="1" max="247">
</div>
</div>
<div class="form-row">
<div class="form-item">
<label for="timeout">超时时间(秒)</label>
<input type="number" id="timeout" v-model.number="config.timeout" min="0.1" max="5" step="0.1">
</div>
<div class="form-item">
<label for="retries">重试次数</label>
<input type="number" id="retries" v-model.number="config.retries" min="1" max="5">
</div>
</div>
<div class="form-row">
<div class="form-item">
<label for="sendInterval">发送间隔(秒)</label>
<input type="number" id="sendInterval" v-model.number="config.sendInterval" min="0.01" max="1" step="0.01">
</div>
</div>
</div>
</div>
<!-- 实时日志面板 -->
<div class="panel">
<h2 class="panel-title"><i class="fas fa-terminal"></i> 通信日志</h2>
<div class="stats-container">
<div class="stat-card">
<div class="stat-label">扫描进度</div>
<div class="stat-value">{{ progress }}%</div>
<div class="progress-container">
<div class="progress-bar" :style="{width: progress + '%'}"></div>
</div>
</div>
<div class="stat-card">
<div class="stat-label">活动设备</div>
<div class="stat-value">{{ activeDevices.length }}</div>
<div class="stat-label">检测到</div>
</div>
<div class="stat-card">
<div class="stat-label">当前状态</div>
<div class="stat-value" :style="{color: isScanning ? '#2ecc71' : '#e74c3c'}">
{{ isScanning ? '扫描中' : '就绪' }}
</div>
<div class="stat-label">{{ currentAddress }}/{{ config.endAddr }}</div>
</div>
</div>
<div class="log-container">
<div v-for="(log, index) in logs" :key="index" class="log-entry" :class="logClass(log)">
<i :class="logIcon(log)"></i> {{ log.message }}
</div>
</div>
</div>
<!-- 结果展示面板 -->
<div class="panel">
<h2 class="panel-title"><i class="fas fa-list"></i> 扫描结果</h2>
<div v-if="activeDevices.length > 0">
<h3>活动设备列表 ({{ activeDevices.length }})</h3>
<ul class="device-list">
<li v-for="device in activeDevices" :key="device.address" class="device-item">
<div class="device-header">
<div class="device-address">设备地址 {{ device.address }}</div>
<div class="device-status status-active">响应有效</div>
</div>
<div class="device-details">
<div class="device-detail">
<strong>寄存器地址:</strong> {{ config.registerAddress }} (0x{{ config.registerAddress.toString(16).toUpperCase().padStart(4, '0') }})
</div>
<div class="device-detail">
<strong>寄存器值:</strong> {{ device.registerValue }}
</div>
<div class="device-detail">
<strong>响应时间:</strong> {{ device.responseTime }} ms
</div>
<div class="device-detail">
<strong>尝试次数:</strong> {{ device.attempts }}
</div>
</div>
</li>
</ul>
</div>
<div v-else style="text-align: center; padding: 40px 0; color: #7f8c8d;">
<i class="fas fa-inbox" style="font-size: 3rem; margin-bottom: 20px;"></i>
<h3>未检测到活动设备</h3>
<p>请检查设备连接和参数设置</p>
</div>
</div>
</div>
<div class="control-bar">
<button class="btn btn-primary" @click="startScan" :disabled="isScanning">
<i class="fas fa-play"></i> 开始扫描
</button>
<button class="btn btn-stop" @click="stopScan" :disabled="!isScanning">
<i class="fas fa-stop"></i> 停止扫描
</button>
</div>
</div>
<script>
const { createApp, ref, computed, reactive } = Vue
createApp({
setup() {
// 默认配置
const config = reactive({
port: 'COM3',
baudrate: '9600',
bytesize: '8',
stopbits: '1',
parity: '无校验',
functionCode: '03',
registerAddress: 0,
startAddr: 1,
endAddr: 10,
timeout: 0.2,
retries: 1,
sendInterval: 0.5
})
// 状态变量
const isScanning = ref(false)
const logs = ref([])
const activeDevices = ref([])
const currentAddress = ref(0)
const progress = ref(0)
let scanInterval
// 计算扫描进度
const scanProgress = computed(() => {
const total = config.endAddr - config.startAddr + 1
const completed = currentAddress.value - config.startAddr
return total > 0 ? Math.round((completed / total) * 100) : 0
})
// 添加日志
const addLog = (message, type = 'info') => {
logs.value.push({
message,
type,
timestamp: new Date().toLocaleTimeString()
})
// 保持日志在100条以内
if (logs.value.length > 100) {
logs.value.shift()
}
}
// 日志样式
const logClass = (log) => {
return {
'log-send': log.type === 'send',
'log-receive': log.type === 'receive',
'log-valid': log.type === 'valid',
'log-error': log.type === 'error'
}
}
// 日志图标
const logIcon = (log) => {
return {
'fas fa-paper-plane': log.type === 'send',
'fas fa-inbox': log.type === 'receive',
'fas fa-check-circle': log.type === 'valid',
'fas fa-exclamation-circle': log.type === 'error',
'fas fa-info-circle': log.type === 'info'
}
}
// 开始扫描
const startScan = () => {
// 重置状态
logs.value = []
activeDevices.value = []
isScanning.value = true
currentAddress.value = config.startAddr
progress.value = 0
addLog('开始扫描 Modbus 设备...', 'info')
addLog(`串口: ${config.port}, 波特率: ${config.baudrate}, 数据位: ${config.bytesize}, 停止位: ${config.stopbits}, 校验: ${config.parity}`, 'info')
addLog(`功能码: ${config.functionCode}, 寄存器地址: ${config.registerAddress} (0x${config.registerAddress.toString(16).toUpperCase().padStart(4, '0')})`, 'info')
addLog(`地址范围: ${config.startAddr} - ${config.endAddr}`, 'info')
// 模拟扫描过程
simulateScan()
}
// 停止扫描
const stopScan = () => {
isScanning.value = false
clearInterval(scanInterval)
addLog('扫描已停止', 'info')
}
// 模拟扫描过程
const simulateScan = () => {
scanInterval = setInterval(() => {
if (currentAddress.value > config.endAddr) {
clearInterval(scanInterval)
isScanning.value = false
addLog(`扫描完成! 发现 ${activeDevices.value.length} 个活动设备`, 'info')
return
}
// 更新进度
progress.value = scanProgress.value
// 发送请求
const frame = `地址 ${currentAddress.value}: 发送 -> ${generateFrame(currentAddress.value)}`
addLog(frame, 'send')
// 模拟响应
setTimeout(() => {
// 随机决定是否有响应
const hasResponse = Math.random() > 0.7
if (hasResponse) {
// 模拟接收数据
const response = generateResponse(currentAddress.value)
addLog(`地址 ${currentAddress.value}: 接收 <- ${response}`, 'receive')
// 模拟有效响应
const isValid = Math.random() > 0.2
if (isValid) {
const registerValue = Math.floor(Math.random() * 65536)
addLog(`地址 ${currentAddress.value} - 设备响应有效 | 寄存器 ${config.registerAddress} 值: ${registerValue}`, 'valid')
// 添加到活动设备列表
activeDevices.value.push({
address: currentAddress.value,
registerValue: registerValue,
responseTime: Math.floor(Math.random() * 100) + 10,
attempts: 1
})
} else {
addLog(`地址 ${currentAddress.value} - 设备响应无效`, 'error')
}
} else {
addLog(`地址 ${currentAddress.value} - 无响应`, 'error')
}
// 移动到下一个地址
currentAddress.value++
}, config.sendInterval * 1000)
}, config.sendInterval * 1000 + 100)
}
// 生成模拟帧
const generateFrame = (address) => {
const funcCode = config.functionCode.padStart(2, '0')
const regAddr = config.registerAddress.toString(16).toUpperCase().padStart(4, '0')
return `${address.toString(16).toUpperCase().padStart(2, '0')} ${funcCode} ${regAddr.substring(0, 2)} ${regAddr.substring(2, 4)} 00 01 XX XX`
}
// 生成模拟响应
const generateResponse = (address) => {
const funcCode = config.functionCode.padStart(2, '0')
const regValue = Math.floor(Math.random() * 65536).toString(16).toUpperCase().padStart(4, '0')
return `${address.toString(16).toUpperCase().padStart(2, '0')} ${funcCode} 02 ${regValue.substring(0, 2)} ${regValue.substring(2, 4)} XX XX`
}
return {
config,
isScanning,
logs,
activeDevices,
currentAddress,
progress,
startScan,
stopScan,
logClass,
logIcon
}
}
}).mount('#app')
</script>
</body>
</html>