1384 lines
58 KiB
JavaScript
1384 lines
58 KiB
JavaScript
/**
|
||
* 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 = '<div class="table-card"><div class="table-wrapper"><table class="signal-table">';
|
||
html += '<thead><tr>';
|
||
for (var i = 0; i < headers.length; i++) {
|
||
html += '<th>' + headers[i] + '</th>';
|
||
}
|
||
html += '</tr></thead><tbody id="' + containerId + '">';
|
||
if (rowsHtml) {
|
||
html += rowsHtml;
|
||
} else {
|
||
html += '<tr class="empty-row"><td colspan="' + headers.length + '">' + (emptyText || '暂无数据,请先添加信号') + '</td></tr>';
|
||
}
|
||
html += '</tbody></table></div></div>';
|
||
return html;
|
||
}
|
||
|
||
function fmtVal(v) {
|
||
if (v === null || v === undefined || v === '') return '-';
|
||
return String(v).replace(/&/g, '&').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, '"').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 = '<?xml version="1.0" encoding="UTF-8"?>\n<root>\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 += ' <signals type="' + st + '"/>\n';
|
||
continue;
|
||
}
|
||
hasData = true;
|
||
xml += ' <signals type="' + st + '">\n';
|
||
for (var i = 0; i < data.length; i++) {
|
||
xml += ' <signal no="' + i + '" saddr="' + escapeXml(data[i].saddr) + '"/>\n';
|
||
}
|
||
xml += ' </signals>\n';
|
||
}
|
||
|
||
xml += '</root>\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 文件中没有 <signals> 元素');
|
||
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 ''
|
||
+ '<div class="toolbar">'
|
||
+ '<div class="field">'
|
||
+ '<label>短地址 saddr</label>'
|
||
+ '<input type="text" id="tb-saddr" placeholder="例: iec.run_cnt">'
|
||
+ '</div>'
|
||
+ '<div class="field">'
|
||
+ '<label>操作</label>'
|
||
+ '<select id="tb-curd">'
|
||
+ '<option value="add">add 添加</option>'
|
||
+ '<option value="del">del 删除</option>'
|
||
+ '</select>'
|
||
+ '</div>'
|
||
+ '<button class="btn btn-primary" id="tb-exec">执行</button>'
|
||
+ '<button class="btn btn-outline" id="tb-clear">清空输入</button>'
|
||
+ '</div>';
|
||
}
|
||
|
||
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 = ''
|
||
+ '<div class="login-card">'
|
||
+ '<h1>RTU 远程终端监控系统</h1>'
|
||
+ '<p class="login-subtitle">Remote Terminal Unit Web Monitor</p>'
|
||
+ '<div class="form-group">'
|
||
+ '<label>用户名</label>'
|
||
+ '<input type="text" id="login-user" placeholder="暂无需认证" disabled>'
|
||
+ '</div>'
|
||
+ '<div class="form-group">'
|
||
+ '<label>密码</label>'
|
||
+ '<input type="password" id="login-pass" placeholder="暂无需认证" disabled>'
|
||
+ '</div>'
|
||
+ '<button class="login-btn" id="login-btn">登 录</button>'
|
||
+ '<p class="login-hint">当前版本无需认证,点击登录即可进入系统</p>'
|
||
+ '</div>';
|
||
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 = '<div class="page-header"><h2>功能导航</h2><p>选择需要管理的信号类型进入对应功能页</p></div>';
|
||
html += '<div class="dashboard-grid">';
|
||
for (var i = 0; i < cards.length; i++) {
|
||
var c = cards[i];
|
||
html += '<a class="dash-card ' + c.cls + '" href="' + c.hash + '">'
|
||
+ '<div class="card-icon">' + c.icon + '</div>'
|
||
+ '<div class="card-title">' + c.title + '</div>'
|
||
+ '<div class="card-desc">' + c.desc + '</div>'
|
||
+ '</a>';
|
||
}
|
||
html += '</div>';
|
||
html += '<div class="batch-toolbar">'
|
||
+ '<h3>📦 批量操作</h3>'
|
||
+ '<p>导入/导出所有信号类型 (out/in/yk/ao/param) 到一个 XML 文件</p>'
|
||
+ '<button class="btn btn-info" id="btn-export-all" style="margin-right:12px">📤 批量导出所有信号</button>'
|
||
+ '<button class="btn btn-warn" id="btn-import-all">📥 批量导入所有信号</button>'
|
||
+ '</div>';
|
||
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 += '<tr>'
|
||
+ '<td><input type="checkbox" class="row-cb" data-idx="' + i + '"' + (checked ? ' checked' : '') + '></td>'
|
||
+ '<td>' + (i + 1) + '</td>'
|
||
+ '<td>' + fmtVal(item.saddr) + '</td>'
|
||
+ '<td>' + fmtVal(item.desc) + '</td>'
|
||
+ '<td>' + fmtVal(item.type) + '</td>'
|
||
+ '<td class="val-cell"><span id="out-val-' + i + '">' + fmtVal(item.val) + '</span></td>'
|
||
+ '<td><input class="val-input" id="mod-' + i + '" placeholder="修改值"></td>'
|
||
+ '<td><button class="btn btn-sm btn-info" data-idx="' + i + '">修改</button></td>'
|
||
+ '</tr>';
|
||
}
|
||
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 = ''
|
||
+ '<div class="page-header"><h2>注册管理</h2><p>out — 状态量信号,注册后支持添加、删除、修改值</p></div>'
|
||
+ makeToolbar('out')
|
||
+ '<div class="batch-ops" style="margin-bottom:12px"><button class="btn btn-danger" id="out-del-btn" disabled>🗑 删除选中</button></div>'
|
||
+ 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 = '<tr class="empty-row"><td colspan="8">暂无注册数据,请先添加信号</td></tr>'; 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 += '<tr>'
|
||
+ '<td><input type="checkbox" class="row-cb" data-idx="' + i + '"' + (checked ? ' checked' : '') + '></td>'
|
||
+ '<td>' + (i + 1) + '</td>'
|
||
+ '<td>' + fmtVal(item.saddr) + '</td>'
|
||
+ '<td>' + fmtVal(item.desc) + '</td>'
|
||
+ '<td>' + fmtVal(item.type) + '</td>'
|
||
+ '<td class="val-cell"><span id="in-val-' + i + '">' + fmtVal(item.val) + '</span></td>'
|
||
+ '</tr>';
|
||
}
|
||
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 = ''
|
||
+ '<div class="page-header"><h2>链接注册管理</h2><p>in — 测量量信号,链接注册后仅支持添加和删除,不支持修改</p></div>'
|
||
+ makeToolbar('in')
|
||
+ '<div class="batch-ops" style="margin-bottom:12px"><button class="btn btn-danger" id="in-del-btn" disabled>🗑 删除选中</button></div>'
|
||
+ 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 = '<tr class="empty-row"><td colspan="6">暂无链接注册数据,请先添加信号</td></tr>'; 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 += '<tr>'
|
||
+ '<td><input type="checkbox" class="row-cb" data-idx="' + i + '"' + (checked ? ' checked' : '') + '></td>'
|
||
+ '<td>' + (i + 1) + '</td>'
|
||
+ '<td>' + fmtVal(item.saddr) + '</td>'
|
||
+ '<td>' + fmtVal(item.desc) + '</td>'
|
||
+ '<td>' + fmtVal(item.type) + '</td>'
|
||
+ '<td class="val-cell">' + fmtVal(item.val) + '</td>'
|
||
+ '<td>' + ctrlLabel + '</td>'
|
||
+ '<td>' + fmtVal(item.min) + '</td>'
|
||
+ '<td>' + fmtVal(item.max) + '</td>'
|
||
+ '<td>' + fmtVal(item.step) + '</td>'
|
||
+ '<td>' + fmtVal(item.unit) + '</td>'
|
||
+ '<td><input class="val-input" id="yk-mod-' + i + '" placeholder="修改值"></td>'
|
||
+ '<td><button class="btn btn-sm btn-info" data-idx="' + i + '">修改</button></td>'
|
||
+ '</tr>';
|
||
}
|
||
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 = ''
|
||
+ '<div class="page-header"><h2>遥控管理</h2><p>yk — 遥控分合闸控制,控合=1(闭合),控分=0(断开)</p></div>'
|
||
+ makeToolbar('yk')
|
||
+ '<div class="batch-ops" style="margin-bottom:12px"><button class="btn btn-danger" id="yk-del-btn" disabled>🗑 删除选中</button></div>'
|
||
+ 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 = '<tr class="empty-row"><td colspan="9">暂无遥控数据,请先添加信号</td></tr>'; 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 += '<tr>'
|
||
+ '<td><input type="checkbox" class="row-cb" data-idx="' + i + '"' + (checked ? ' checked' : '') + '></td>'
|
||
+ '<td>' + (i + 1) + '</td>'
|
||
+ '<td>' + fmtVal(item.saddr) + '</td>'
|
||
+ '<td>' + fmtVal(item.desc) + '</td>'
|
||
+ '<td>' + fmtVal(item.type) + '</td>'
|
||
+ '<td class="val-cell">' + fmtVal(item.val) + '</td>'
|
||
+ '<td>' + ctrlLabel + '</td>'
|
||
+ '<td>' + fmtVal(item.min) + '</td>'
|
||
+ '<td>' + fmtVal(item.max) + '</td>'
|
||
+ '<td>' + fmtVal(item.step) + '</td>'
|
||
+ '<td>' + fmtVal(item.unit) + '</td>'
|
||
+ '<td><input class="val-input" id="ao-mod-' + i + '" placeholder="修改值"></td>'
|
||
+ '<td><button class="btn btn-sm btn-info" data-idx="' + i + '">修改</button></td>'
|
||
+ '</tr>';
|
||
}
|
||
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<items.length;i++){WsClient.send({curd:'del',signal_type:'ao',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 = ''
|
||
+ '<div class="page-header"><h2>参数管理</h2><p>ao — 模拟输出参数设定,支持单值修改</p></div>'
|
||
+ makeToolbar('ao')
|
||
+ '<div class="batch-ops" style="margin-bottom:12px"><button class="btn btn-danger" id="ao-del-btn" disabled>🗑 删除选中</button></div>'
|
||
+ 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 = '<tr class="empty-row"><td colspan="13">暂无参数数据,请先添加信号</td></tr>'; 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 += '<tr class="expand-row" data-pidx="' + i + '">'
|
||
+ '<td><input type="checkbox" class="row-cb" data-idx="' + i + '"' + (checked ? ' checked' : '') + '></td>'
|
||
+ '<td>' + (i + 1) + '</td>'
|
||
+ '<td>' + fmtVal(item.saddr) + '</td>'
|
||
+ '<td>' + fmtVal(item.desc) + '</td>'
|
||
+ '<td>' + fmtVal(item.type) + '</td>'
|
||
+ '<td>' + ctrlLabel + '</td>'
|
||
+ '<td>' + zoneCount + '</td>'
|
||
+ '<td><span class="expand-icon' + (isOpen ? ' open' : '') + '">' + icon + '</span> 展开</td>'
|
||
+ '</tr>';
|
||
|
||
// 展开的定值区子表
|
||
if (isOpen) {
|
||
rows += '<tr class="zone-sub-row" id="zone-sub-' + i + '"><td colspan="8">'
|
||
+ '<table class="zone-sub-table">'
|
||
+ '<thead><tr>'
|
||
+ '<th>定值区 ID</th><th>val</th><th>default_val</th>'
|
||
+ '<th>min</th><th>max</th><th>step</th><th>unit</th>'
|
||
+ '<th>修改值</th><th>修改</th>'
|
||
+ '</tr></thead><tbody>';
|
||
|
||
for (var j = 0; j < zones.length; j++) {
|
||
var zone = zones[j];
|
||
rows += '<tr>'
|
||
+ '<td>' + fmtVal(zone.id) + '</td>'
|
||
+ '<td>' + fmtVal(zone.val) + '</td>'
|
||
+ '<td>' + fmtVal(zone.default_val) + '</td>'
|
||
+ '<td>' + fmtVal(item.min) + '</td>'
|
||
+ '<td>' + fmtVal(item.max) + '</td>'
|
||
+ '<td>' + fmtVal(item.step) + '</td>'
|
||
+ '<td>' + fmtVal(item.unit) + '</td>'
|
||
+ '<td><input class="val-input" id="pm-' + i + '-' + j + '" placeholder="修改值"></td>'
|
||
+ '<td><button class="btn btn-sm btn-info" data-pidx="' + i + '" data-zidx="' + j + '">修改</button></td>'
|
||
+ '</tr>';
|
||
}
|
||
|
||
rows += '</tbody></table></td></tr>';
|
||
}
|
||
}
|
||
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<items.length;i++){WsClient.send({curd:'del',signal_type:'param',saddr:items[i].saddr,setting_zone:'0',signal_data:''});} selected={}; expandedRows={}; updateDelBtn(); refreshTable(); }
|
||
function refreshTable() {
|
||
var tbody = document.getElementById('param-tbody');
|
||
if (!tbody) return;
|
||
tbody.innerHTML = renderTableHtml();
|
||
bindEvents();
|
||
}
|
||
return {
|
||
render: function(container) {
|
||
expandedRows = {};
|
||
selected = {};
|
||
var headers = ['', '#', 'saddr', 'desc', '数据类型', 'ctrl_type', '定值区数', '展开'];
|
||
container.innerHTML = ''
|
||
+ '<div class="page-header"><h2>定值管理</h2><p>param — 多定值区参数管理,点击行展开查看各定值区详情并可修改</p></div>'
|
||
+ makeToolbar('param')
|
||
+ '<div class="batch-ops" style="margin-bottom:12px"><button class="btn btn-danger" id="param-del-btn" disabled>🗑 删除选中</button></div>'
|
||
+ 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 = '<tr class="empty-row"><td colspan="8">暂无定值数据,请先添加信号</td></tr>'; 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, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function renderLog() {
|
||
var el = document.getElementById('monitor-log');
|
||
if (!el) return;
|
||
|
||
var records = MonitorRing.getAll();
|
||
if (records.length === 0) {
|
||
el.innerHTML = '<div class="monitor-empty">暂无数据交互,请先操作信号页面...</div>';
|
||
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 += '<div class="mon-item' + (isOpen ? ' open' : '') + '" data-midx="' + i + '">';
|
||
html += '<div class="mon-item-head">';
|
||
html += '<span class="mon-time">[' + formatTime(r.time) + ']</span>';
|
||
html += '<span class="mon-dir ' + dirClass + '">' + dirLabel + '</span>';
|
||
html += '<span class="mon-addr">' + source + ' ' + arrow + ' ' + target + '</span>';
|
||
html += '<span class="mon-summary">' + escapeHtml(summary) + '</span>';
|
||
html += '<span class="mon-expand-icon">' + (isOpen ? '▼' : '▶') + '</span>';
|
||
html += '</div>';
|
||
if (isOpen) {
|
||
html += '<div class="mon-item-body"><pre>' + escapeHtml(r.data) + '</pre></div>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
|
||
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 = ''
|
||
+ '<div class="page-header"><h2>数据监控</h2><p>实时查看 WebSocket 上下行 JSON 数据,15 分钟自动清理历史,点击条目展开详情</p></div>'
|
||
+ '<div class="monitor-stats">'
|
||
+ '<span class="stat-label">状态:</span>'
|
||
+ '<span class="stat-value" id="monitor-ws-status">' + WsClient.getStatus() + '</span>'
|
||
+ '<span class="stat-label" style="margin-left:16px">已记录:</span>'
|
||
+ '<span class="stat-value" id="monitor-count">0</span>'
|
||
+ '<span class="stat-label">条</span>'
|
||
+ '<button class="btn btn-sm btn-outline" id="monitor-clear" style="margin-left:auto">清空记录</button>'
|
||
+ '</div>'
|
||
+ '<div class="monitor-log" id="monitor-log"></div>';
|
||
|
||
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 = '<span class="cmd-list-hint">等待命令列表...</span>';
|
||
return;
|
||
}
|
||
var html = '';
|
||
for (var i = 0; i < cmdList.length; i++) {
|
||
var cmd = cmdList[i];
|
||
html += '<span class="cmd-tag" data-cmd="' + cmd.name + '" title="' + cmd.desc + '">' + cmd.name + '</span>';
|
||
}
|
||
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 = ''
|
||
+ '<div class="page-header"><h2>命令终端</h2><p>输入 RTU 命令,Enter 发送,↑↓ 历史记录</p></div>'
|
||
+ '<div class="terminal-wrap">'
|
||
+ '<div class="terminal-cmd-list" id="term-cmd-list"></div>'
|
||
+ '<div class="terminal-output" id="term-output"></div>'
|
||
+ '<div class="terminal-input-line">'
|
||
+ '<span class="terminal-prompt">cmd > </span>'
|
||
+ '<input type="text" class="terminal-input" id="term-input" autofocus spellcheck="false">'
|
||
+ '</div>'
|
||
+ '</div>';
|
||
|
||
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 = '<th style="width:36px"><input type="checkbox" id="dc-cb-all"' + (allChecked ? ' checked' : '') + '></th>';
|
||
for (var j = 1; j < COLUMNS.length; j++) {
|
||
html += '<th>' + COLUMNS[j] + '</th>';
|
||
}
|
||
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 = '<tr class="empty-row"><td colspan="' + COLUMNS.length + '">暂无数据</td></tr>';
|
||
return;
|
||
}
|
||
|
||
var html = '';
|
||
for (var i = 0; i < data.length; i++) {
|
||
var item = data[i];
|
||
var checked = !!selectedSet[item.saddr];
|
||
html += '<tr>'
|
||
+ '<td><input type="checkbox" class="dc-cb-row"' + (checked ? ' checked' : '') + ' data-saddr="' + fmtVal(item.saddr) + '"></td>'
|
||
+ '<td>' + fmtVal(item.id) + '</td>'
|
||
+ '<td>' + fmtVal(item.saddr) + '</td>'
|
||
+ '<td>' + fmtVal(item.desc) + '</td>'
|
||
+ '<td>' + fmtVal(item.type) + '</td>'
|
||
+ '<td>' + fmtVal(CTRL_TYPE_MAP[item.ctrl_type] || item.ctrl_type) + '</td>'
|
||
+ '<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + fmtVal(item.link_saddrs) + '">' + fmtVal(item.link_saddrs) + '</td>'
|
||
+ '</tr>';
|
||
}
|
||
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 += '<span class="' + cls + '" data-tab="' + t + '">' + labels[t] + '</span>';
|
||
}
|
||
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 = ''
|
||
+ '<div class="page-header"><h2>数据中心</h2><p>勾选信号后点击"添加到"即可添加至对应配置页 | 查看所有信号数据,按类型分表,支持搜索</p></div>'
|
||
+ '<div class="datacenter-toolbar">'
|
||
+ '<div class="dc-tab-bar" id="dc-tab-bar"></div>'
|
||
+ '<input type="text" class="dc-search" id="dc-search" placeholder="搜索 saddr 或 desc...">'
|
||
+ '<span class="dc-count" id="dc-count">0</span>'
|
||
+ '</div>'
|
||
+ '<div class="dc-sel-bar" id="dc-sel-bar" style="display:none">'
|
||
+ '<span class="dc-sel-label">已选 0 个信号</span>'
|
||
+ '<button class="btn btn-primary" id="dc-btn-add" style="margin-left:16px">添加到配置页</button>'
|
||
+ '</div>'
|
||
+ '<div class="table-card"><div class="table-wrapper">'
|
||
+ '<table class="signal-table"><thead><tr id="dc-thead"></tr></thead><tbody id="dc-tbody"></tbody></table>'
|
||
+ '</div></div>';
|
||
|
||
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();
|
||
}
|
||
}
|
||
};
|
||
})();
|
||
|