/** * pages.js — 页面渲染器 * * 每个页面对象实现: * render(container) — 渲染 HTML 到容器 * onEnter() — 页面激活时调用 * onLeave() — 页面离开时调用 * onData(signalType, arr) — 接收下行数据更新表格 * * 术语映射: * out → 注册 | in → 链接注册 | yk → 遥控 | ao → 参数 | param → 定值 */ var CTRL_TYPE_MAP = { 0: '只读', 1: '直控', 2: '选控' }; /* 每种信号类型的最大注册数量 */ var MAX_SIGNALS_PER_TYPE = 500; /** * 获取指定信号类型已注册的数量 * @param {string} signalType - out/in/yk/ao/param * @returns {number} */ function getRegisteredCount(signalType) { var data = (typeof App !== 'undefined' && App.getSignalData) ? App.getSignalData(signalType) : null; return (data && Array.isArray(data)) ? data.length : 0; } /* ======================================================================== * 共用工具 * ======================================================================== */ function renderTable(containerId, headers, rowsHtml, emptyText) { var html = '
'; html += ''; for (var i = 0; i < headers.length; i++) { html += ''; } html += ''; if (rowsHtml) { html += rowsHtml; } else { html += ''; } html += '
' + headers[i] + '
' + (emptyText || '暂无数据,请先添加信号') + '
'; return html; } function fmtVal(v) { if (v === null || v === undefined || v === '') return '-'; return String(v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } /* ------------------------------------------------------------------------ * XML 工具函数(导入导出) * ------------------------------------------------------------------------ */ function escapeXml(str) { if (typeof str !== 'string') return String(str); return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } /** * 导出所有5种信号类型(out/in/yk/ao/param)到一个XML文件 */ function exportAllToXml() { var types = ['out', 'in', 'yk', 'ao', 'param']; var typeLabels = { out:'注册', in:'链接注册', yk:'遥控', ao:'参数', param:'定值' }; var hasData = false; var xml = '\n\n'; for (var t = 0; t < types.length; t++) { var st = types[t]; var data = null; if (typeof App !== 'undefined' && App.getSignalData) { data = App.getSignalData(st); } if (!data || !Array.isArray(data) || data.length === 0) { xml += ' \n'; continue; } hasData = true; xml += ' \n'; for (var i = 0; i < data.length; i++) { xml += ' \n'; } xml += ' \n'; } xml += '\n'; if (!hasData) { alert('当前没有任何信号数据,无法导出'); return; } var blob = new Blob([xml], { type: 'application/xml;charset=utf-8' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = 'signals_all_' + Date.now() + '.xml'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } /** * 从 XML 文件导入所有5种信号类型,按 type 分组批量执行 add */ function importAllFromXml() { var input = document.createElement('input'); input.type = 'file'; input.accept = '.xml'; input.style.display = 'none'; document.body.appendChild(input); input.onchange = function(e) { var file = e.target.files[0]; if (!file) { document.body.removeChild(input); return; } var reader = new FileReader(); reader.onload = function(ev) { document.body.removeChild(input); try { var parser = new DOMParser(); var xmlDoc = parser.parseFromString(ev.target.result, 'text/xml'); var parseError = xmlDoc.querySelector('parsererror'); if (parseError) { alert('XML 解析失败:' + parseError.textContent); return; } var typeNodes = xmlDoc.querySelectorAll('signals'); if (typeNodes.length === 0) { alert('XML 文件中没有 元素'); return; } var validTypes = { out: '注册', in: '链接注册', yk: '遥控', ao: '参数', param: '定值' }; var totalCount = 0; var typeCounts = {}; for (var ti = 0; ti < typeNodes.length; ti++) { var signalsEl = typeNodes[ti]; var fileType = signalsEl.getAttribute('type'); if (!fileType || !validTypes[fileType]) continue; var signals = []; var children = signalsEl.children; for (var si = 0; si < children.length; si++) { if (children[si].tagName !== 'signal') continue; var saddr = children[si].getAttribute('saddr'); if (!saddr || !saddr.trim()) continue; var noAttr = children[si].getAttribute('no'); signals.push({ no: noAttr !== null ? parseInt(noAttr) : si, saddr: saddr.trim() }); } if (signals.length > 0) { typeCounts[fileType] = signals.length; totalCount += signals.length; } } if (totalCount === 0) { alert('XML 文件中没有有效的信号数据'); return; } var msg = '即将导入以下信号:\n'; for (var t in typeCounts) { msg += ' ' + validTypes[t] + ' (' + t + '): ' + typeCounts[t] + ' 个\n'; } msg += '\n共 ' + totalCount + ' 个信号,是否继续?'; if (!confirm(msg)) { return; } for (var ti2 = 0; ti2 < typeNodes.length; ti2++) { var signalsEl2 = typeNodes[ti2]; var fileType2 = signalsEl2.getAttribute('type'); if (!fileType2 || !validTypes[fileType2]) continue; var children2 = signalsEl2.children; for (var si2 = 0; si2 < children2.length; si2++) { if (children2[si2].tagName !== 'signal') continue; var saddr2 = children2[si2].getAttribute('saddr'); if (!saddr2 || !saddr2.trim()) continue; WsClient.send({ saddr: saddr2.trim(), signal_type: fileType2, curd: 'add', setting_zone: '0', signal_data: '' }); } } alert('导入完成,共发送 ' + totalCount + ' 个 add 请求'); } catch (ex) { alert('导入失败:' + ex.message); } }; reader.readAsText(file); }; input.click(); } /* ------------------------------------------------------------------------ * 共用工具栏 HTML * ------------------------------------------------------------------------ */ function makeToolbar(signalType, showSet) { var typeLabel = signalType === 'ao' ? '参数' : (signalType === 'param' ? '定值' : signalType); return '' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '' + '' + '
'; } function bindToolbar(signalType, getDataFn) { var execBtn = document.getElementById('tb-exec'); var clearBtn = document.getElementById('tb-clear'); var saddrInput = document.getElementById('tb-saddr'); var curdSelect = document.getElementById('tb-curd'); if (!execBtn || !clearBtn) return; execBtn.onclick = function() { var saddr = saddrInput.value.trim(); var curd = curdSelect.value; if (!saddr) { alert('请输入短地址 saddr'); return; } if (curd === 'add') { var cur = getRegisteredCount(signalType); if (cur >= MAX_SIGNALS_PER_TYPE) { alert(signalType + ' 已注册 ' + cur + ' 个,最多允许 ' + MAX_SIGNALS_PER_TYPE + ' 个,请先删除旧信号'); return; } } if (!WsClient.send({ saddr: saddr, signal_type: signalType, curd: curd, setting_zone: '0', signal_data: '' })) { alert('WebSocket 未连接'); } }; clearBtn.onclick = function() { saddrInput.value = ''; }; } /* ======================================================================== * 1. LoginPage — 登录页 * ======================================================================== */ var LoginPage = { render: function(container) { container.innerHTML = '' + '
' + '

RTU 远程终端监控系统

' + '' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '' + '' + '
'; document.getElementById('login-btn').onclick = function() { localStorage.setItem('rtu_logged_in', '1'); window.location.hash = '#dashboard'; }; }, onEnter: function() { document.body.classList.add('login-page'); }, onLeave: function() { document.body.classList.remove('login-page'); } }; /* ======================================================================== * 2. DashboardPage — 功能入口主页 * ======================================================================== */ var DashboardPage = { render: function(container) { var cards = [ { cls: 'out', icon: '📡', title: '注册', desc: 'out — 状态量信号注册与修改', hash: '#out' }, { cls: 'in', icon: '📊', title: '链接注册', desc: 'in — 测量量信号链接注册与监视', hash: '#in' }, { cls: 'yk', icon: '⚡', title: '遥控', desc: 'yk — 遥控分合闸控制', hash: '#yk' }, { cls: 'ao', icon: '🔧', title: '参数', desc: 'ao — 模拟输出参数设定', hash: '#ao' }, { cls: 'param', icon: '⚙', title: '定值', desc: 'param — 多定值区参数管理', hash: '#param' }, { cls: 'monitor', icon: '📋', title: '数据监控', desc: '实时查看 WebSocket 交互数据', hash: '#monitor' }, { cls: 'terminal', icon: '💻', title: '命令终端', desc: '实时命令行操作 RTU 系统', hash: '#terminal' }, { cls: 'plc', icon: '🔌', title: 'PLC调试', desc: '指示灯/按键绑定 out 信号调试', hash: '#plc_debug' }, { cls: 'plc_cfg', icon: '🧮', title: 'PLC逻辑配置', desc: 'PLC 逻辑图可视化查看与在线编辑', hash: '#plc_config' } ]; var html = ''; html += '
'; for (var i = 0; i < cards.length; i++) { var c = cards[i]; html += '' + '
' + c.icon + '
' + '
' + c.title + '
' + '
' + c.desc + '
' + '
'; } html += '
'; html += '
' + '

📦 批量操作

' + '

导入/导出所有信号类型 (out/in/yk/ao/param) 到一个 XML 文件

' + '' + '' + '
'; container.innerHTML = html; document.getElementById('btn-export-all').onclick = exportAllToXml; document.getElementById('btn-import-all').onclick = importAllFromXml; }, onEnter: function() {}, onLeave: function() {} }; /* ======================================================================== * 3. OutPage — 注册管理 (out) * ======================================================================== */ var OutPage = (function() { var data = []; var selected = {}; /* {idx: true} 行选中状态 */ function getCheckedItems() { var items = []; for (var k in selected) { if (selected.hasOwnProperty(k) && data[parseInt(k)]) { items.push(data[parseInt(k)]); } } return items; } function updateDelBtn() { var btn = document.getElementById('out-del-btn'); if (!btn) return; var checked = document.querySelectorAll('#out-tbody .row-cb:checked'); var cnt = checked.length; btn.textContent = '🗑 删除选中' + (cnt > 0 ? '(' + cnt + ')' : ''); btn.disabled = (cnt === 0); } function renderTableHtml() { if (data.length === 0) return ''; var rows = ''; for (var i = 0; i < data.length; i++) { var item = data[i]; var checked = !!selected[i]; rows += '' + '' + '' + (i + 1) + '' + '' + fmtVal(item.saddr) + '' + '' + fmtVal(item.desc) + '' + '' + fmtVal(item.type) + '' + '' + fmtVal(item.val) + '' + '' + '' + ''; } return rows; } function bindModifyButtons() { var buttons = document.querySelectorAll('#out-tbody .btn-info'); for (var k = 0; k < buttons.length; k++) { buttons[k].onclick = function() { var idx = parseInt(this.getAttribute('data-idx')); var input = document.getElementById('mod-' + idx); var val = input.value.trim(); if (val === '') { alert('请输入修改值'); return; } var item = data[idx]; WsClient.send({ curd:'set', signal_type:'out', saddr:item.saddr, signal_data:val, setting_zone:'0' }); }; } /* 绑定行复选框 */ var cbs = document.querySelectorAll('#out-tbody .row-cb'); for (var j = 0; j < cbs.length; j++) { cbs[j].onclick = function(e) { e.stopPropagation(); var idx = this.getAttribute('data-idx'); if (this.checked) selected[idx] = true; else delete selected[idx]; updateDelBtn(); }; } } function doDeleteChecked() { var items = getCheckedItems(); if (items.length === 0) { alert('请先勾选要删除的信号'); return; } if (!confirm('确定删除 ' + items.length + ' 个信号?')) return; for (var i = 0; i < items.length; i++) { WsClient.send({ curd:'del', signal_type:'out', saddr:items[i].saddr, setting_zone:'0', signal_data:'' }); } selected = {}; updateDelBtn(); } return { render: function(container) { selected = {}; var headers = ['', '#', 'saddr', 'desc', '数据类型', 'val', '修改值', '修改']; container.innerHTML = '' + '' + makeToolbar('out') + '
' + renderTable('out-tbody', headers, renderTableHtml(), '暂无注册数据,请先添加信号'); bindToolbar('out', function() { return data; }); bindModifyButtons(); var delBtn = document.getElementById('out-del-btn'); if (delBtn) delBtn.onclick = doDeleteChecked; updateDelBtn(); }, onEnter: function() {}, onLeave: function() { data = []; selected = {}; }, onData: function(arr) { data = arr || []; var tbody = document.getElementById('out-tbody'); if (!tbody) return; if (data.length === 0) { tbody.innerHTML = '暂无注册数据,请先添加信号'; updateDelBtn(); return; } var oldRowCnt = tbody.querySelectorAll('tr:not(.empty-row)').length; if (oldRowCnt !== data.length) { tbody.innerHTML = renderTableHtml(); bindModifyButtons(); } else { var valCells = tbody.querySelectorAll('.val-cell'); for (var v = 0; v < data.length && v < valCells.length; v++) { valCells[v].textContent = fmtVal(data[v].val); } } updateDelBtn(); } }; })(); /* ======================================================================== * 4. InPage — 链接注册管理 (in) * ======================================================================== */ var InPage = (function() { var data = []; var selected = {}; function getCheckedItems() { var items = []; for (var k in selected) { if (selected.hasOwnProperty(k) && data[parseInt(k)]) items.push(data[parseInt(k)]); } return items; } function updateDelBtn() { var btn = document.getElementById('in-del-btn'); if (!btn) return; var cnt = 0; for (var k in selected) { if (selected.hasOwnProperty(k)) cnt++; } btn.textContent = '🗑 删除选中' + (cnt > 0 ? '(' + cnt + ')' : ''); btn.disabled = (cnt === 0); } function renderTableHtml() { if (data.length === 0) return ''; var rows = ''; for (var i = 0; i < data.length; i++) { var item = data[i]; var checked = !!selected[i]; rows += '' + '' + '' + (i + 1) + '' + '' + fmtVal(item.saddr) + '' + '' + fmtVal(item.desc) + '' + '' + fmtVal(item.type) + '' + '' + fmtVal(item.val) + '' + ''; } return rows; } function bindCb() { var cbs = document.querySelectorAll('#in-tbody .row-cb'); for (var j = 0; j < cbs.length; j++) { cbs[j].onclick = function(e) { e.stopPropagation(); var idx = this.getAttribute('data-idx'); if (this.checked) selected[idx] = true; else delete selected[idx]; updateDelBtn(); }; } } function doDeleteChecked() { var items = getCheckedItems(); if (items.length === 0) { alert('请先勾选要删除的信号'); return; } if (!confirm('确定删除 ' + items.length + ' 个信号?')) return; for (var i = 0; i < items.length; i++) { WsClient.send({ curd:'del', signal_type:'in', saddr:items[i].saddr, setting_zone:'0', signal_data:'' }); } selected = {}; updateDelBtn(); } return { render: function(container) { selected = {}; var headers = ['', '#', 'saddr', 'desc', '数据类型', 'val']; container.innerHTML = '' + '' + makeToolbar('in') + '
' + renderTable('in-tbody', headers, renderTableHtml(), '暂无链接注册数据,请先添加信号'); bindToolbar('in', function() { return data; }); bindCb(); var delBtn = document.getElementById('in-del-btn'); if (delBtn) delBtn.onclick = doDeleteChecked; updateDelBtn(); }, onEnter: function() {}, onLeave: function() { data = []; selected = {}; }, onData: function(arr) { data = arr || []; var tbody = document.getElementById('in-tbody'); if (!tbody) return; if (data.length === 0) { tbody.innerHTML = '暂无链接注册数据,请先添加信号'; updateDelBtn(); return; } tbody.innerHTML = renderTableHtml(); bindCb(); updateDelBtn(); } }; })(); /* ======================================================================== * 5. YkPage — 遥控管理 (yk) * ======================================================================== */ var YkPage = (function() { var data = []; var selected = {}; function getCheckedItems() { var items = []; for (var k in selected) { if (selected.hasOwnProperty(k) && data[parseInt(k)]) items.push(data[parseInt(k)]); } return items; } function updateDelBtn() { var btn = document.getElementById('yk-del-btn'); if (!btn) return; var cnt = 0; for (var k in selected) { if (selected.hasOwnProperty(k)) cnt++; } btn.textContent = '🗑 删除选中' + (cnt > 0 ? '(' + cnt + ')' : ''); btn.disabled = (cnt === 0); } function renderTableHtml() { if (data.length === 0) return ''; var rows = ''; for (var i = 0; i < data.length; i++) { var item = data[i]; var checked = !!selected[i]; var ctrlLabel = CTRL_TYPE_MAP[item.ctrl_type] || item.ctrl_type; rows += '' + '' + '' + (i + 1) + '' + '' + fmtVal(item.saddr) + '' + '' + fmtVal(item.desc) + '' + '' + fmtVal(item.type) + '' + '' + fmtVal(item.val) + '' + '' + ctrlLabel + '' + '' + fmtVal(item.min) + '' + '' + fmtVal(item.max) + '' + '' + fmtVal(item.step) + '' + '' + fmtVal(item.unit) + '' + '' + '' + ''; } return rows; } function bindButtons() { var buttons = document.querySelectorAll('#yk-tbody .btn-sm'); for (var k = 0; k < buttons.length; k++) { buttons[k].onclick = function() { var idx = parseInt(this.getAttribute('data-idx')); var val = this.getAttribute('data-val'); var item = data[idx]; WsClient.send({ curd: 'set', signal_type: 'yk', saddr: item.saddr, signal_data: val, setting_zone: '0' }); }; } var cbs = document.querySelectorAll('#yk-tbody .row-cb'); for (var j = 0; j < cbs.length; j++) { cbs[j].onclick = function(e) { e.stopPropagation(); var idx = this.getAttribute('data-idx'); if (this.checked) selected[idx] = true; else delete selected[idx]; updateDelBtn(); }; } } function doDeleteChecked() { var items = getCheckedItems(); if (items.length === 0) { alert('请先勾选要删除的信号'); return; } if (!confirm('确定删除 ' + items.length + ' 个信号?')) return; for (var i = 0; i < items.length; i++) { WsClient.send({ curd:'del', signal_type:'yk', saddr:items[i].saddr, setting_zone:'0', signal_data:'' }); } selected = {}; updateDelBtn(); } return { render: function(container) { selected = {}; var headers = ['', '#', 'saddr', 'desc', '数据类型', 'val', 'ctrl_type', 'min', 'max', 'step', 'unit', '修改值', '修改']; container.innerHTML = '' + '' + makeToolbar('yk') + '
' + renderTable('yk-tbody', headers, renderTableHtml(), '暂无遥控数据,请先添加信号'); bindToolbar('yk', function() { return data; }); bindButtons(); var delBtn = document.getElementById('yk-del-btn'); if (delBtn) delBtn.onclick = doDeleteChecked; updateDelBtn(); }, onEnter: function() {}, onLeave: function() { data = []; selected = {}; }, onData: function(arr) { data = arr || []; // 不重置 selected,保留用户勾选 var tbody = document.getElementById('yk-tbody'); if (!tbody) return; if (data.length === 0) { tbody.innerHTML = '暂无遥控数据,请先添加信号'; updateDelBtn(); return; } tbody.innerHTML = renderTableHtml(); bindButtons(); updateDelBtn(); } }; })(); /* ======================================================================== * 6. AoPage — 参数管理 (ao, 单值修改) * ======================================================================== */ var AoPage = (function() { var data = []; var selected = {}; function getCheckedItems() { var items=[]; for(var k in selected){if(selected.hasOwnProperty(k)&&data[parseInt(k)])items.push(data[parseInt(k)]);} return items; } function updateDelBtn() { var btn=document.getElementById('ao-del-btn'); if(!btn)return; var cnt=0; for(var k in selected){if(selected.hasOwnProperty(k))cnt++;} btn.textContent='🗑 删除选中'+(cnt>0?'('+cnt+')':''); btn.disabled=(cnt===0); } function renderTableHtml() { if (data.length === 0) return ''; var rows = ''; for (var i = 0; i < data.length; i++) { var item = data[i]; var checked = !!selected[i]; var ctrlLabel = CTRL_TYPE_MAP[item.ctrl_type] || item.ctrl_type; rows += '' + '' + '' + (i + 1) + '' + '' + fmtVal(item.saddr) + '' + '' + fmtVal(item.desc) + '' + '' + fmtVal(item.type) + '' + '' + fmtVal(item.val) + '' + '' + ctrlLabel + '' + '' + fmtVal(item.min) + '' + '' + fmtVal(item.max) + '' + '' + fmtVal(item.step) + '' + '' + fmtVal(item.unit) + '' + '' + '' + ''; } return rows; } function bindAll() { var buttons = document.querySelectorAll('#ao-tbody .btn-info'); for (var k = 0; k < buttons.length; k++) { buttons[k].onclick=function(){var idx=parseInt(this.getAttribute('data-idx'));var input=document.getElementById('ao-mod-'+idx);var val=input.value.trim();if(val===''){alert('请输入修改值');return;}var item=data[idx]; WsClient.send({curd:'set',signal_type:'ao',saddr:item.saddr,signal_data:val,setting_zone:'0'});};} var cbs = document.querySelectorAll('#ao-tbody .row-cb'); for (var j = 0; j < cbs.length; j++) { cbs[j].onclick=function(e){e.stopPropagation();var idx=this.getAttribute('data-idx');if(this.checked)selected[idx]=true;else delete selected[idx];updateDelBtn();};} } function doDeleteChecked() { var items=getCheckedItems(); if(items.length===0){alert('请先勾选要删除的信号');return;} if(!confirm('确定删除 '+items.length+' 个信号?'))return; for(var i=0;i

参数管理

ao — 模拟输出参数设定,支持单值修改

' + makeToolbar('ao') + '
' + renderTable('ao-tbody', headers, renderTableHtml(), '暂无参数数据,请先添加信号'); bindToolbar('ao', function() { return data; }); bindAll(); var delBtn = document.getElementById('ao-del-btn'); if (delBtn) delBtn.onclick = doDeleteChecked; updateDelBtn(); }, onEnter: function() {}, onLeave: function() { data = []; selected = {}; }, onData: function(arr) { data = arr || []; // 不重置 selected,保留用户勾选 var tbody = document.getElementById('ao-tbody'); if (!tbody) return; if (data.length === 0) { tbody.innerHTML = '暂无参数数据,请先添加信号'; updateDelBtn(); return; } tbody.innerHTML = renderTableHtml(); bindAll(); updateDelBtn(); } }; })(); /* ======================================================================== * 7. ParamPage — 定值管理 (param, 多定值区展开) * ======================================================================== */ var ParamPage = (function() { var data = []; var expandedRows = {}; var selected = {}; function getCheckedItems() { var items=[]; for(var k in selected){if(selected.hasOwnProperty(k)&&data[parseInt(k)])items.push(data[parseInt(k)]);} return items; } function updateDelBtn() { var btn=document.getElementById('param-del-btn'); if(!btn)return; var cnt=0; for(var k in selected){if(selected.hasOwnProperty(k))cnt++;} btn.textContent='🗑 删除选中'+(cnt>0?'('+cnt+')':''); btn.disabled=(cnt===0); } function renderTableHtml() { if (data.length === 0) return ''; var rows = ''; for (var i = 0; i < data.length; i++) { var item = data[i]; var ctrlLabel = CTRL_TYPE_MAP[item.ctrl_type] || item.ctrl_type; var zones = item.setting_zone_list || []; var zoneCount = zones.length; var isOpen = expandedRows[i] === true; var icon = isOpen ? '▼' : '▶'; var checked = !!selected[i]; // 主行(可点击展开) rows += '' + '' + '' + (i + 1) + '' + '' + fmtVal(item.saddr) + '' + '' + fmtVal(item.desc) + '' + '' + fmtVal(item.type) + '' + '' + ctrlLabel + '' + '' + zoneCount + '' + '' + icon + ' 展开' + ''; // 展开的定值区子表 if (isOpen) { rows += '' + '' + '' + '' + '' + '' + ''; for (var j = 0; j < zones.length; j++) { var zone = zones[j]; rows += '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; } rows += '
定值区 IDvaldefault_valminmaxstepunit修改值修改
' + fmtVal(zone.id) + '' + fmtVal(zone.val) + '' + fmtVal(zone.default_val) + '' + fmtVal(item.min) + '' + fmtVal(item.max) + '' + fmtVal(item.step) + '' + fmtVal(item.unit) + '
'; } } return rows; } function bindEvents() { var expandRows = document.querySelectorAll('#param-tbody .expand-row'); for (var k = 0; k < expandRows.length; k++) { expandRows[k].onclick = function(e) { if (e.target.tagName === 'INPUT') return; // 不拦截勾选框 var idx = parseInt(this.getAttribute('data-pidx')); expandedRows[idx] = !expandedRows[idx]; refreshTable(); }; } var modButtons = document.querySelectorAll('#param-tbody .btn-info'); for (var m = 0; m < modButtons.length; m++) { modButtons[m].onclick = function(e) { e.stopPropagation(); var pidx=parseInt(this.getAttribute('data-pidx')); var zidx=parseInt(this.getAttribute('data-zidx')); var input=document.getElementById('pm-'+pidx+'-'+zidx); var val=input.value.trim(); if(val===''){alert('请输入修改值');return;} var item=data[pidx]; var zoneId=item.setting_zone_list[zidx].id; WsClient.send({curd:'set',signal_type:'param',saddr:item.saddr,signal_data:val,setting_zone:zoneId});}; } var cbs = document.querySelectorAll('#param-tbody .row-cb'); for (var j = 0; j < cbs.length; j++) { cbs[j].onclick = function(e) { e.stopPropagation(); var idx = this.getAttribute('data-idx'); if(this.checked)selected[idx]=true; else delete selected[idx]; updateDelBtn(); }; } } function doDeleteChecked() { var items=getCheckedItems(); if(items.length===0){alert('请先勾选要删除的信号');return;} if(!confirm('确定删除 '+items.length+' 个信号?'))return; for(var i=0;i

定值管理

param — 多定值区参数管理,点击行展开查看各定值区详情并可修改

' + makeToolbar('param') + '
' + renderTable('param-tbody', headers, renderTableHtml(), '暂无定值数据,请先添加信号'); bindToolbar('param', function() { return data; }); bindEvents(); var delBtn = document.getElementById('param-del-btn'); if (delBtn) delBtn.onclick = doDeleteChecked; updateDelBtn(); }, onEnter: function() {}, onLeave: function() { data = []; expandedRows = {}; selected = {}; }, onData: function(arr) { data = arr || []; expandedRows = {}; // 不重置 selected,保留用户勾选 var tbody = document.getElementById('param-tbody'); if (!tbody) return; if (data.length === 0) { tbody.innerHTML = '暂无定值数据,请先添加信号'; updateDelBtn(); return; } tbody.innerHTML = renderTableHtml(); bindEvents(); updateDelBtn(); } }; })(); /* ======================================================================== * 8. MonitorPage — 数据交互监控(可展开列表,15 分钟留存) * ======================================================================== */ var MonitorPage = (function() { var refreshTimer = null; var expandedSet = {}; // 记录展开状态 {index: true} function formatTime(ts) { var d = new Date(ts); var h = String(d.getHours()).padStart(2, '0'); var m = String(d.getMinutes()).padStart(2, '0'); var s = String(d.getSeconds()).padStart(2, '0'); var ms = String(d.getMilliseconds()).padStart(3, '0'); return h + ':' + m + ':' + s + '.' + ms; } function buildSummary(dir, raw) { try { var obj = JSON.parse(raw); if (dir === '↑') { // 上行:提取 curd, signal_type, saddr var parts = []; if (obj.curd) parts.push(obj.curd); if (obj.signal_type) parts.push(obj.signal_type); if (obj.saddr) parts.push(obj.saddr); return parts.length > 0 ? parts.join(' ') : raw.substring(0, 60); } else { // 下行:统计各信号类型数量 var summaryParts = []; var types = ['out', 'in', 'yk', 'ao', 'param']; for (var t = 0; t < types.length; t++) { var arr = obj[types[t]]; if (Array.isArray(arr) && arr.length > 0) { summaryParts.push(types[t] + '×' + arr.length); } } return summaryParts.length > 0 ? summaryParts.join(', ') : raw.substring(0, 60); } } catch (e) { return raw.substring(0, 60); } } function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function renderLog() { var el = document.getElementById('monitor-log'); if (!el) return; var records = MonitorRing.getAll(); if (records.length === 0) { el.innerHTML = '
暂无数据交互,请先操作信号页面...
'; document.getElementById('monitor-count').textContent = '0'; return; } // 保存滚动状态:用户是否在底部 var prevScrollTop = el.scrollTop; var prevClientHeight = el.clientHeight; var prevScrollHeight = el.scrollHeight; var wasAtBottom = (prevScrollTop + prevClientHeight >= prevScrollHeight - 10); var html = ''; for (var i = 0; i < records.length; i++) { var r = records[i]; var isOpen = expandedSet[i] === true; var summary = buildSummary(r.dir, r.data); var dirClass = r.dir === '↑' ? 'mon-up' : 'mon-down'; var dirLabel = r.dir === '↑' ? '↑ 发送' : '↓ 接收'; var source = r.dir === '↑' ? '客户端' : 'RTU Server'; var target = r.dir === '↑' ? 'RTU Server' : '客户端'; var arrow = r.dir === '↑' ? '→' : '←'; html += '
'; html += '
'; html += '[' + formatTime(r.time) + ']'; html += '' + dirLabel + ''; html += '' + source + ' ' + arrow + ' ' + target + ''; html += '' + escapeHtml(summary) + ''; html += '' + (isOpen ? '▼' : '▶') + ''; html += '
'; if (isOpen) { html += '
' + escapeHtml(r.data) + '
'; } html += '
'; } el.innerHTML = html; // 绑定点击展开/收起 var heads = el.querySelectorAll('.mon-item-head'); for (var k = 0; k < heads.length; k++) { heads[k].onclick = function() { var item = this.parentElement; var idx = parseInt(item.getAttribute('data-midx')); expandedSet[idx] = !expandedSet[idx]; renderLog(); }; } // 恢复滚动位置:在底部才跟随,否则保持原位 if(wasAtBottom) { el.scrollTop = el.scrollHeight; } else if(prevScrollHeight > 0) { var ratio = prevScrollTop / prevScrollHeight; el.scrollTop = Math.round(ratio * el.scrollHeight); } document.getElementById('monitor-count').textContent = records.length; } return { render: function(container) { expandedSet = {}; container.innerHTML = '' + '' + '
' + '状态:' + '' + WsClient.getStatus() + '' + '已记录:' + '0' + '' + '' + '
' + '
'; document.getElementById('monitor-clear').onclick = function() { MonitorRing.clear(); expandedSet = {}; renderLog(); }; renderLog(); }, onEnter: function() { refreshTimer = setInterval(renderLog, 500); }, onLeave: function() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } expandedSet = {}; } }; })(); /* ======================================================================== * 命令终端页面 * ======================================================================== */ var TerminalPage = (function() { var history = []; var historyIdx = 0; var cmdList = []; function append(text, cls) { var el = document.getElementById('term-output'); if (!el) return; var div = document.createElement('div'); div.className = cls || ''; div.textContent = text; el.appendChild(div); el.scrollTop = el.scrollHeight; } function renderCmdList() { var el = document.getElementById('term-cmd-list'); if (!el) return; if (cmdList.length === 0) { el.innerHTML = '等待命令列表...'; return; } var html = ''; for (var i = 0; i < cmdList.length; i++) { var cmd = cmdList[i]; html += '' + cmd.name + ''; } el.innerHTML = html; var tags = el.querySelectorAll('.cmd-tag'); for (var k = 0; k < tags.length; k++) { tags[k].onclick = function() { var input = document.getElementById('term-input'); if (input) { input.value = this.getAttribute('data-cmd') + ' '; input.focus(); } }; } } return { render: function(container) { history = []; historyIdx = 0; container.innerHTML = '' + '' + '
' + '
' + '
' + '
' + 'cmd > ' + '' + '
' + '
'; var input = document.getElementById('term-input'); if (!input) return; input.onkeydown = function(e) { if (e.key === 'Enter') { var cmd = input.value.trim(); if (cmd) { history.push(cmd); historyIdx = history.length; append('cmd > ' + cmd, 'term-sent'); if (!WsClient.send({type:'cmd', data:cmd})) { append('[未连接]', 'term-err'); } } input.value = ''; } else if (e.key === 'ArrowUp') { e.preventDefault(); if (history.length > 0) { historyIdx = Math.max(0, historyIdx - 1); input.value = history[historyIdx]; } } else if (e.key === 'ArrowDown') { e.preventDefault(); if (historyIdx < history.length - 1) { historyIdx++; input.value = history[historyIdx]; } else { historyIdx = history.length; input.value = ''; } } }; renderCmdList(); append('输入 help 查看所有命令', 'term-info'); }, onData: function(data) { if (data && data.cmds) { cmdList = data.cmds; renderCmdList(); return; } if (data && typeof data === 'string') { append(data, 'term-resp'); } else if (data && data.type === 'cmd_list') { cmdList = data.cmds; renderCmdList(); } }, onBinary: function(ab) { var decoder = new TextDecoder('utf-8'); var text = decoder.decode(ab); if (text) append(text, 'term-resp'); }, onEnter: function() { var input = document.getElementById('term-input'); if (input) setTimeout(function() { input.focus(); }, 100); renderCmdList(); if (cmdList.length === 0) { WsClient.send({type:'get_cmds'}); } }, onLeave: function() { } }; })(); /* ======================================================================== * 数据中心页面 * ======================================================================== */ var DatacenterPage = (function() { var allData = {}; var activeTab = 'out'; var searchText = ''; var selectedSet = {}; // {saddr: true} var COLUMNS = ['', 'id', 'saddr', 'desc', '数据类型', 'ctrl_type', 'link_saddrs']; function getVisibleData() { var data = allData[activeTab] || []; if (!searchText) return data; var st = searchText.toLowerCase(); return data.filter(function(item) { return (item.saddr && item.saddr.toLowerCase().indexOf(st) !== -1) || (item.desc && item.desc.toLowerCase().indexOf(st) !== -1); }); } function onSelectAll() { var visible = getVisibleData(); var cb = document.getElementById('dc-cb-all'); if (cb && cb.checked && visible.length > 0) { for (var i = 0; i < visible.length; i++) { selectedSet[visible[i].saddr] = true; } } else { for (var i = 0; i < visible.length; i++) { delete selectedSet[visible[i].saddr]; } } renderTable(); updateSelectionBar(); } function onToggleRow(saddr) { if (selectedSet[saddr]) { delete selectedSet[saddr]; } else { selectedSet[saddr] = true; } renderTable(); updateSelectionBar(); } function getSelectedCount() { var count = 0; for (var k in selectedSet) { if (selectedSet.hasOwnProperty(k)) count++; } return count; } function getSelectedItems() { var result = []; var fullData = allData[activeTab] || []; for (var i = 0; i < fullData.length; i++) { if (selectedSet[fullData[i].saddr]) { result.push(fullData[i]); } } return result; } function updateSelectionBar() { var bar = document.getElementById('dc-sel-bar'); if (!bar) return; var count = getSelectedCount(); if (count === 0) { bar.style.display = 'none'; } else { bar.style.display = 'flex'; bar.querySelector('.dc-sel-label').textContent = '已选 ' + count + ' 个信号'; } } function doAddSelected() { var items = getSelectedItems(); if (items.length === 0) { alert('请先勾选要添加的信号'); return; } var typeLabels = { out:'注册', in:'链接注册', yk:'遥控', ao:'参数', param:'定值' }; var label = typeLabels[activeTab] || activeTab; var cur = getRegisteredCount(activeTab); if (cur + items.length > MAX_SIGNALS_PER_TYPE) { alert(label + ' 已有 ' + cur + ' 个,本次选择 ' + items.length + ' 个,合计 ' + (cur + items.length) + ' 个,超过上限 ' + MAX_SIGNALS_PER_TYPE + ' 个。\n请先删除旧信号或减少选择'); return; } if (!confirm('确定将 ' + items.length + ' 个信号添加到 ' + label + '?')) return; var succ = 0; for (var i = 0; i < items.length; i++) { var ok = WsClient.send({ saddr: items[i].saddr, signal_type: activeTab, curd: 'add', setting_zone: '0', signal_data: '' }); if (ok) succ++; } alert('已发送 ' + succ + '/' + items.length + ' 个 add 请求到 ' + label); } function renderHeaders() { var thead = document.getElementById('dc-thead'); if (!thead) return; var visible = getVisibleData(); var allChecked = visible.length > 0; for (var i = 0; i < visible.length && allChecked; i++) { if (!selectedSet[visible[i].saddr]) allChecked = false; } var html = ''; for (var j = 1; j < COLUMNS.length; j++) { html += '' + COLUMNS[j] + ''; } thead.innerHTML = html; var cbAll = document.getElementById('dc-cb-all'); if (cbAll) cbAll.onchange = onSelectAll; } function renderTable() { var data = getVisibleData(); var tbody = document.getElementById('dc-tbody'); if (!tbody) return; var fullData = allData[activeTab] || []; document.getElementById('dc-count').textContent = data.length + ' / ' + fullData.length; if (data.length === 0) { tbody.innerHTML = '暂无数据'; return; } var html = ''; for (var i = 0; i < data.length; i++) { var item = data[i]; var checked = !!selectedSet[item.saddr]; html += '' + '' + '' + fmtVal(item.id) + '' + '' + fmtVal(item.saddr) + '' + '' + fmtVal(item.desc) + '' + '' + fmtVal(item.type) + '' + '' + fmtVal(CTRL_TYPE_MAP[item.ctrl_type] || item.ctrl_type) + '' + '' + fmtVal(item.link_saddrs) + '' + ''; } tbody.innerHTML = html; // 绑定行复选框事件 var cbs = tbody.querySelectorAll('.dc-cb-row'); for (var k = 0; k < cbs.length; k++) { cbs[k].onclick = function(e) { e.stopPropagation(); onToggleRow(this.getAttribute('data-saddr')); }; } } function renderTabs() { var types = ['out', 'in', 'yk', 'ao', 'param']; var labels = { out: '注册 (out)', in: '链接注册 (in)', yk: '遥控 (yk)', ao: '参数 (ao)', param: '定值 (param)' }; var el = document.getElementById('dc-tab-bar'); if (!el) return; var html = ''; for (var i = 0; i < types.length; i++) { var t = types[i]; var cls = t === activeTab ? 'dc-tab active' : 'dc-tab'; html += '' + labels[t] + ''; } el.innerHTML = html; var tabs = el.querySelectorAll('.dc-tab'); for (var k = 0; k < tabs.length; k++) { tabs[k].onclick = function() { activeTab = this.getAttribute('data-tab'); searchText = ''; selectedSet = {}; document.getElementById('dc-search').value = ''; renderTabs(); renderHeaders(); renderTable(); updateSelectionBar(); }; } } return { render: function(container) { allData = {}; activeTab = 'out'; searchText = ''; selectedSet = {}; container.innerHTML = '' + '' + '
' + '
' + '' + '0' + '
' + '' + '
' + '
' + '
'; renderTabs(); renderHeaders(); renderTable(); var searchInput = document.getElementById('dc-search'); if (searchInput) { searchInput.oninput = function() { searchText = this.value; renderHeaders(); renderTable(); }; } var addBtn = document.getElementById('dc-btn-add'); if (addBtn) addBtn.onclick = doAddSelected; }, onEnter: function() {}, onLeave: function() { allData = {}; selectedSet = {}; }, onData: function(data) { if (!data || typeof data !== 'object') return; if (data.out) allData.out = data.out; if (data.in) allData.in = data.in; if (data.yk) allData.yk = data.yk; if (data.ao) allData.ao = data.ao; if (data.param) allData.param = data.param; if (data.out || data.in || data.yk || data.ao || data.param) { renderHeaders(); renderTable(); updateSelectionBar(); } } }; })();