2417 lines
91 KiB
HTML
2417 lines
91 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>分布式数据采集网关设备控制</title>
|
||
<!-- 引入MQTT.js客户端库(CDN) -->
|
||
<script src="https://cdn.jsdelivr.net/npm/mqtt@4.3.7/dist/mqtt.min.js"></script>
|
||
<style>
|
||
/* 基础样式重置 */
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Microsoft YaHei', 'Segoe UI', Arial, sans-serif;
|
||
background-color: #f5f5f5;
|
||
color: #333333;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
padding: 20px;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* 容器样式 */
|
||
.container {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
/* 全局提示栏 */
|
||
.global-notification {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
padding: 15px 20px;
|
||
text-align: center;
|
||
font-weight: 500;
|
||
display: none;
|
||
z-index: 9999;
|
||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||
animation: slideDown 0.3s ease;
|
||
}
|
||
|
||
.global-notification.success {
|
||
background-color: #d4edda;
|
||
color: #155724;
|
||
border-bottom: 3px solid #28a745;
|
||
}
|
||
|
||
.global-notification.warning {
|
||
background-color: #fff3cd;
|
||
color: #856404;
|
||
border-bottom: 3px solid #ffc107;
|
||
}
|
||
|
||
.global-notification.error {
|
||
background-color: #f8d7da;
|
||
color: #721c24;
|
||
border-bottom: 3px solid #dc3545;
|
||
}
|
||
|
||
.global-notification.info {
|
||
background-color: #d1ecf1;
|
||
color: #0c5460;
|
||
border-bottom: 3px solid #17a2b8;
|
||
}
|
||
|
||
@keyframes slideDown {
|
||
from {
|
||
transform: translateY(-100%);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
transform: translateY(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
/* 模块卡片通用样式 */
|
||
.module-card {
|
||
background-color: #ffffff;
|
||
border-radius: 8px;
|
||
padding: 25px;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||
border: 1px solid #e0e0e0;
|
||
}
|
||
|
||
/* 连接状态卡片样式 */
|
||
.status-card {
|
||
padding: 15px 25px;
|
||
}
|
||
|
||
.connection-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-top: 20px;
|
||
padding: 12px 15px;
|
||
background-color: #f8f9fa;
|
||
border-radius: 4px;
|
||
border: 1px solid #e0e0e0;
|
||
}
|
||
|
||
.status-indicator {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
background-color: #999;
|
||
animation: pulse 1.5s infinite;
|
||
}
|
||
|
||
.status-indicator.disconnected {
|
||
background-color: #999;
|
||
animation: none;
|
||
}
|
||
|
||
.status-indicator.connecting {
|
||
background-color: #ffc107;
|
||
animation: pulse 1.5s infinite;
|
||
}
|
||
|
||
.status-indicator.connected {
|
||
background-color: #28a745;
|
||
animation: none;
|
||
}
|
||
|
||
.status-indicator.error {
|
||
background-color: #dc3545;
|
||
animation: none;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% {
|
||
opacity: 1;
|
||
}
|
||
50% {
|
||
opacity: 0.5;
|
||
}
|
||
}
|
||
|
||
.status-text {
|
||
font-weight: 500;
|
||
color: #555555;
|
||
}
|
||
|
||
.module-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #333333;
|
||
margin-bottom: 20px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 2px solid #007bff;
|
||
}
|
||
|
||
/* 两栏布局容器 */
|
||
.two-column-layout {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 20px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.two-column-layout .module-card {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
/* 参数说明文本 */
|
||
.parameter-hint {
|
||
font-size: 11px;
|
||
color: #6c757d;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
/* MQTT连接配置区 */
|
||
.port-notice {
|
||
background-color: #fff3cd;
|
||
border-left: 4px solid #ffc107;
|
||
padding: 12px 15px;
|
||
margin-bottom: 20px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.port-notice strong {
|
||
color: #856404;
|
||
display: block;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.port-notice p {
|
||
color: #856404;
|
||
font-size: 13px;
|
||
margin: 0;
|
||
}
|
||
|
||
.input-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 15px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.input-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.input-group label {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #555555;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.input-group input {
|
||
padding: 10px 12px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.input-group input:focus {
|
||
outline: none;
|
||
border-color: #007bff;
|
||
box-shadow: 0 0 0 3px rgba(0,123,255,0.1);
|
||
}
|
||
|
||
.input-group input.error {
|
||
border-color: #dc3545;
|
||
}
|
||
|
||
.button-group {
|
||
display: flex;
|
||
gap: 15px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.btn {
|
||
padding: 10px 25px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.btn:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.btn-primary {
|
||
background-color: #007bff;
|
||
color: white;
|
||
}
|
||
|
||
.btn-primary:hover:not(:disabled) {
|
||
background-color: #0056b3;
|
||
}
|
||
|
||
.btn-danger {
|
||
background-color: #dc3545;
|
||
color: white;
|
||
}
|
||
|
||
.btn-danger:hover:not(:disabled) {
|
||
background-color: #c82333;
|
||
}
|
||
|
||
.btn-success {
|
||
background-color: #28a745;
|
||
color: white;
|
||
}
|
||
|
||
.btn-success:hover:not(:disabled) {
|
||
background-color: #218838;
|
||
}
|
||
|
||
.btn-secondary {
|
||
background-color: #6c757d;
|
||
color: white;
|
||
}
|
||
|
||
.btn-secondary:hover:not(:disabled) {
|
||
background-color: #5a6268;
|
||
}
|
||
|
||
/* Modbus参数配置区 */
|
||
.param-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 15px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.number-input {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.number-input:focus {
|
||
outline: none;
|
||
border-color: #007bff;
|
||
box-shadow: 0 0 0 3px rgba(0,123,255,0.1);
|
||
}
|
||
|
||
.number-input.error {
|
||
border-color: #dc3545;
|
||
}
|
||
|
||
.warning-text {
|
||
color: #856404;
|
||
font-size: 13px;
|
||
margin-bottom: 15px;
|
||
padding: 8px 12px;
|
||
background-color: #fff3cd;
|
||
border-left: 3px solid #ffc107;
|
||
border-radius: 4px;
|
||
display: none;
|
||
}
|
||
|
||
.warning-text.show {
|
||
display: block;
|
||
}
|
||
|
||
/* 指令预览区 */
|
||
.command-preview {
|
||
background-color: #2d2d2d;
|
||
color: #f8f8f2;
|
||
padding: 15px;
|
||
border-radius: 4px;
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
font-size: 13px;
|
||
margin-bottom: 20px;
|
||
overflow-x: auto;
|
||
border: 1px solid #444;
|
||
}
|
||
|
||
.command-preview .key {
|
||
color: #66d9ef;
|
||
}
|
||
|
||
.command-preview .number {
|
||
color: #ae81ff;
|
||
}
|
||
|
||
.command-preview .string {
|
||
color: #e6db74;
|
||
}
|
||
|
||
/* 寄存器名称自定义区 */
|
||
.register-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 10px;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
padding: 10px;
|
||
border: 1px solid #e0e0e0;
|
||
border-radius: 4px;
|
||
background-color: #fafafa;
|
||
}
|
||
|
||
/* 寄存器高级配置区 */
|
||
.register-advanced-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 15px;
|
||
max-height: 500px;
|
||
overflow-y: auto;
|
||
padding: 15px;
|
||
border: 1px solid #e0e0e0;
|
||
border-radius: 4px;
|
||
background-color: #fafafa;
|
||
}
|
||
|
||
.register-advanced-item {
|
||
background-color: white;
|
||
border: 1px solid #e0e0e0;
|
||
border-radius: 4px;
|
||
padding: 15px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.register-advanced-item:hover {
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.register-advanced-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid #eee;
|
||
}
|
||
|
||
.register-advanced-title {
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
color: #333;
|
||
}
|
||
|
||
.register-advanced-options {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr 1fr;
|
||
gap: 12px;
|
||
align-items: start;
|
||
}
|
||
|
||
.advanced-option {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
}
|
||
|
||
.advanced-option label {
|
||
font-size: 12px;
|
||
color: #666;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.advanced-option select,
|
||
.advanced-option input[type="number"] {
|
||
padding: 6px 8px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
background-color: white;
|
||
}
|
||
|
||
.advanced-option select:focus,
|
||
.advanced-option input:focus {
|
||
outline: none;
|
||
border-color: #007bff;
|
||
box-shadow: 0 0 0 2px rgba(0,123,255,0.1);
|
||
}
|
||
|
||
.register-exclude-checkbox {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.register-exclude-checkbox input[type="checkbox"] {
|
||
width: 16px;
|
||
height: 16px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.register-exclude-checkbox label {
|
||
font-size: 13px;
|
||
color: #555;
|
||
cursor: pointer;
|
||
margin: 0;
|
||
}
|
||
|
||
.register-combine-input {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
}
|
||
|
||
.register-combine-input input {
|
||
padding: 6px 8px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
width: 100%;
|
||
}
|
||
|
||
.register-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px;
|
||
background-color: white;
|
||
border: 1px solid #e0e0e0;
|
||
border-radius: 4px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.register-item .register-name-input {
|
||
flex: 1;
|
||
min-width: 120px;
|
||
}
|
||
|
||
.register-index {
|
||
font-weight: 600;
|
||
color: #007bff;
|
||
min-width: 60px;
|
||
}
|
||
|
||
.register-name-input {
|
||
flex: 1;
|
||
padding: 6px 10px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.register-name-input:focus {
|
||
outline: none;
|
||
border-color: #007bff;
|
||
box-shadow: 0 0 0 2px rgba(0,123,255,0.1);
|
||
}
|
||
|
||
/* Modbus数据解析展示区 */
|
||
.data-display-layout {
|
||
display: grid;
|
||
grid-template-columns: 2fr 1fr;
|
||
gap: 20px;
|
||
}
|
||
|
||
/* 传感器网格容器 - 与原始报文高度一致 */
|
||
.sensor-grid-container {
|
||
height: 320px;
|
||
overflow-y: auto;
|
||
border: 1px solid #e0e0e0;
|
||
border-radius: 8px;
|
||
background-color: #f5f7fa;
|
||
padding: 15px;
|
||
}
|
||
|
||
/* 传感器网格 - 米家风格 */
|
||
.sensor-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, 1fr);
|
||
gap: 12px;
|
||
}
|
||
|
||
/* 传感器卡片 - 米家风格 */
|
||
.sensor-card {
|
||
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||
border-radius: 12px;
|
||
padding: 12px 8px;
|
||
text-align: center;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||
transition: all 0.3s ease;
|
||
border: 1px solid #e8e8e8;
|
||
min-height: 80px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.sensor-card:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||
}
|
||
|
||
.sensor-card-index {
|
||
font-size: 11px;
|
||
color: #999;
|
||
margin-bottom: 4px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.sensor-card-name {
|
||
font-size: 12px;
|
||
color: #666;
|
||
margin-bottom: 6px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.sensor-card-value {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #17a2b8;
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.sensor-card-unit {
|
||
font-size: 10px;
|
||
color: #999;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.sensor-grid {
|
||
grid-template-columns: repeat(4, 1fr);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.sensor-grid {
|
||
grid-template-columns: repeat(3, 1fr);
|
||
}
|
||
.sensor-grid-container {
|
||
height: 280px;
|
||
}
|
||
}
|
||
|
||
.slave-addr-display {
|
||
padding: 12px 15px;
|
||
background-color: #d1ecf1;
|
||
color: #0c5460;
|
||
border-radius: 4px;
|
||
font-weight: 600;
|
||
margin-bottom: 15px;
|
||
border-left: 4px solid #17a2b8;
|
||
}
|
||
|
||
.data-table-container {
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
border: 1px solid #e0e0e0;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.data-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.data-table thead {
|
||
background-color: #007bff;
|
||
color: white;
|
||
position: sticky;
|
||
top: 0;
|
||
}
|
||
|
||
.data-table th {
|
||
padding: 12px;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.data-table tbody tr {
|
||
border-bottom: 1px solid #e0e0e0;
|
||
}
|
||
|
||
.data-table tbody tr:hover {
|
||
background-color: #f8f9fa;
|
||
}
|
||
|
||
.data-table td {
|
||
padding: 10px 12px;
|
||
}
|
||
|
||
.data-table td.value {
|
||
font-weight: 600;
|
||
color: #007bff;
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
}
|
||
|
||
/* 原始报文展示区 */
|
||
.raw-message-container {
|
||
border: 1px solid #e0e0e0;
|
||
border-radius: 8px;
|
||
background-color: #2d2d2d;
|
||
height: 320px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.raw-message-title {
|
||
padding: 10px 15px;
|
||
background-color: #1a1a1a;
|
||
color: #f8f8f2;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
border-bottom: 1px solid #444;
|
||
}
|
||
|
||
.raw-message-content {
|
||
padding: 15px;
|
||
color: #f8f8f2;
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
font-size: 12px;
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
line-height: 1.8;
|
||
}
|
||
|
||
.raw-timestamp {
|
||
color: #ff6b6b;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.raw-message {
|
||
color: #f8f8f2;
|
||
}
|
||
|
||
/* 滚动条样式 */
|
||
::-webkit-scrollbar {
|
||
width: 8px;
|
||
height: 8px;
|
||
}
|
||
|
||
/* 被禁用的寄存器配置项样式 */
|
||
.register-advanced-item.disabled {
|
||
opacity: 0.5;
|
||
background-color: #f0f0f0;
|
||
}
|
||
|
||
.register-advanced-item.disabled .register-advanced-title {
|
||
color: #999;
|
||
}
|
||
|
||
::-webkit-scrollbar-track {
|
||
background: #f1f1f1;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb {
|
||
background: #888;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb:hover {
|
||
background: #555;
|
||
}
|
||
|
||
/* 响应式调整 */
|
||
@media (max-width: 768px) {
|
||
.input-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.param-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.register-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.data-display-layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.two-column-layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* 清空按钮 */
|
||
.clear-btn {
|
||
padding: 5px 10px;
|
||
font-size: 12px;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.input-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
/* 空状态提示 */
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 40px 20px;
|
||
color: #999;
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* 配置管理样式 */
|
||
.config-manager {
|
||
background-color: #f8f9fa;
|
||
border: 1px solid #e0e0e0;
|
||
border-radius: 4px;
|
||
padding: 15px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.config-label {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #555555;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.config-select {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
margin-bottom: 15px;
|
||
background-color: white;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.config-select:focus {
|
||
outline: none;
|
||
border-color: #007bff;
|
||
box-shadow: 0 0 0 3px rgba(0,123,255,0.1);
|
||
}
|
||
|
||
.config-buttons {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.config-buttons .btn {
|
||
padding: 8px 15px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.config-save-group {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex: 1;
|
||
}
|
||
|
||
.config-save-group input {
|
||
flex: 1;
|
||
padding: 8px 12px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.config-save-group input:focus {
|
||
outline: none;
|
||
border-color: #007bff;
|
||
box-shadow: 0 0 0 3px rgba(0,123,255,0.1);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- 全局提示栏 -->
|
||
<div id="globalNotification" class="global-notification"></div>
|
||
|
||
<div class="container">
|
||
<!-- 连接状态显示区 -->
|
||
<div class="module-card status-card">
|
||
<div class="connection-status">
|
||
<div id="statusIndicator" class="status-indicator disconnected"></div>
|
||
<span id="statusText" class="status-text">未连接</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 设备状态信息区 -->
|
||
<div class="module-card">
|
||
<h2 class="module-title">设备状态信息</h2>
|
||
<div id="deviceStatusContent" style="background-color: #f8f9fa; padding: 15px; border-radius: 4px;">
|
||
<div class="empty-state">暂无设备状态信息</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modbus数据解析展示区 -->
|
||
<div class="module-card">
|
||
<h2 class="module-title">Modbus数据解析展示</h2>
|
||
|
||
<div id="slaveAddrDisplay" class="slave-addr-display" style="display: none;">
|
||
设备地址: <span id="slaveAddrValue">--</span>
|
||
</div>
|
||
|
||
<div class="data-display-layout">
|
||
<div class="sensor-grid-container">
|
||
<div id="sensorGrid" class="sensor-grid">
|
||
<div class="empty-state">暂无数据</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="raw-message-container">
|
||
<div class="raw-message-title">原始报文</div>
|
||
<div id="rawMessageContent" class="raw-message-content">
|
||
<div class="empty-state">暂无报文</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 参数配置区(两栏布局) -->
|
||
<div class="two-column-layout">
|
||
<!-- 左栏:Modbus动态参数配置区 + 指令预览与控制 -->
|
||
<div class="module-card">
|
||
<h2 class="module-title">Modbus动态参数配置</h2>
|
||
|
||
<div class="param-grid">
|
||
<div class="input-group">
|
||
<label for="slave_addr">Slave Addr</label>
|
||
<div class="parameter-hint">从机地址 (1-247)</div>
|
||
<input type="number" class="number-input" id="slave_addr" min="1" max="247" value="1" oninput="validateNumber(this)" onchange="onParamsChange()">
|
||
</div>
|
||
<div class="input-group">
|
||
<label for="start_addr">Start Addr</label>
|
||
<div class="parameter-hint">起始寄存器地址</div>
|
||
<input type="number" class="number-input" id="start_addr" min="0" max="65535" value="0" oninput="validateNumber(this)" onchange="onParamsChange()">
|
||
</div>
|
||
<div class="input-group">
|
||
<label for="reg_count">Reg Count</label>
|
||
<div class="parameter-hint">读取寄存器数量 (1-125)</div>
|
||
<input type="number" class="number-input" id="reg_count" min="1" max="125" value="10" oninput="validateNumber(this)" onchange="onParamsChange()">
|
||
</div>
|
||
<div class="input-group">
|
||
<label for="channel">Channel</label>
|
||
<div class="parameter-hint">RS485通道 (0或1)</div>
|
||
<input type="number" class="number-input" id="channel" min="0" max="1" value="0" oninput="validateNumber(this)" onchange="generateCommand()">
|
||
</div>
|
||
<div class="input-group">
|
||
<label for="interval">Interval</label>
|
||
<div class="parameter-hint">轮询间隔(ms, 最小100)</div>
|
||
<input type="number" class="number-input" id="interval" min="100" value="1000" oninput="validateNumber(this)" onchange="generateCommand()">
|
||
</div>
|
||
<div class="input-group">
|
||
<label for="enabled">Enabled</label>
|
||
<div class="parameter-hint">是否启用轮询</div>
|
||
<select id="enabled" class="number-input" style="padding: 10px;" onchange="generateCommand()">
|
||
<option value="true">启用</option>
|
||
<option value="false">禁用</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="warningText" class="warning-text"></div>
|
||
|
||
<!-- 指令预览和下发 -->
|
||
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #e0e0e0;">
|
||
<h3 style="font-size: 14px; color: #555; margin-bottom: 12px;">指令预览与控制</h3>
|
||
<div class="input-group" style="margin-bottom: 12px;">
|
||
<label>指令预览</label>
|
||
<div id="commandPreview" class="command-preview">{}</div>
|
||
</div>
|
||
<div class="button-group">
|
||
<button id="btnPublish" class="btn btn-success" onclick="publishCommand()" disabled>下发指令</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右栏:寄存器显示参数设置区 -->
|
||
<div class="module-card">
|
||
<h2 class="module-title">寄存器显示参数设置</h2>
|
||
|
||
<!-- 配置模式切换 -->
|
||
<div style="margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #e0e0e0;">
|
||
<div style="display: flex; gap: 10px;">
|
||
<button id="simpleModeBtn" class="btn btn-secondary" onclick="switchConfigMode('simple')" style="flex: 1;">简单模式</button>
|
||
<button id="advancedModeBtn" class="btn btn-secondary" onclick="switchConfigMode('advanced')" style="flex: 1;">高级模式</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 简单模式:仅自定义名称 -->
|
||
<div id="simpleConfigArea">
|
||
<div id="registerGrid" class="register-grid">
|
||
<div class="empty-state">请先配置Modbus参数</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 高级模式:支持缩放、单位、多寄存器组合、排除显示 -->
|
||
<div id="advancedConfigArea" style="display: none;">
|
||
<div id="registerAdvancedGrid" class="register-advanced-grid">
|
||
<div class="empty-state">请先配置Modbus参数</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MQTT连接配置区 -->
|
||
<div class="module-card">
|
||
<h2 class="module-title">MQTT连接配置</h2>
|
||
|
||
<!-- MQTT地址说明 -->
|
||
<div class="port-notice">
|
||
<strong>MQTT服务器地址说明</strong>
|
||
<p>请输入MQTT服务器的域名或IP地址。系统将自动使用ws://协议、8083端口、/mqtt路径进行连接。</p>
|
||
</div>
|
||
|
||
<!-- 配置管理 -->
|
||
<div class="config-manager">
|
||
<label class="config-label">配置管理</label>
|
||
<select id="configSelect" class="config-select" onchange="onConfigSelect()">
|
||
<option value="">-- 无保存的配置 --</option>
|
||
</select>
|
||
<div class="config-buttons">
|
||
<div class="config-save-group">
|
||
<input type="text" id="configNameInput" placeholder="配置名称(可选)">
|
||
<button class="btn btn-success" onclick="saveCurrentConfig()">保存配置</button>
|
||
</div>
|
||
<button class="btn btn-danger" onclick="deleteCurrentConfig()">删除配置</button>
|
||
<button class="btn btn-secondary" onclick="exportConfigs()">导出配置</button>
|
||
<button class="btn btn-secondary" onclick="document.getElementById('importFile').click()">导入配置</button>
|
||
<input type="file" id="importFile" style="display: none;" accept=".json" onchange="importConfigs(this)">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="input-grid">
|
||
<div class="input-group">
|
||
<label for="wsAddress">MQTT服务器地址</label>
|
||
<div class="input-wrapper">
|
||
<input type="text" id="wsAddress" value="127.0.0.1" placeholder="请输入域名或IP">
|
||
</div>
|
||
</div>
|
||
<div class="input-group">
|
||
<label for="clientId">客户端ID</label>
|
||
<div class="input-wrapper">
|
||
<input type="text" id="clientId" placeholder="自动生成或自定义">
|
||
<button type="button" class="btn btn-secondary clear-btn" onclick="generateClientId()">生成</button>
|
||
</div>
|
||
</div>
|
||
<div class="input-group">
|
||
<label for="subscribeTopic">订阅主题 *</label>
|
||
<div class="input-wrapper">
|
||
<input type="text" id="subscribeTopic" placeholder="请输入订阅主题">
|
||
</div>
|
||
</div>
|
||
<div class="input-group">
|
||
<label for="publishTopic">发布主题 *</label>
|
||
<div class="input-wrapper">
|
||
<input type="text" id="publishTopic" placeholder="请输入发布主题">
|
||
</div>
|
||
</div>
|
||
<div class="input-group">
|
||
<label for="username">用户名 *</label>
|
||
<input type="text" id="username" placeholder="请输入用户名">
|
||
</div>
|
||
<div class="input-group">
|
||
<label for="password">密码 *</label>
|
||
<input type="password" id="password" placeholder="请输入密码">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="button-group">
|
||
<button id="btnConnect" class="btn btn-primary" onclick="connectMQTT()">连接MQTT</button>
|
||
<button id="btnDisconnect" class="btn btn-danger" onclick="disconnectMQTT()" disabled>断开MQTT</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// 全局变量
|
||
let mqttClient = null;
|
||
let isConnected = false;
|
||
let registerNames = [];
|
||
let registerConfigs = []; // 高级配置:缩放、单位、多寄存器组合、排除标志
|
||
let receivedData = null;
|
||
let deviceStatus = null; // 设备状态信息
|
||
let mqttConfigs = [];
|
||
let currentConfigName = '';
|
||
let rawMessageHistory = []; // 原始报文历史记录(最多7条)
|
||
let configMode = 'simple'; // 'simple' 或 'advanced'
|
||
|
||
// 常见单位列表
|
||
const COMMON_UNITS = [
|
||
{ value: '', label: '无' },
|
||
{ value: '°C', label: '摄氏度 (°C)' },
|
||
{ value: '°F', label: '华氏度 (°F)' },
|
||
{ value: 'K', label: '开尔文 (K)' },
|
||
{ value: 'V', label: '伏特 (V)' },
|
||
{ value: 'mV', label: '毫伏 (mV)' },
|
||
{ value: 'kV', label: '千伏 (kV)' },
|
||
{ value: 'μV', label: '微伏 (μV)' },
|
||
{ value: 'A', label: '安培 (A)' },
|
||
{ value: 'mA', label: '毫安 (mA)' },
|
||
{ value: 'kA', label: '千安 (kA)' },
|
||
{ value: 'μA', label: '微安 (μA)' },
|
||
{ value: 'Ω', label: '欧姆 (Ω)' },
|
||
{ value: 'kΩ', label: '千欧 (kΩ)' },
|
||
{ value: 'MΩ', label: '兆欧 (MΩ)' },
|
||
{ value: 'mΩ', label: '毫欧 (mΩ)' },
|
||
{ value: 'Hz', label: '赫兹 (Hz)' },
|
||
{ value: 'kHz', label: '千赫 (kHz)' },
|
||
{ value: 'MHz', label: '兆赫 (MHz)' },
|
||
{ value: 'rpm', label: '转速 (rpm)' },
|
||
{ value: 'rad/s', label: '弧度/秒 (rad/s)' },
|
||
{ value: '%', label: '百分比 (%)' },
|
||
{ value: 'Pa', label: '帕斯卡 (Pa)' },
|
||
{ value: 'kPa', label: '千帕 (kPa)' },
|
||
{ value: 'MPa', label: '兆帕 (MPa)' },
|
||
{ value: 'bar', label: '巴 (bar)' },
|
||
{ value: 'mbar', label: '毫巴 (mbar)' },
|
||
{ value: 'psi', label: '磅/平方英寸 (psi)' },
|
||
{ value: 'mmHg', label: '毫米汞柱 (mmHg)' },
|
||
{ value: 'm/s', label: '米/秒 (m/s)' },
|
||
{ value: 'km/h', label: '千米/小时 (km/h)' },
|
||
{ value: 'm/s²', label: '米/秒² (m/s²)' },
|
||
{ value: 'g', label: '重力加速度 (g)' },
|
||
{ value: 'm³/h', label: '立方米/小时 (m³/h)' },
|
||
{ value: 'L/min', label: '升/分钟 (L/min)' },
|
||
{ value: 'L/h', label: '升/小时 (L/h)' },
|
||
{ value: 'm³', label: '立方米 (m³)' },
|
||
{ value: 'L', label: '升 (L)' },
|
||
{ value: 'mL', label: '毫升 (mL)' },
|
||
{ value: 'kg', label: '千克 (kg)' },
|
||
{ value: 'g', label: '克 (g)' },
|
||
{ value: 'mg', label: '毫克 (mg)' },
|
||
{ value: 't', label: '吨 (t)' },
|
||
{ value: 'lb', label: '磅 (lb)' },
|
||
{ value: 'oz', label: '盎司 (oz)' },
|
||
{ value: 'W', label: '瓦特 (W)' },
|
||
{ value: 'kW', label: '千瓦 (kW)' },
|
||
{ value: 'MW', label: '兆瓦 (MW)' },
|
||
{ value: 'mW', label: '毫瓦 (mW)' },
|
||
{ value: 'VA', label: '伏安 (VA)' },
|
||
{ value: 'kVA', label: '千伏安 (kVA)' },
|
||
{ value: 'MVA', label: '兆伏安 (MVA)' },
|
||
{ value: 'Wh', label: '瓦时 (Wh)' },
|
||
{ value: 'kWh', label: '千瓦时 (kWh)' },
|
||
{ value: 'MWh', label: '兆瓦时 (MWh)' },
|
||
{ value: 'J', label: '焦耳 (J)' },
|
||
{ value: 'kJ', label: '千焦 (kJ)' },
|
||
{ value: 'MJ', label: '兆焦 (MJ)' },
|
||
{ value: 'h', label: '小时 (h)' },
|
||
{ value: 'min', label: '分钟 (min)' },
|
||
{ value: 's', label: '秒 (s)' },
|
||
{ value: 'ms', label: '毫秒 (ms)' },
|
||
{ value: 'μs', label: '微秒 (μs)' },
|
||
{ value: 'ns', label: '纳秒 (ns)' },
|
||
{ value: 'd', label: '天 (d)' },
|
||
{ value: 'm', label: '米 (m)' },
|
||
{ value: 'km', label: '千米 (km)' },
|
||
{ value: 'cm', label: '厘米 (cm)' },
|
||
{ value: 'mm', label: '毫米 (mm)' },
|
||
{ value: 'μm', label: '微米 (μm)' },
|
||
{ value: 'nm', label: '纳米 (nm)' },
|
||
{ value: 'mi', label: '英里 (mi)' },
|
||
{ value: 'ft', label: '英尺 (ft)' },
|
||
{ value: 'in', label: '英寸 (in)' },
|
||
{ value: 'm²', label: '平方米 (m²)' },
|
||
{ value: 'cm²', label: '平方厘米 (cm²)' },
|
||
{ value: 'mm²', label: '平方毫米 (mm²)' },
|
||
{ value: 'm³', label: '立方米 (m³)' },
|
||
{ value: 'cm³', label: '立方厘米 (cm³)' },
|
||
{ value: 'mm³', label: '立方毫米 (mm³)' },
|
||
{ value: 'count', label: '计数 (count)' },
|
||
{ value: 'pcs', label: '件 (pcs)' },
|
||
{ value: 'pair', label: '对 (pair)' },
|
||
{ value: 'set', label: '套 (set)' },
|
||
{ value: 'custom', label: '自定义...' }
|
||
];
|
||
|
||
// 页面加载完成后初始化
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// 自动生成客户端ID
|
||
generateClientId();
|
||
// 生成初始指令预览
|
||
generateCommand();
|
||
// 初始化寄存器名称区域
|
||
updateRegisterNames();
|
||
// 加载MQTT配置
|
||
loadConfigs();
|
||
// 加载寄存器配置
|
||
loadRegisterConfigs();
|
||
// 初始化配置模式
|
||
switchConfigMode('simple');
|
||
// 初始化设备状态显示
|
||
updateDeviceStatusDisplay();
|
||
});
|
||
|
||
// 保存寄存器配置到localStorage
|
||
function saveRegisterConfigs() {
|
||
try {
|
||
const configData = {
|
||
registerNames: registerNames,
|
||
registerConfigs: registerConfigs,
|
||
configMode: configMode
|
||
};
|
||
localStorage.setItem('register_configs', JSON.stringify(configData));
|
||
return true;
|
||
} catch (error) {
|
||
console.error('保存寄存器配置失败:', error);
|
||
showNotification('寄存器配置保存失败', 'error');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 从localStorage加载寄存器配置
|
||
function loadRegisterConfigs() {
|
||
try {
|
||
const savedConfigs = localStorage.getItem('register_configs');
|
||
if (savedConfigs) {
|
||
const configData = JSON.parse(savedConfigs);
|
||
registerNames = configData.registerNames || [];
|
||
registerConfigs = configData.registerConfigs || [];
|
||
configMode = configData.configMode || 'simple';
|
||
}
|
||
} catch (error) {
|
||
console.error('加载寄存器配置失败:', error);
|
||
registerNames = [];
|
||
registerConfigs = [];
|
||
configMode = 'simple';
|
||
}
|
||
}
|
||
|
||
// ==================== 配置管理函数 ====================
|
||
|
||
// 从localStorage加载配置列表
|
||
function loadConfigs() {
|
||
try {
|
||
const savedConfigs = localStorage.getItem('mqtt_configs');
|
||
if (savedConfigs) {
|
||
mqttConfigs = JSON.parse(savedConfigs);
|
||
// 按最后使用时间降序排列
|
||
mqttConfigs.sort((a, b) => b.lastUsed - a.lastUsed);
|
||
} else {
|
||
mqttConfigs = [];
|
||
}
|
||
} catch (error) {
|
||
console.error('加载配置失败:', error);
|
||
mqttConfigs = [];
|
||
}
|
||
updateConfigDropdown();
|
||
}
|
||
|
||
// 保存配置列表到localStorage
|
||
function saveConfigsToStorage() {
|
||
try {
|
||
localStorage.setItem('mqtt_configs', JSON.stringify(mqttConfigs));
|
||
return true;
|
||
} catch (error) {
|
||
console.error('保存配置失败:', error);
|
||
showNotification('配置保存失败,请检查浏览器存储空间', 'error');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 更新配置下拉框
|
||
function updateConfigDropdown() {
|
||
const select = document.getElementById('configSelect');
|
||
select.innerHTML = '<option value="">-- 无保存的配置 --</option>';
|
||
|
||
if (mqttConfigs.length === 0) {
|
||
return;
|
||
}
|
||
|
||
mqttConfigs.forEach(config => {
|
||
const option = document.createElement('option');
|
||
option.value = config.name;
|
||
option.textContent = `${config.name} (${config.serverAddress})`;
|
||
select.appendChild(option);
|
||
});
|
||
|
||
// 自动选中最近使用的配置
|
||
if (mqttConfigs.length > 0) {
|
||
select.value = mqttConfigs[0].name;
|
||
loadConfigToForm(mqttConfigs[0]);
|
||
}
|
||
}
|
||
|
||
// 保存当前表单为配置
|
||
function saveCurrentConfig() {
|
||
const serverAddress = document.getElementById('wsAddress').value.trim();
|
||
const clientId = document.getElementById('clientId').value.trim();
|
||
const subscribeTopic = document.getElementById('subscribeTopic').value.trim();
|
||
const publishTopic = document.getElementById('publishTopic').value.trim();
|
||
const username = document.getElementById('username').value.trim();
|
||
const password = document.getElementById('password').value.trim();
|
||
|
||
// 校验必填字段
|
||
if (!serverAddress || !clientId || !subscribeTopic || !publishTopic || !username || !password) {
|
||
showNotification('请填写完整的MQTT连接参数', 'error');
|
||
return;
|
||
}
|
||
|
||
// 获取配置名称
|
||
let configName = document.getElementById('configNameInput').value.trim();
|
||
|
||
// 如果没有输入名称,自动生成
|
||
if (!configName) {
|
||
const timestamp = new Date().toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
configName = `${serverAddress}_${timestamp}`;
|
||
}
|
||
|
||
// 检查配置名称是否已存在
|
||
const existingIndex = mqttConfigs.findIndex(c => c.name === configName);
|
||
|
||
const newConfig = {
|
||
name: configName,
|
||
serverAddress: serverAddress,
|
||
clientId: clientId,
|
||
subscribeTopic: subscribeTopic,
|
||
publishTopic: publishTopic,
|
||
username: username,
|
||
password: password,
|
||
lastUsed: Date.now()
|
||
};
|
||
|
||
if (existingIndex !== -1) {
|
||
// 更新现有配置
|
||
mqttConfigs[existingIndex] = newConfig;
|
||
showNotification(`配置"${configName}"已更新`, 'success');
|
||
} else {
|
||
// 添加新配置
|
||
mqttConfigs.push(newConfig);
|
||
showNotification(`配置"${configName}"已保存`, 'success');
|
||
}
|
||
|
||
// 按最后使用时间排序并保存
|
||
mqttConfigs.sort((a, b) => b.lastUsed - a.lastUsed);
|
||
if (saveConfigsToStorage()) {
|
||
updateConfigDropdown();
|
||
// 清空配置名称输入框
|
||
document.getElementById('configNameInput').value = '';
|
||
}
|
||
}
|
||
|
||
// 删除当前选中的配置
|
||
function deleteCurrentConfig() {
|
||
const select = document.getElementById('configSelect');
|
||
const configName = select.value;
|
||
|
||
if (!configName) {
|
||
showNotification('请先选择要删除的配置', 'error');
|
||
return;
|
||
}
|
||
|
||
// 确认删除
|
||
if (!confirm(`确定要删除配置"${configName}"吗?`)) {
|
||
return;
|
||
}
|
||
|
||
const index = mqttConfigs.findIndex(c => c.name === configName);
|
||
if (index !== -1) {
|
||
mqttConfigs.splice(index, 1);
|
||
if (saveConfigsToStorage()) {
|
||
updateConfigDropdown();
|
||
showNotification(`配置"${configName}"已删除`, 'success');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 配置选择改变时的处理
|
||
function onConfigSelect() {
|
||
const select = document.getElementById('configSelect');
|
||
const configName = select.value;
|
||
|
||
if (!configName) {
|
||
return;
|
||
}
|
||
|
||
const config = mqttConfigs.find(c => c.name === configName);
|
||
if (config) {
|
||
loadConfigToForm(config);
|
||
currentConfigName = configName;
|
||
}
|
||
}
|
||
|
||
// 将配置加载到表单
|
||
function loadConfigToForm(config) {
|
||
document.getElementById('wsAddress').value = config.serverAddress || '';
|
||
document.getElementById('clientId').value = config.clientId || '';
|
||
document.getElementById('subscribeTopic').value = config.subscribeTopic || '';
|
||
document.getElementById('publishTopic').value = config.publishTopic || '';
|
||
document.getElementById('username').value = config.username || '';
|
||
document.getElementById('password').value = config.password || '';
|
||
currentConfigName = config.name;
|
||
}
|
||
|
||
// 更新配置的最后使用时间
|
||
function updateConfigLastUsed(configName) {
|
||
if (!configName) return;
|
||
|
||
const index = mqttConfigs.findIndex(c => c.name === configName);
|
||
if (index !== -1) {
|
||
mqttConfigs[index].lastUsed = Date.now();
|
||
// 按最后使用时间排序
|
||
mqttConfigs.sort((a, b) => b.lastUsed - a.lastUsed);
|
||
saveConfigsToStorage();
|
||
updateConfigDropdown();
|
||
}
|
||
}
|
||
|
||
// 自动保存当前配置(连接成功后调用)
|
||
function autoSaveCurrentConfig(serverAddress, clientId, subscribeTopic, publishTopic, username, password) {
|
||
// 检查是否有完全匹配的配置
|
||
const existingConfig = mqttConfigs.find(c =>
|
||
c.serverAddress === serverAddress &&
|
||
c.clientId === clientId &&
|
||
c.subscribeTopic === subscribeTopic &&
|
||
c.publishTopic === publishTopic &&
|
||
c.username === username &&
|
||
c.password === password
|
||
);
|
||
|
||
if (existingConfig) {
|
||
// 更新已有配置的使用时间
|
||
updateConfigLastUsed(existingConfig.name);
|
||
currentConfigName = existingConfig.name;
|
||
} else {
|
||
// 创建新配置并保存
|
||
const timestamp = new Date().toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
|
||
const newConfig = {
|
||
name: `${serverAddress}_${timestamp}`,
|
||
serverAddress: serverAddress,
|
||
clientId: clientId,
|
||
subscribeTopic: subscribeTopic,
|
||
publishTopic: publishTopic,
|
||
username: username,
|
||
password: password,
|
||
lastUsed: Date.now()
|
||
};
|
||
|
||
mqttConfigs.push(newConfig);
|
||
mqttConfigs.sort((a, b) => b.lastUsed - a.lastUsed);
|
||
|
||
if (saveConfigsToStorage()) {
|
||
updateConfigDropdown();
|
||
currentConfigName = newConfig.name;
|
||
showNotification('连接配置已自动保存', 'info');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 导出配置为JSON文件
|
||
function exportConfigs() {
|
||
if (mqttConfigs.length === 0) {
|
||
showNotification('没有可导出的配置', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const dataStr = JSON.stringify(mqttConfigs, null, 2);
|
||
const blob = new Blob([dataStr], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `mqtt_configs_${new Date().getTime()}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
|
||
showNotification(`已导出${mqttConfigs.length}个配置`, 'success');
|
||
} catch (error) {
|
||
console.error('导出配置失败:', error);
|
||
showNotification('导出配置失败', 'error');
|
||
}
|
||
}
|
||
|
||
// 从JSON文件导入配置
|
||
function importConfigs(input) {
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
|
||
// 重置input以便重复选择同一文件
|
||
input.value = '';
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = function(e) {
|
||
try {
|
||
const importedConfigs = JSON.parse(e.target.result);
|
||
|
||
// 校验数据格式
|
||
if (!Array.isArray(importedConfigs)) {
|
||
throw new Error('配置格式错误:应为数组');
|
||
}
|
||
|
||
// 校验每个配置的必需字段
|
||
const requiredFields = ['name', 'serverAddress', 'clientId', 'subscribeTopic', 'publishTopic', 'username', 'password'];
|
||
for (const config of importedConfigs) {
|
||
for (const field of requiredFields) {
|
||
if (!config.hasOwnProperty(field)) {
|
||
throw new Error(`配置缺少必需字段: ${field}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 合并配置
|
||
let addedCount = 0;
|
||
let updatedCount = 0;
|
||
|
||
for (const importedConfig of importedConfigs) {
|
||
const existingIndex = mqttConfigs.findIndex(c => c.name === importedConfig.name);
|
||
const newConfig = {
|
||
...importedConfig,
|
||
lastUsed: importedConfig.lastUsed || Date.now()
|
||
};
|
||
|
||
if (existingIndex !== -1) {
|
||
// 更新现有配置
|
||
if (confirm(`配置"${importedConfig.name}"已存在,是否覆盖?`)) {
|
||
mqttConfigs[existingIndex] = newConfig;
|
||
updatedCount++;
|
||
}
|
||
} else {
|
||
// 添加新配置
|
||
mqttConfigs.push(newConfig);
|
||
addedCount++;
|
||
}
|
||
}
|
||
|
||
// 按最后使用时间排序并保存
|
||
mqttConfigs.sort((a, b) => b.lastUsed - a.lastUsed);
|
||
if (saveConfigsToStorage()) {
|
||
updateConfigDropdown();
|
||
let message = `导入成功!`;
|
||
if (addedCount > 0) message += ` 新增${addedCount}个`;
|
||
if (updatedCount > 0) message += ` 更新${updatedCount}个`;
|
||
showNotification(message, 'success');
|
||
}
|
||
} catch (error) {
|
||
console.error('导入配置失败:', error);
|
||
showNotification(`导入配置失败: ${error.message}`, 'error');
|
||
}
|
||
};
|
||
|
||
reader.onerror = function() {
|
||
showNotification('读取文件失败', 'error');
|
||
};
|
||
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
// 生成随机客户端ID
|
||
function generateClientId() {
|
||
const timestamp = Date.now().toString();
|
||
document.getElementById('clientId').value = 'web_control_' + timestamp;
|
||
showNotification('已生成新的客户端ID', 'info');
|
||
}
|
||
|
||
// 数值输入校验
|
||
function validateNumber(input) {
|
||
const value = input.value;
|
||
if (value === '' || value < 0) {
|
||
input.classList.add('error');
|
||
showNotification('请输入有效的正整数', 'error');
|
||
} else {
|
||
input.classList.remove('error');
|
||
}
|
||
}
|
||
|
||
// 参数变化处理
|
||
function onParamsChange() {
|
||
generateCommand();
|
||
validateParams();
|
||
updateRegisterNames();
|
||
}
|
||
|
||
// 参数逻辑校验
|
||
function validateParams() {
|
||
const slave_addr = parseInt(document.getElementById('slave_addr').value) || 0;
|
||
const start_addr = parseInt(document.getElementById('start_addr').value) || 0;
|
||
const reg_count = parseInt(document.getElementById('reg_count').value) || 0;
|
||
const interval = parseInt(document.getElementById('interval').value) || 0;
|
||
const warningText = document.getElementById('warningText');
|
||
|
||
const warnings = [];
|
||
|
||
if (slave_addr < 1 || slave_addr > 247) {
|
||
warnings.push('从机地址范围应为 1-247');
|
||
}
|
||
if (start_addr < 0 || start_addr > 65535) {
|
||
warnings.push('起始地址范围应为 0-65535');
|
||
}
|
||
if (reg_count < 1 || reg_count > 125) {
|
||
warnings.push('寄存器数量范围应为 1-125');
|
||
}
|
||
if (interval < 100) {
|
||
warnings.push('轮询间隔最小为 100ms');
|
||
}
|
||
|
||
if (warnings.length > 0) {
|
||
warningText.textContent = warnings.join('; ');
|
||
warningText.classList.add('show');
|
||
} else {
|
||
warningText.classList.remove('show');
|
||
}
|
||
}
|
||
|
||
// 生成JSON指令
|
||
function generateCommand() {
|
||
const slave_addr = parseInt(document.getElementById('slave_addr').value) || 1;
|
||
const start_addr = parseInt(document.getElementById('start_addr').value) || 0;
|
||
const reg_count = parseInt(document.getElementById('reg_count').value) || 10;
|
||
const channel = parseInt(document.getElementById('channel').value) || 0;
|
||
const interval = parseInt(document.getElementById('interval').value) || 1000;
|
||
const enabled = document.getElementById('enabled').value === 'true';
|
||
|
||
const command = {
|
||
command: "modbus_poll",
|
||
channel: channel,
|
||
slave_addr: slave_addr,
|
||
start_addr: start_addr,
|
||
reg_count: reg_count,
|
||
interval: interval,
|
||
enabled: enabled
|
||
};
|
||
|
||
const preview = document.getElementById('commandPreview');
|
||
preview.innerHTML = formatJSON(command);
|
||
}
|
||
|
||
// JSON格式化展示
|
||
function formatJSON(obj) {
|
||
const json = JSON.stringify(obj, null, 2);
|
||
return json
|
||
.replace(/"([^"]+)":/g, '<span class="key">"$1"</span>:')
|
||
.replace(/: ([0-9]+)/g, ': <span class="number">$1</span>')
|
||
.replace(/: "([^"]+)"/g, ': <span class="string">"$1"</span>');
|
||
}
|
||
|
||
// ==================== 寄存器配置管理 ====================
|
||
|
||
// 切换配置模式
|
||
function switchConfigMode(mode) {
|
||
configMode = mode;
|
||
saveRegisterConfigs();
|
||
const simpleBtn = document.getElementById('simpleModeBtn');
|
||
const advancedBtn = document.getElementById('advancedModeBtn');
|
||
const simpleArea = document.getElementById('simpleConfigArea');
|
||
const advancedArea = document.getElementById('advancedConfigArea');
|
||
|
||
if (mode === 'simple') {
|
||
simpleBtn.classList.remove('btn-secondary');
|
||
simpleBtn.classList.add('btn-primary');
|
||
advancedBtn.classList.remove('btn-primary');
|
||
advancedBtn.classList.add('btn-secondary');
|
||
simpleArea.style.display = 'block';
|
||
advancedArea.style.display = 'none';
|
||
// 从高级模式同步到简单模式
|
||
syncConfigFromAdvancedToSimple();
|
||
updateRegisterNames();
|
||
} else {
|
||
advancedBtn.classList.remove('btn-secondary');
|
||
advancedBtn.classList.add('btn-primary');
|
||
simpleBtn.classList.remove('btn-primary');
|
||
simpleBtn.classList.add('btn-secondary');
|
||
simpleArea.style.display = 'none';
|
||
advancedArea.style.display = 'block';
|
||
// 从简单模式同步到高级模式
|
||
syncConfigFromSimpleToAdvanced();
|
||
updateAdvancedRegisterConfigs(); // 初始化高级配置
|
||
}
|
||
}
|
||
|
||
// 从简单模式同步配置到高级模式
|
||
function syncConfigFromSimpleToAdvanced() {
|
||
for (let i = 0; i < registerNames.length; i++) {
|
||
if (!registerConfigs[i]) {
|
||
registerConfigs[i] = { exclude: false, scaleFactor: 1, unit: '', combineRegisters: '' };
|
||
}
|
||
// 同步名称
|
||
registerConfigs[i].name = registerNames[i];
|
||
}
|
||
saveRegisterConfigs();
|
||
}
|
||
|
||
// 从高级模式同步配置到简单模式
|
||
function syncConfigFromAdvancedToSimple() {
|
||
for (let i = 0; i < registerConfigs.length; i++) {
|
||
if (registerConfigs[i] && registerConfigs[i].name) {
|
||
registerNames[i] = registerConfigs[i].name;
|
||
}
|
||
}
|
||
saveRegisterConfigs();
|
||
}
|
||
|
||
// 更新寄存器名称区域(简单模式)
|
||
function updateRegisterNames() {
|
||
const reg_count = parseInt(document.getElementById('reg_count').value) || 0;
|
||
const start_addr = parseInt(document.getElementById('start_addr').value) || 0;
|
||
const registerGrid = document.getElementById('registerGrid');
|
||
|
||
// 清空现有内容
|
||
registerGrid.innerHTML = '';
|
||
|
||
if (reg_count <= 0) {
|
||
registerGrid.innerHTML = '<div class="empty-state">请先设置有效的寄存器数量</div>';
|
||
return;
|
||
}
|
||
|
||
// 保存旧的寄存器名称
|
||
const oldNames = [...registerNames];
|
||
registerNames = [];
|
||
|
||
// 同步简单模式的排除状态到高级配置
|
||
for (let i = 0; i < reg_count; i++) {
|
||
if (!registerConfigs[i]) {
|
||
registerConfigs[i] = { exclude: false, scaleFactor: 1, unit: '', combineRegisters: '' };
|
||
}
|
||
}
|
||
|
||
// 生成新的寄存器名称输入框
|
||
for (let i = 0; i < reg_count; i++) {
|
||
const registerIndex = start_addr + i;
|
||
// 优先使用高级配置中的名称,如果没有则使用旧名称
|
||
const config = registerConfigs[i] || {};
|
||
const name = config.name || oldNames[i] || `寄存器${registerIndex}`;
|
||
registerNames.push(name);
|
||
|
||
const item = document.createElement('div');
|
||
item.className = 'register-item';
|
||
item.innerHTML = `
|
||
<span class="register-index">${registerIndex}</span>
|
||
<input type="text" class="register-name-input"
|
||
value="${name}"
|
||
data-index="${i}"
|
||
onchange="onRegisterNameChange(${i}, this.value)"
|
||
onkeyup="onRegisterNameChange(${i}, this.value)"
|
||
style="flex: 1;">
|
||
<div class="register-exclude-checkbox" style="flex-shrink: 0;">
|
||
<input type="checkbox" id="simpleExclude_${i}" ${config.exclude ? 'checked' : ''}
|
||
onchange="onSimpleExcludeChange(${i}, this.checked)">
|
||
<label for="simpleExclude_${i}">排除</label>
|
||
</div>
|
||
`;
|
||
registerGrid.appendChild(item);
|
||
}
|
||
|
||
// 更新数据表格
|
||
updateDataTable();
|
||
}
|
||
|
||
// 简单模式的排除变化处理
|
||
function onSimpleExcludeChange(index, value) {
|
||
if (!registerConfigs[index]) {
|
||
registerConfigs[index] = { exclude: false, scaleFactor: 1, unit: '', combineRegisters: '' };
|
||
}
|
||
registerConfigs[index].exclude = value;
|
||
saveRegisterConfigs();
|
||
updateDataTable();
|
||
}
|
||
|
||
// 更新寄存器高级配置区域
|
||
function updateAdvancedRegisterConfigs() {
|
||
const reg_count = parseInt(document.getElementById('reg_count').value) || 0;
|
||
const start_addr = parseInt(document.getElementById('start_addr').value) || 0;
|
||
const advancedGrid = document.getElementById('registerAdvancedGrid');
|
||
|
||
// 清空现有内容
|
||
advancedGrid.innerHTML = '';
|
||
|
||
if (reg_count <= 0) {
|
||
advancedGrid.innerHTML = '<div class="empty-state">请先设置有效的寄存器数量</div>';
|
||
return;
|
||
}
|
||
|
||
// 保存旧的配置
|
||
const oldConfigs = [...registerConfigs];
|
||
registerConfigs = [];
|
||
|
||
// 生成单位选项(包括自定义)
|
||
const unitOptions = COMMON_UNITS.map(u => `<option value="${u.value}">${u.label}</option>`).join('');
|
||
|
||
// 用于检测被组合的寄存器(禁用显示)
|
||
const combinedIndices = new Set();
|
||
const combineOwners = {}; // 记录每个被组合寄存器属于哪个主寄存器
|
||
|
||
// 第一遍:收集所有被组合的寄存器索引
|
||
for (let i = 0; i < reg_count; i++) {
|
||
const oldConfig = oldConfigs[i] || {};
|
||
if (oldConfig.combineRegisters && oldConfig.combineRegisters.trim()) {
|
||
const indices = oldConfig.combineRegisters.split(',').map(idx => parseInt(idx.trim())).filter(idx => !isNaN(idx));
|
||
indices.forEach((idx, index) => {
|
||
combinedIndices.add(idx);
|
||
if (!combineOwners[idx]) {
|
||
combineOwners[idx] = i; // 记录该寄存器被哪个主寄存器组合
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 生成新的寄存器配置项
|
||
for (let i = 0; i < reg_count; i++) {
|
||
const registerIndex = start_addr + i;
|
||
const defaultName = `寄存器${registerIndex}`;
|
||
const name = oldConfigs[i]?.name || defaultName;
|
||
const oldConfig = oldConfigs[i] || {};
|
||
|
||
const newConfig = {
|
||
name: name,
|
||
exclude: oldConfig.exclude || false,
|
||
scaleFactor: oldConfig.scaleFactor || 1,
|
||
unit: oldConfig.unit || '',
|
||
combineRegisters: oldConfig.combineRegisters || ''
|
||
};
|
||
registerConfigs.push(newConfig);
|
||
|
||
// 判断是否应该禁用(被其他寄存器组合且不是主寄存器)
|
||
const isDisabled = combinedIndices.has(i) && combineOwners[i] !== i;
|
||
|
||
const disabledAttr = isDisabled ? 'disabled style="background-color: #f5f5f5; cursor: not-allowed;"' : '';
|
||
const disabledClass = isDisabled ? 'disabled' : '';
|
||
const disabledText = isDisabled ? ' (已被组合)' : '';
|
||
|
||
// 判断是否是自定义单位
|
||
const isCustomUnit = newConfig.unit && newConfig.unit !== '' && !COMMON_UNITS.some(u => u.value === newConfig.unit);
|
||
let customUnitInput = '';
|
||
if (isCustomUnit || newConfig.unit === 'custom') {
|
||
customUnitInput = `<input type="text" id="customUnit_${i}" value="${isCustomUnit ? newConfig.unit : ''}"
|
||
placeholder="输入自定义单位"
|
||
onchange="onCustomUnitChange(${i}, this.value)"
|
||
style="width: 100%; padding: 6px 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px; margin-top: 5px;">`;
|
||
}
|
||
|
||
const item = document.createElement('div');
|
||
item.className = `register-advanced-item ${disabledClass}`;
|
||
item.id = `registerItem_${i}`;
|
||
item.innerHTML = `
|
||
<div class="register-advanced-header">
|
||
<span class="register-advanced-title">寄存器 ${registerIndex}${disabledText}</span>
|
||
</div>
|
||
<div style="margin-bottom: 12px;">
|
||
<label style="font-size: 12px; color: #666; font-weight: 500; display: block; margin-bottom: 5px;">自定义名称</label>
|
||
<input type="text" class="register-name-input"
|
||
value="${newConfig.name}"
|
||
data-index="${i}"
|
||
onchange="onRegisterConfigChange(${i}, 'name', this.value)"
|
||
onkeyup="onRegisterConfigChange(${i}, 'name', this.value)"
|
||
style="width: 100%; padding: 6px 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px;"
|
||
${disabledAttr}>
|
||
</div>
|
||
<div class="register-advanced-options" id="options_${i}" ${disabledAttr}>
|
||
<div class="register-exclude-checkbox">
|
||
<input type="checkbox" id="exclude_${i}" ${newConfig.exclude ? 'checked' : ''}
|
||
onchange="onRegisterConfigChange(${i}, 'exclude', this.checked)"
|
||
${disabledAttr}>
|
||
<label for="exclude_${i}">排除显示(无值时)</label>
|
||
</div>
|
||
<div class="advanced-option">
|
||
<label>缩放因子</label>
|
||
<input type="number" id="scale_${i}" value="${newConfig.scaleFactor}" step="0.1"
|
||
onchange="onRegisterConfigChange(${i}, 'scaleFactor', this.value)"
|
||
${disabledAttr}>
|
||
</div>
|
||
<div class="advanced-option">
|
||
<label>单位</label>
|
||
<select id="unit_${i}" onchange="onUnitSelectChange(${i}, this.value)"
|
||
${disabledAttr}>
|
||
${unitOptions.replace(`value="${newConfig.unit}"`, `value="${newConfig.unit}" selected`)}
|
||
</select>
|
||
<div id="customUnitContainer_${i}">${customUnitInput}</div>
|
||
</div>
|
||
<div class="advanced-option" style="grid-column: span 3;">
|
||
<label>多寄存器组合(逗号分隔,如:0,1 表示组合前2个寄存器)</label>
|
||
<input type="text" id="combine_${i}" value="${newConfig.combineRegisters}" placeholder="留空表示不组合"
|
||
onchange="onRegisterConfigChange(${i}, 'combineRegisters', this.value)"
|
||
onkeyup="onCombineRegisterChange(${i}, this.value)"
|
||
style="width: 100%; padding: 6px 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px;"
|
||
${disabledAttr}>
|
||
</div>
|
||
</div>
|
||
`;
|
||
advancedGrid.appendChild(item);
|
||
}
|
||
|
||
// 更新数据表格
|
||
updateDataTable();
|
||
}
|
||
|
||
// 寄存器配置修改处理
|
||
function onRegisterConfigChange(index, field, value) {
|
||
if (field === 'scaleFactor') {
|
||
value = parseFloat(value) || 1;
|
||
}
|
||
registerConfigs[index][field] = value;
|
||
saveRegisterConfigs();
|
||
updateDataTable();
|
||
}
|
||
|
||
// 单位选择变化处理
|
||
function onUnitSelectChange(index, value) {
|
||
const customUnitContainer = document.getElementById(`customUnitContainer_${index}`);
|
||
if (value === 'custom') {
|
||
customUnitContainer.innerHTML = `<input type="text" id="customUnit_${index}"
|
||
placeholder="输入自定义单位"
|
||
onchange="onCustomUnitChange(${index}, this.value)"
|
||
style="width: 100%; padding: 6px 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px; margin-top: 5px;">`;
|
||
document.getElementById(`customUnit_${index}`).focus();
|
||
} else {
|
||
customUnitContainer.innerHTML = '';
|
||
registerConfigs[index].unit = value;
|
||
saveRegisterConfigs();
|
||
updateDataTable();
|
||
}
|
||
}
|
||
|
||
// 自定义单位变化处理
|
||
function onCustomUnitChange(index, value) {
|
||
registerConfigs[index].unit = value;
|
||
saveRegisterConfigs();
|
||
updateDataTable();
|
||
}
|
||
|
||
// 组合寄存器变化处理
|
||
function onCombineRegisterChange(index, value) {
|
||
registerConfigs[index].combineRegisters = value;
|
||
saveRegisterConfigs();
|
||
updateDataTable();
|
||
// 更新界面以反映新的组合状态
|
||
updateAdvancedRegisterConfigs();
|
||
}
|
||
|
||
// 寄存器名称修改处理
|
||
function onRegisterNameChange(index, value) {
|
||
registerNames[index] = value;
|
||
// 同步到高级配置
|
||
if (!registerConfigs[index]) {
|
||
registerConfigs[index] = { exclude: false, scaleFactor: 1, unit: '', combineRegisters: '' };
|
||
}
|
||
registerConfigs[index].name = value;
|
||
saveRegisterConfigs();
|
||
updateDataTable();
|
||
}
|
||
|
||
// 更新数据展示(米家风格传感器卡片)
|
||
function updateDataTable() {
|
||
const sensorGrid = document.getElementById('sensorGrid');
|
||
const reg_count = registerNames.length;
|
||
|
||
if (reg_count === 0) {
|
||
sensorGrid.innerHTML = '<div class="empty-state" style="grid-column: span 5;">暂无数据</div>';
|
||
return;
|
||
}
|
||
|
||
if (!receivedData) {
|
||
sensorGrid.innerHTML = '<div class="empty-state" style="grid-column: span 5;">暂无数据</div>';
|
||
return;
|
||
}
|
||
|
||
const start_addr = parseInt(document.getElementById('start_addr').value) || 0;
|
||
sensorGrid.innerHTML = '';
|
||
|
||
// 用于跟踪已处理的组合寄存器,避免重复显示
|
||
const processedRegisters = new Set();
|
||
|
||
for (let i = 0; i < reg_count; i++) {
|
||
// 如果该寄存器已被组合处理,跳过
|
||
if (processedRegisters.has(i)) {
|
||
continue;
|
||
}
|
||
|
||
// 获取值(检查索引是否存在)
|
||
if (receivedData.registers[i] === undefined || receivedData.registers[i] === null) {
|
||
continue;
|
||
}
|
||
|
||
// 根据当前模式获取配置
|
||
const config = registerConfigs[i] || {};
|
||
const registerName = configMode === 'advanced' ? (config.name || `寄存器${start_addr + i}`) : (registerNames[i] || `寄存器${start_addr + i}`);
|
||
let value = receivedData.registers[i];
|
||
let displayValue = value;
|
||
let displayUnit = '';
|
||
|
||
// 检查是否需要排除该寄存器(简单模式和高级模式都支持)
|
||
if (config.exclude) {
|
||
continue; // 跳过此寄存器
|
||
}
|
||
|
||
// 高级模式处理
|
||
if (configMode === 'advanced') {
|
||
// 处理多寄存器组合
|
||
if (config.combineRegisters && config.combineRegisters.trim()) {
|
||
try {
|
||
const indices = config.combineRegisters.split(',').map(idx => parseInt(idx.trim())).filter(idx => !isNaN(idx) && idx >= 0 && idx < reg_count);
|
||
if (indices.length > 0 && indices[0] === i) {
|
||
// 这是组合的主寄存器,计算组合值
|
||
let combinedValue = 0;
|
||
for (const idx of indices) {
|
||
if (receivedData.registers[idx] !== undefined && receivedData.registers[idx] !== null) {
|
||
combinedValue = combinedValue * 65536 + receivedData.registers[idx];
|
||
}
|
||
processedRegisters.add(idx);
|
||
}
|
||
value = combinedValue;
|
||
} else {
|
||
// 不是主寄存器,跳过(已被主寄存器处理)
|
||
continue;
|
||
}
|
||
} catch (e) {
|
||
console.error('解析组合寄存器失败:', e);
|
||
}
|
||
}
|
||
|
||
// 应用缩放因子
|
||
displayValue = (value * (config.scaleFactor || 1)).toFixed(2);
|
||
// 去除末尾的0
|
||
displayValue = parseFloat(displayValue).toString();
|
||
|
||
// 添加单位
|
||
displayUnit = config.unit || '';
|
||
} else {
|
||
// 简单模式:也应用缩放因子和单位(如果有配置)
|
||
if (config.scaleFactor && config.scaleFactor !== 1) {
|
||
displayValue = (value * config.scaleFactor).toFixed(2);
|
||
displayValue = parseFloat(displayValue).toString();
|
||
}
|
||
// 简单模式也支持单位显示
|
||
displayUnit = config.unit || '';
|
||
}
|
||
|
||
// 创建传感器卡片
|
||
const card = document.createElement('div');
|
||
card.className = 'sensor-card';
|
||
card.innerHTML = `
|
||
<div class="sensor-card-index">#${start_addr + i}</div>
|
||
<div class="sensor-card-name" title="${registerName}">${registerName}</div>
|
||
<div class="sensor-card-value">${displayValue}</div>
|
||
${displayUnit ? `<div class="sensor-card-unit">${displayUnit}</div>` : ''}
|
||
`;
|
||
sensorGrid.appendChild(card);
|
||
}
|
||
|
||
// 如果所有寄存器都被排除,显示提示
|
||
if (sensorGrid.children.length === 0) {
|
||
sensorGrid.innerHTML = '<div class="empty-state" style="grid-column: span 5;">所有寄存器值均为空或已被排除</div>';
|
||
}
|
||
}
|
||
|
||
// MQTT连接
|
||
function connectMQTT() {
|
||
const serverAddress = document.getElementById('wsAddress').value.trim();
|
||
const clientId = document.getElementById('clientId').value.trim();
|
||
const subscribeTopic = document.getElementById('subscribeTopic').value.trim();
|
||
const publishTopic = document.getElementById('publishTopic').value.trim();
|
||
const username = document.getElementById('username').value.trim();
|
||
const password = document.getElementById('password').value.trim();
|
||
|
||
// 构建完整的WebSocket地址
|
||
const wsAddress = 'ws://' + serverAddress + ':8083/mqtt';
|
||
|
||
// 参数校验
|
||
if (!serverAddress) {
|
||
showNotification('请输入MQTT服务器地址', 'error');
|
||
return;
|
||
}
|
||
if (!clientId) {
|
||
showNotification('请输入客户端ID', 'error');
|
||
return;
|
||
}
|
||
if (!subscribeTopic) {
|
||
showNotification('请输入订阅主题', 'error');
|
||
return;
|
||
}
|
||
if (!publishTopic) {
|
||
showNotification('请输入发布主题', 'error');
|
||
return;
|
||
}
|
||
if (!username) {
|
||
showNotification('请输入用户名', 'error');
|
||
return;
|
||
}
|
||
if (!password) {
|
||
showNotification('请输入密码', 'error');
|
||
return;
|
||
}
|
||
|
||
// 更新状态为连接中
|
||
updateConnectionStatus('connecting');
|
||
showNotification('正在连接MQTT...', 'info');
|
||
|
||
try {
|
||
// 创建MQTT客户端
|
||
const options = {
|
||
clientId: clientId,
|
||
clean: true,
|
||
connectTimeout: 10000,
|
||
reconnectPeriod: 0
|
||
};
|
||
|
||
options.username = username;
|
||
options.password = password;
|
||
|
||
mqttClient = mqtt.connect(wsAddress, options);
|
||
|
||
// 连接成功
|
||
mqttClient.on('connect', function() {
|
||
isConnected = true;
|
||
updateConnectionStatus('connected');
|
||
updateButtonStates();
|
||
showNotification('MQTT连接成功', 'success');
|
||
|
||
// 订阅主题
|
||
mqttClient.subscribe(subscribeTopic, function(err) {
|
||
if (!err) {
|
||
showNotification(`已订阅主题: ${subscribeTopic}`, 'success');
|
||
|
||
// 连接成功后自动发送默认指令
|
||
setTimeout(function() {
|
||
publishCommand();
|
||
}, 500); // 延迟500ms发送,确保订阅已生效
|
||
} else {
|
||
showNotification(`订阅主题失败: ${err.message}`, 'error');
|
||
}
|
||
});
|
||
|
||
// 自动保存当前配置
|
||
autoSaveCurrentConfig(serverAddress, clientId, subscribeTopic, publishTopic, username, password);
|
||
});
|
||
|
||
// 连接失败
|
||
mqttClient.on('error', function(err) {
|
||
console.error('MQTT连接错误:', err);
|
||
isConnected = false;
|
||
updateConnectionStatus('error');
|
||
updateButtonStates();
|
||
showNotification(`MQTT连接失败: ${err.message}`, 'error');
|
||
});
|
||
|
||
// 连接关闭
|
||
mqttClient.on('close', function() {
|
||
isConnected = false;
|
||
updateConnectionStatus('disconnected');
|
||
updateButtonStates();
|
||
showNotification('MQTT连接已断开', 'info');
|
||
});
|
||
|
||
// 接收消息
|
||
mqttClient.on('message', function(t, message) {
|
||
handleReceivedMessage(message);
|
||
});
|
||
|
||
// 连接超时
|
||
mqttClient.on('offline', function() {
|
||
if (!isConnected) {
|
||
updateConnectionStatus('error');
|
||
updateButtonStates();
|
||
showNotification('MQTT连接超时,请检查网络和服务端配置', 'error');
|
||
}
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('MQTT连接异常:', error);
|
||
updateConnectionStatus('error');
|
||
updateButtonStates();
|
||
showNotification(`MQTT连接异常: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// MQTT断开
|
||
function disconnectMQTT() {
|
||
if (mqttClient) {
|
||
try {
|
||
const subscribeTopic = document.getElementById('subscribeTopic').value.trim();
|
||
mqttClient.unsubscribe(subscribeTopic);
|
||
mqttClient.end();
|
||
mqttClient = null;
|
||
} catch (error) {
|
||
console.error('断开MQTT连接异常:', error);
|
||
}
|
||
}
|
||
|
||
isConnected = false;
|
||
updateConnectionStatus('disconnected');
|
||
updateButtonStates();
|
||
showNotification('已断开MQTT连接', 'success');
|
||
}
|
||
|
||
// 更新连接状态显示
|
||
function updateConnectionStatus(status) {
|
||
const indicator = document.getElementById('statusIndicator');
|
||
const statusText = document.getElementById('statusText');
|
||
|
||
indicator.className = 'status-indicator ' + status;
|
||
|
||
switch(status) {
|
||
case 'disconnected':
|
||
statusText.textContent = '未连接';
|
||
break;
|
||
case 'connecting':
|
||
statusText.textContent = '连接中...';
|
||
break;
|
||
case 'connected':
|
||
statusText.textContent = '已连接';
|
||
break;
|
||
case 'error':
|
||
statusText.textContent = '连接失败';
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 更新按钮状态
|
||
function updateButtonStates() {
|
||
const btnConnect = document.getElementById('btnConnect');
|
||
const btnDisconnect = document.getElementById('btnDisconnect');
|
||
const btnPublish = document.getElementById('btnPublish');
|
||
|
||
btnConnect.disabled = isConnected;
|
||
btnDisconnect.disabled = !isConnected;
|
||
btnPublish.disabled = !isConnected;
|
||
}
|
||
|
||
// 下发指令
|
||
function publishCommand() {
|
||
if (!isConnected) {
|
||
showNotification('请先连接MQTT', 'error');
|
||
return;
|
||
}
|
||
|
||
const publishTopic = document.getElementById('publishTopic').value.trim();
|
||
if (!publishTopic) {
|
||
showNotification('请输入发布主题', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const slave_addr = parseInt(document.getElementById('slave_addr').value) || 1;
|
||
const start_addr = parseInt(document.getElementById('start_addr').value) || 0;
|
||
const reg_count = parseInt(document.getElementById('reg_count').value) || 10;
|
||
const channel = parseInt(document.getElementById('channel').value) || 0;
|
||
const interval = parseInt(document.getElementById('interval').value) || 1000;
|
||
const enabled = document.getElementById('enabled').value === 'true';
|
||
|
||
if (reg_count <= 0) {
|
||
showNotification('请设置有效的寄存器数量', 'error');
|
||
return;
|
||
}
|
||
|
||
const command = {
|
||
command: "modbus_poll",
|
||
channel: channel,
|
||
slave_addr: slave_addr,
|
||
start_addr: start_addr,
|
||
reg_count: reg_count,
|
||
interval: interval,
|
||
enabled: enabled
|
||
};
|
||
|
||
const message = JSON.stringify(command);
|
||
|
||
mqttClient.publish(publishTopic, message, { qos: 0 }, function(err) {
|
||
if (err) {
|
||
showNotification(`指令下发失败: ${err.message}`, 'error');
|
||
} else {
|
||
showNotification('指令下发成功', 'success');
|
||
}
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('下发指令异常:', error);
|
||
showNotification(`下发指令异常: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// HTML转义函数,防止XSS攻击
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// 处理接收到的消息
|
||
function handleReceivedMessage(message) {
|
||
try {
|
||
const messageStr = message.toString();
|
||
const now = new Date();
|
||
const timestamp = now.toLocaleTimeString('zh-CN', { hour12: false }) + '.' + now.getMilliseconds().toString().padStart(3, '0');
|
||
|
||
// 添加到历史记录(最多保留7条)
|
||
rawMessageHistory.unshift({ timestamp, message: messageStr });
|
||
if (rawMessageHistory.length > 7) {
|
||
rawMessageHistory.pop();
|
||
}
|
||
|
||
// 显示原始报文历史(带红色时间戳)
|
||
const rawContent = document.getElementById('rawMessageContent');
|
||
rawContent.innerHTML = rawMessageHistory.map(item =>
|
||
`<div><span class="raw-timestamp">[${item.timestamp}]</span> <span class="raw-message">${escapeHtml(item.message)}</span></div>`
|
||
).join('');
|
||
|
||
// 解析JSON
|
||
const data = JSON.parse(messageStr);
|
||
|
||
// 判断消息类型
|
||
if (data.message_type === 'device_status') {
|
||
// 处理设备状态信息
|
||
deviceStatus = data;
|
||
updateDeviceStatusDisplay();
|
||
showNotification('收到设备状态更新', 'info');
|
||
return;
|
||
}
|
||
|
||
// 处理MODBUS数据
|
||
if (!data.hasOwnProperty('slave_addr') || !data.hasOwnProperty('registers')) {
|
||
showNotification('报文格式错误:缺少slave_addr或registers字段', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!Array.isArray(data.registers)) {
|
||
showNotification('报文格式错误:registers不是数组', 'error');
|
||
return;
|
||
}
|
||
|
||
// 校验寄存器数量
|
||
const reg_count = parseInt(document.getElementById('reg_count').value) || 0;
|
||
if (data.registers.length !== reg_count) {
|
||
showNotification(`寄存器数量不匹配(本地设置${reg_count}个,设备返回${data.registers.length}个)`, 'warning');
|
||
}
|
||
|
||
// 更新设备地址显示
|
||
const slaveAddrDisplay = document.getElementById('slaveAddrDisplay');
|
||
const slaveAddrValue = document.getElementById('slaveAddrValue');
|
||
slaveAddrDisplay.style.display = 'block';
|
||
slaveAddrValue.textContent = data.slave_addr;
|
||
|
||
// 保存接收的数据
|
||
receivedData = {
|
||
slave_addr: data.slave_addr,
|
||
registers: data.registers
|
||
};
|
||
|
||
// 更新数据表格
|
||
updateDataTable();
|
||
|
||
} catch (error) {
|
||
console.error('解析报文异常:', error);
|
||
showNotification('报文格式错误/非指定JSON格式', 'error');
|
||
}
|
||
}
|
||
|
||
// 显示全局提示
|
||
function showNotification(message, type = 'info') {
|
||
const notification = document.getElementById('globalNotification');
|
||
notification.textContent = message;
|
||
notification.className = 'global-notification ' + type;
|
||
notification.style.display = 'block';
|
||
|
||
// 3秒后自动隐藏
|
||
setTimeout(function() {
|
||
notification.style.display = 'none';
|
||
}, 3000);
|
||
}
|
||
|
||
// 更新设备状态显示
|
||
function updateDeviceStatusDisplay() {
|
||
const statusContent = document.getElementById('deviceStatusContent');
|
||
|
||
if (!deviceStatus) {
|
||
statusContent.innerHTML = '<div class="empty-state">暂无设备状态信息</div>';
|
||
return;
|
||
}
|
||
|
||
const html = `
|
||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px;">
|
||
<!-- 基本信息 -->
|
||
<div style="background-color: white; padding: 15px; border-radius: 4px; border: 1px solid #e0e0e0;">
|
||
<h4 style="margin: 0 0 12px 0; color: #007bff; font-size: 14px;">基本信息</h4>
|
||
<div style="font-size: 13px; line-height: 1.8;">
|
||
<div><strong>MAC地址:</strong> ${deviceStatus.mac_address || '--'}</div>
|
||
<div><strong>IP地址:</strong> ${deviceStatus.ip_address || '--'}</div>
|
||
<div><strong>芯片型号:</strong> ${deviceStatus.chip_model || '--'}</div>
|
||
<div><strong>固件版本:</strong> ${deviceStatus.idf_version || '--'}</div>
|
||
<div><strong>运行时间:</strong> ${deviceStatus.uptime_desc || '--'}</div>
|
||
<div><strong>更新时间:</strong> ${deviceStatus.update_time || '--'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 运行状态 -->
|
||
<div style="background-color: white; padding: 15px; border-radius: 4px; border: 1px solid #e0e0e0;">
|
||
<h4 style="margin: 0 0 12px 0; color: #28a745; font-size: 14px;">运行状态</h4>
|
||
<div style="font-size: 13px; line-height: 1.8;">
|
||
<div><strong>设备状态:</strong> <span style="color: ${deviceStatus.status === 'online' ? '#28a745' : '#dc3545'}">${deviceStatus.status_desc || '--'}</span></div>
|
||
<div><strong>剩余内存:</strong> ${deviceStatus.free_heap ? deviceStatus.free_heap.toLocaleString() : '--'} 字节</div>
|
||
<div><strong>内存状态:</strong> <span style="color: ${getHeapStatusColor(deviceStatus.heap_status)}">${deviceStatus.heap_status || '--'}</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- LED状态 -->
|
||
<div style="background-color: white; padding: 15px; border-radius: 4px; border: 1px solid #e0e0e0;">
|
||
<h4 style="margin: 0 0 12px 0; color: #ffc107; font-size: 14px;">LED状态</h4>
|
||
<div style="font-size: 13px; line-height: 1.8;">
|
||
<div><strong>LED1 (${deviceStatus.led1_function || '--'}):</strong> ${deviceStatus.led1_desc || '--'}</div>
|
||
<div><strong>LED2 (${deviceStatus.led2_function || '--'}):</strong> ${deviceStatus.led2_desc || '--'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MODBUS轮询状态 -->
|
||
<div style="background-color: white; padding: 15px; border-radius: 4px; border: 1px solid #e0e0e0; grid-column: span 3;">
|
||
<h4 style="margin: 0 0 12px 0; color: #17a2b8; font-size: 14px;">MODBUS轮询状态</h4>
|
||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; font-size: 13px;">
|
||
<div><strong>轮询状态:</strong> <span style="color: ${deviceStatus.modbus_enabled ? '#28a745' : '#dc3545'}">${deviceStatus.modbus_enabled_desc || '--'}</span></div>
|
||
<div><strong>RS485通道:</strong> ${deviceStatus.modbus_channel_desc || '--'}</div>
|
||
<div><strong>从机地址:</strong> ${deviceStatus.modbus_slave_addr || '--'}</div>
|
||
<div><strong>轮询间隔:</strong> ${deviceStatus.modbus_interval ? deviceStatus.modbus_interval + ' ms' : '--'}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
statusContent.innerHTML = html;
|
||
}
|
||
|
||
// 获取内存状态颜色
|
||
function getHeapStatusColor(status) {
|
||
switch (status) {
|
||
case '充足':
|
||
return '#28a745';
|
||
case '一般':
|
||
return '#ffc107';
|
||
case '紧张':
|
||
return '#dc3545';
|
||
default:
|
||
return '#999';
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|