Files
DistributedCollectorGateway/web_control/index.html

2417 lines
91 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>