RTU/test/web_root/js/pages.js

1115 lines
45 KiB
JavaScript
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.

/**
* pages.js — 页面渲染器
*
* 每个页面对象实现:
* render(container) — 渲染 HTML 到容器
* onEnter() — 页面激活时调用
* onLeave() — 页面离开时调用
* onData(signalType, arr) — 接收下行数据更新表格
*
* 术语映射:
* out → 注册 | in → 链接注册 | yk → 遥控 | ao → 参数 | param → 定值
*/
var CTRL_TYPE_MAP = { 0: '只读', 1: '直控', 2: '选控' };
/* ========================================================================
* 共用工具
* ======================================================================== */
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ------------------------------------------------------------------------
* XML 工具函数(导入导出)
* ------------------------------------------------------------------------ */
function escapeXml(str) {
if (typeof str !== 'string') return String(str);
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
/**
* 导出所有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 (!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' }
];
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 = [];
function renderTableHtml() {
if (data.length === 0) return '';
var rows = '';
for (var i = 0; i < data.length; i++) {
var item = data[i];
rows += '<tr>'
+ '<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><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' });
};
}
}
return {
render: function(container) {
var headers = ['#', 'saddr', 'desc', '数据类型', 'val', '修改值', '修改'];
container.innerHTML = ''
+ '<div class="page-header"><h2>注册管理</h2><p>out — 状态量信号,注册后支持添加、删除、修改值</p></div>'
+ makeToolbar('out')
+ renderTable('out-tbody', headers, renderTableHtml(), '暂无注册数据,请先添加信号');
bindToolbar('out', function() { return data; });
bindModifyButtons();
},
onEnter: function() {},
onLeave: function() { data = []; },
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="7">暂无注册数据,请先添加信号</td></tr>'; return; }
tbody.innerHTML = renderTableHtml();
bindModifyButtons();
}
};
})();
/* ========================================================================
* 4. InPage — 链接注册管理 (in)
* ======================================================================== */
var InPage = (function() {
var data = [];
function renderTableHtml() {
if (data.length === 0) return '';
var rows = '';
for (var i = 0; i < data.length; i++) {
var item = data[i];
rows += '<tr>'
+ '<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>'
+ '</tr>';
}
return rows;
}
return {
render: function(container) {
var headers = ['#', 'saddr', 'desc', '数据类型', 'val'];
container.innerHTML = ''
+ '<div class="page-header"><h2>链接注册管理</h2><p>in — 测量量信号,链接注册后仅支持添加和删除,不支持修改</p></div>'
+ makeToolbar('in')
+ renderTable('in-tbody', headers, renderTableHtml(), '暂无链接注册数据,请先添加信号');
bindToolbar('in', function() { return data; });
},
onEnter: function() {},
onLeave: function() { data = []; },
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="5">暂无链接注册数据,请先添加信号</td></tr>';
return;
}
tbody.innerHTML = renderTableHtml();
}
};
})();
/* ========================================================================
* 5. YkPage — 遥控管理 (yk)
* ======================================================================== */
var YkPage = (function() {
var data = [];
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;
rows += '<tr>'
+ '<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><button class="btn btn-sm btn-success" data-idx="' + i + '" data-val="1">控合</button></td>'
+ '<td><button class="btn btn-sm btn-danger" data-idx="' + i + '" data-val="0">控分</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'
});
};
}
}
return {
render: function(container) {
var headers = ['#', 'saddr', 'desc', '数据类型', 'val', 'ctrl_type', '控合', '控分'];
container.innerHTML = ''
+ '<div class="page-header"><h2>遥控管理</h2><p>yk — 遥控分合闸控制,控合=1(闭合),控分=0(断开)</p></div>'
+ makeToolbar('yk')
+ renderTable('yk-tbody', headers, renderTableHtml(), '暂无遥控数据,请先添加信号');
bindToolbar('yk', function() { return data; });
bindButtons();
},
onEnter: function() {},
onLeave: function() { data = []; },
onData: function(arr) {
data = arr || [];
var tbody = document.getElementById('yk-tbody');
if (!tbody) return;
if (data.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="8">暂无遥控数据,请先添加信号</td></tr>';
return;
}
tbody.innerHTML = renderTableHtml();
bindButtons();
}
};
})();
/* ========================================================================
* 6. AoPage — 参数管理 (ao, 单值修改)
* ======================================================================== */
var AoPage = (function() {
var data = [];
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;
rows += '<tr>'
+ '<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 bindModifyButtons() {
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'
});
};
}
}
return {
render: function(container) {
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')
+ renderTable('ao-tbody', headers, renderTableHtml(), '暂无参数数据,请先添加信号');
bindToolbar('ao', function() { return data; });
bindModifyButtons();
},
onEnter: function() {},
onLeave: function() { data = []; },
onData: function(arr) {
data = arr || [];
var tbody = document.getElementById('ao-tbody');
if (!tbody) return;
if (data.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="12">暂无参数数据,请先添加信号</td></tr>';
return;
}
tbody.innerHTML = renderTableHtml();
bindModifyButtons();
}
};
})();
/* ========================================================================
* 7. ParamPage — 定值管理 (param, 多定值区展开)
* ======================================================================== */
var ParamPage = (function() {
var data = [];
var expandedRows = {}; // 记录展开状态
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 ? '▼' : '▶';
// 主行(可点击展开)
rows += '<tr class="expand-row" data-pidx="' + i + '">'
+ '<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="7">'
+ '<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() {
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
});
};
}
}
function refreshTable() {
var tbody = document.getElementById('param-tbody');
if (!tbody) return;
tbody.innerHTML = renderTableHtml();
bindEvents();
}
return {
render: function(container) {
expandedRows = {};
var headers = ['#', 'saddr', 'desc', '数据类型', 'ctrl_type', '定值区数', '展开'];
container.innerHTML = ''
+ '<div class="page-header"><h2>定值管理</h2><p>param — 多定值区参数管理,点击行展开查看各定值区详情并可修改</p></div>'
+ makeToolbar('param')
+ renderTable('param-tbody', headers, renderTableHtml(), '暂无定值数据,请先添加信号');
bindToolbar('param', function() { return data; });
bindEvents();
},
onEnter: function() {},
onLeave: function() { data = []; expandedRows = {}; },
onData: function(arr) {
data = arr || [];
expandedRows = {}; // 新数据到达时重置展开状态
var tbody = document.getElementById('param-tbody');
if (!tbody) return;
if (data.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="7">暂无定值数据,请先添加信号</td></tr>';
return;
}
tbody.innerHTML = renderTableHtml();
bindEvents();
}
};
})();
/* ========================================================================
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 &gt; </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 COLUMNS = ['id', 'saddr', 'desc', '数据类型', 'ctrl_type', 'link_saddrs'];
function renderTable() {
var data = allData[activeTab] || [];
var tbody = document.getElementById('dc-tbody');
if (!tbody) return;
if (data.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="6">暂无数据</td></tr>';
document.getElementById('dc-count').textContent = '0';
return;
}
var rows = data;
if (searchText) {
var st = searchText.toLowerCase();
rows = data.filter(function(item) {
return (item.saddr && item.saddr.toLowerCase().indexOf(st) !== -1)
|| (item.desc && item.desc.toLowerCase().indexOf(st) !== -1);
});
}
document.getElementById('dc-count').textContent = rows.length + ' / ' + data.length;
if (rows.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="6">无匹配结果</td></tr>';
return;
}
var html = '';
for (var i = 0; i < rows.length; i++) {
var item = rows[i];
html += '<tr>'
+ '<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;
}
function renderHeaders() {
var thead = document.getElementById('dc-thead');
if (!thead) return;
var html = '';
for (var i = 0; i < COLUMNS.length; i++) {
html += '<th>' + COLUMNS[i] + '</th>';
}
thead.innerHTML = html;
}
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 = '';
document.getElementById('dc-search').value = '';
renderTabs();
renderHeaders();
renderTable();
};
}
}
return {
render: function(container) {
allData = {};
activeTab = 'out';
searchText = '';
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="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;
renderTable();
};
}
},
onEnter: function() {
},
onLeave: function() {
allData = {};
},
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) {
renderTable();
}
}
};
})();