RTU/test/web_root/js/plc_config.js

1948 lines
71 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.

/* ========================================================================
* PLC 逻辑配置页面 — 信号管理 + 逻辑图查看 + 新建配置编辑器
* ======================================================================== */
var PlcConfigPage = (function() {
"use strict";
/* ---- Canvas roundRect polyfill (兼容旧浏览器) ---- */
if (!CanvasRenderingContext2D.prototype.roundRect) {
CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) {
if (typeof r === 'number') r = { tl: r, tr: r, br: r, bl: r };
else if (Array.isArray(r)) r = { tl: r[0]||0, tr: r[1]||r[0]||0, br: r[2]||r[0]||0, bl: r[3]||r[0]||0 };
this.beginPath();
this.moveTo(x + r.tl, y);
this.lineTo(x + w - r.tr, y);
this.quadraticCurveTo(x + w, y, x + w, y + r.tr);
this.lineTo(x + w, y + h - r.br);
this.quadraticCurveTo(x + w, y + h, x + w - r.br, y + h);
this.lineTo(x + r.bl, y + h);
this.quadraticCurveTo(x, y + h, x, y + h - r.bl);
this.lineTo(x, y + r.tl);
this.quadraticCurveTo(x, y, x + r.tl, y);
this.closePath();
};
}
/* ========== 全局状态 ========== */
var activeTab = 'signals'; // signals | viewer | editor
var dcOutSignals = []; // [{no, saddr, desc}] 从 datacenter_out.xml
var logicGraphs = []; // 已解析的逻辑图数组
var onlineCfgId = '';
var loadedFiles = {}; // {path: content} 记录已加载的文件
/* ======== 编辑器状态 ======== */
var editNodes = []; // [{type, id, x, y, saddr, desc, no, inPins, _lx, _ly}]
var editLinks = []; // [{srcIdx, dstIdx, dstPin}] — dstPin 目标节点输入引脚序号
var editKey = ''; // 逻辑图 key
var editName = ''; // 逻辑图名称
var editOnlineCfgId = ''; // 当前 online_cfgId
var editSelectedNode = -1; // 当前选中节点索引,-1=无选中
var editSelectedLink = -1; // 当前选中连线索引,-1=无选中
var editWiring = null; // {fromNodeIdx, fromSide, fromPin} 当前连线起点
var editDragNode = null; // 拖拽中的节点索引
var editDragOffX = 0, editDragOffY = 0;
var editDragCanvas = null; // 画布拖拽状态
var editPanX = 0, editPanY = 0; // 画布偏移
var editScale = 1.0; // 缩放
var editNextGateIdx = 0; // 逻辑门计数器
var editLoadedFromFile = false; // 是否从文件加载
var editLastClickTime = 0; // 上次 click 时间戳(双击检测)
var editLastClickNode = -1; // 上次 click 的节点索引
/* ========== 查看器状态 ========== */
var viewPanX = 0, viewPanY = 0;
var viewScale = 1.0;
var viewDrag = null; // {startX, startY, panX, panY}
var viewAutoFit = true; // 首次 / resize 后自动适配缩放
/* ========== 节点类型常量 ========== */
var NT_OUTPUT = 0, NT_INPUT = 1, NT_OR = 2, NT_AND = 3, NT_NOT = 4;
var NT_62P = 5, NT_62D = 6, NT_RISING = 7, NT_SR = 8, NT_RS = 9, NT_FALLING = 10;
var NODE_INFO = {
0: { name: '输出点', cls: 'node-out', icon: '📤', color: '#4CAF50', inPins: 1, outPins: 0, inMax: 1, outMax: 0 },
1: { name: '输入点', cls: 'node-in', icon: '📥', color: '#2196F3', inPins: 0, outPins: 1, inMax: 0, outMax: 99 },
2: { name: '或门 OR', cls: 'node-or', icon: '🔶', color: '#FF9800', inPins: 2, outPins: 1, inMax: 99, outMax: 10 },
3: { name: '与门 AND', cls: 'node-and', icon: '🔷', color: '#9C27B0', inPins: 2, outPins: 1, inMax: 99, outMax: 10 },
4: { name: '非门 NOT', cls: 'node-not', icon: '🔺', color: '#f44336', inPins: 1, outPins: 1, inMax: 1, outMax: 10 },
5: { name: '62p延时', cls: 'node-62p', icon: '⏱️', color: '#00BCD4', inPins: 1, outPins: 1, inMax: 1, outMax: 1 },
6: { name: '62d延时', cls: 'node-62d', icon: '⏱️', color: '#009688', inPins: 1, outPins: 1, inMax: 1, outMax: 1 },
7: { name: '上升沿', cls: 'node-re', icon: '⬆️', color: '#E91E63', inPins: 1, outPins: 1, inMax: 1, outMax: 1 },
8: { name: 'SR触发器', cls: 'node-sr', icon: '🔒', color: '#795548', inPins: 2, outPins: 1, inMax: 2, outMax: 1 },
9: { name: 'RS触发器', cls: 'node-rs', icon: '🔒', color: '#607D8B', inPins: 2, outPins: 1, inMax: 2, outMax: 1 },
10: { name: '下降沿', cls: 'node-fe', icon: '⬇️', color: '#FF5722', inPins: 1, outPins: 1, inMax: 1, outMax: 1 }
};
/* ========== 工具函数 ========== */
function fmtVal(v) { return (v === null || v === undefined) ? '' : String(v); }
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
/* ---- 通过节点 ID 查找信号信息 (datacenter out 中 no 即 ID) ---- */
function findSignalByNo(signalNo) {
for (var i = 0; i < dcOutSignals.length; i++) {
if (dcOutSignals[i].no === signalNo) return dcOutSignals[i];
}
return null;
}
/* ---- 生成节点显示标签 (十进制ID, 含saddr/desc) ---- */
function getNodeDisplayLabel(node) {
var info = NODE_INFO[node.type] || {};
if (node.type === NT_INPUT || node.type === NT_OUTPUT) {
var prefix = (node.type === NT_INPUT) ? '输入' : '输出';
var decId = node.id; // id 在 parse 时已转为十进制
var sig = findSignalByNo(decId);
if (sig) {
return prefix + ' #' + decId + ' ("' + sig.saddr + '", "' + sig.desc + '")';
}
return prefix + ' #' + decId;
}
// 逻辑门:显示组件序号
var gateIdx = node.id & 0xFF;
return info.icon + ' ' + info.name + ' #' + gateIdx;
}
/* ---- 生成编辑器小标签 (简洁版,用于编辑器节点) ---- */
function getEditorNodeLabel(node) {
var info = NODE_INFO[node.type] || {};
if (node.type === NT_INPUT || node.type === NT_OUTPUT) {
var prefix = (node.type === NT_INPUT) ? '输入点' : '输出点';
var decId = node.id;
var sig = node.saddr ? node : findSignalByNo(decId);
if (sig && sig.saddr) {
return info.icon + ' ' + prefix + ' #' + decId + '\n' + sig.saddr + '\n' + (sig.desc || '');
}
return info.icon + ' ' + prefix + ' #' + decId;
}
var gateIdx = node.id & 0xFF;
return info.icon + ' ' + info.name + ' #' + gateIdx;
}
function showToast(msg, type) {
type = type || 'info';
var t = document.getElementById('plc-cfg-toast');
if (!t) {
t = document.createElement('div');
t.id = 'plc-cfg-toast';
t.style.cssText = 'position:fixed;top:80px;left:50%;transform:translateX(-50%);padding:10px 24px;border-radius:6px;z-index:99999;font-size:14px;box-shadow:0 4px 12px rgba(0,0,0,0.3);transition:opacity 0.3s;pointer-events:none;color:#fff';
document.body.appendChild(t);
}
t.style.background = (type === 'err') ? '#f44336' : (type === 'ok') ? '#4CAF50' : '#333';
t.textContent = msg; t.style.opacity = '1';
clearTimeout(t._timer);
t._timer = setTimeout(function() { t.style.opacity = '0'; }, 2000);
}
/* ========== WebSocket 文件请求 ========== */
function requestFile(path, callback) {
if (loadedFiles[path]) {
callback(loadedFiles[path]);
return;
}
if (!PlcConfigPage._pending) PlcConfigPage._pending = {};
PlcConfigPage._pending[path] = callback;
var sent = WsClient.send({ type: 'get_file', path: path });
if (!sent) {
if (!PlcConfigPage._retryList) PlcConfigPage._retryList = [];
PlcConfigPage._retryList.push(path);
}
}
/* ========== 主渲染 ========== */
function renderTabs() {
var tabs = [
{ id: 'signals', label: '📋 信号列表', title: 'datacenter_out.xml — 已注册 Out 信号' },
{ id: 'viewer', label: '👁️ 逻辑图查看', title: 'Reclose_logic.txt 可视化' },
{ id: 'editor', label: '✏️ 新建配置', title: '新建/编辑 PLC 逻辑配置' }
];
var html = '<div class="plc-cfg-tabs">';
for (var i = 0; i < tabs.length; i++) {
var cls = (tabs[i].id === activeTab) ? 'plc-cfg-tab active' : 'plc-cfg-tab';
html += '<button class="' + cls + '" data-tab="' + tabs[i].id + '">' + tabs[i].label + '</button>';
}
html += '</div>';
return html;
}
function render(container) {
container.innerHTML = ''
+ '<div class="page-header"><h2>🔌 PLC 逻辑配置</h2><p>管理 PLC 逻辑图 — 信号源 / 逻辑图可视化 / 在线编辑</p></div>'
+ renderTabs()
+ '<div id="plc-cfg-content"></div>';
// Tab 切换事件
var tabEls = container.querySelectorAll('.plc-cfg-tab');
for (var i = 0; i < tabEls.length; i++) {
tabEls[i].onclick = function() {
activeTab = this.getAttribute('data-tab');
render(container);
};
}
// 渲染当前 Tab
var content = document.getElementById('plc-cfg-content');
if (!content) return;
switch (activeTab) {
case 'signals': renderSignalsTab(content); break;
case 'viewer': renderViewerTab(content); break;
case 'editor': renderEditorTab(content); break;
}
}
/* ================================================================
* Tab 1: 信号列表 — 解析 datacenter_out.xml
* ================================================================ */
function renderSignalsTab(container) {
container.innerHTML = ''
+ '<div class="plc-sig-header">'
+ '<input type="text" id="plc-sig-search" placeholder="搜索 saddr / desc...">'
+ '<button class="btn btn-primary" id="plc-sig-reload">🔄 重新加载</button>'
+ '<span style="margin-left:auto;color:#999;font-size:13px" id="plc-sig-count"></span>'
+ '</div>'
+ '<div class="plc-sig-table-wrap"><table class="plc-sig-table" id="plc-sig-table">'
+ '<thead><tr><th>序号 (no)</th><th>短地址 (saddr)</th><th>描述 (desc)</th></tr></thead>'
+ '<tbody id="plc-sig-tbody"></tbody>'
+ '</table></div>';
document.getElementById('plc-sig-search').oninput = function() { refreshSigTable(); };
document.getElementById('plc-sig-reload').onclick = function() {
loadedFiles['config/SYSTEM/datacenter_out.xml'] = null;
dcOutSignals = [];
loadDcOutSignals(function() { refreshSigTable(); });
};
if (dcOutSignals.length === 0) {
loadDcOutSignals(function() { refreshSigTable(); });
} else {
refreshSigTable();
}
}
function loadDcOutSignals(cb) {
requestFile('config/SYSTEM/datacenter_out.xml', function(content) {
if (!content) { showToast('加载 datacenter_out.xml 失败,请先在终端执行 datacenter out', 'err'); return; }
parseDcOutXml(content);
if (cb) cb();
});
}
function parseDcOutXml(xmlStr) {
dcOutSignals = [];
try {
var parser = new DOMParser();
var doc = parser.parseFromString(xmlStr, 'text/xml');
var outNodes = doc.documentElement.getElementsByTagName('Out');
if (outNodes.length > 0) {
var signals = outNodes[0].getElementsByTagName('Signal');
for (var i = 0; i < signals.length; i++) {
dcOutSignals.push({
no: parseInt(signals[i].getAttribute('no')) || i,
saddr: signals[i].getAttribute('saddr') || '',
desc: signals[i].getAttribute('desc') || ''
});
}
}
} catch(e) {
showToast('解析 datacenter_out.xml 失败: ' + e.message, 'err');
}
}
function refreshSigTable() {
var search = (document.getElementById('plc-sig-search').value || '').toLowerCase();
var data = dcOutSignals;
// 过滤
var filtered = data;
if (search) {
filtered = data.filter(function(s) {
return s.saddr.toLowerCase().indexOf(search) !== -1
|| s.desc.toLowerCase().indexOf(search) !== -1
|| String(s.no).indexOf(search) !== -1;
});
}
document.getElementById('plc-sig-count').textContent = '共 ' + filtered.length + ' / ' + data.length + ' 条';
var html = '';
for (var i = 0; i < filtered.length; i++) {
var s = filtered[i];
html += '<tr>'
+ '<td>' + s.no + '</td>'
+ '<td><code>' + escHtml(s.saddr) + '</code></td>'
+ '<td>' + escHtml(s.desc) + '</td>'
+ '</tr>';
}
document.getElementById('plc-sig-tbody').innerHTML = html;
}
/* ================================================================
* Tab 2: 逻辑图查看器 — Canvas 可视化 Reclose_logic.txt
* ================================================================ */
function renderViewerTab(container) {
container.innerHTML = ''
+ '<div class="plc-viewer-toolbar">'
+ '<select id="plc-view-graph" style="min-width:200px"><option value="">-- 选择逻辑图 --</option></select>'
+ '<span style="flex:1"></span>'
+ '<button class="btn btn-outline" id="plc-view-zoomin">🔍+</button>'
+ '<button class="btn btn-outline" id="plc-view-zoomout">🔍-</button>'
+ '<button class="btn btn-outline" id="plc-view-reset">↺ 重置</button>'
+ '<button class="btn btn-primary" id="plc-view-reload">🔄 刷新</button>'
+ '</div>'
+ '<div class="plc-canvas-wrap" id="plc-view-canvas-wrap">'
+ '<canvas id="plc-view-canvas"></canvas>'
+ '</div>'
+ '<div class="plc-view-legend" id="plc-view-legend"></div>';
// 图例
var legendHtml = '<span style="font-weight:700">图例:</span> ';
var types = [1,0,3,2,4,5,6,7,8,9,10];
for (var i = 0; i < types.length; i++) {
var info = NODE_INFO[types[i]];
legendHtml += '<span style="display:inline-block;margin:2px 8px;padding:2px 8px;border-radius:4px;background:' + info.color + ';color:#fff;font-size:12px">' + info.icon + ' ' + info.name + '</span>';
}
document.getElementById('plc-view-legend').innerHTML = legendHtml;
// 绑定事件
document.getElementById('plc-view-zoomin').onclick = function() { viewScale = Math.min(3, viewScale * 1.2); drawViewer(); };
document.getElementById('plc-view-zoomout').onclick = function() { viewScale = Math.max(0.2, viewScale / 1.2); drawViewer(); };
document.getElementById('plc-view-reset').onclick = function() { viewAutoFit = true; drawViewer(); };
document.getElementById('plc-view-reload').onclick = function() {
loadedFiles['config/PLC/Reclose_logic.txt'] = null;
loadedFiles['config/PLC/Reclose_logic.txt.bak'] = null;
logicGraphs = [];
loadLogicFileAll(function() { refreshGraphSelect(); });
};
document.getElementById('plc-view-graph').onchange = function() { viewAutoFit = true; drawViewer(); };
// 画布交互
setupViewerCanvas();
if (logicGraphs.length === 0) {
loadLogicFileAll(function() { refreshGraphSelect(); });
} else {
refreshGraphSelect();
}
}
function loadLogicFileAll(cb) {
var loadCount = 0;
var loadedFiles2 = []; // 记录成功加载的文件名
var failedFiles = []; // 记录失败的文件名
function tryCb() {
loadCount++;
if (loadCount >= 2 && cb) {
if (failedFiles.length > 0) {
var msg = loadedFiles2.length > 0
? '已加载: ' + loadedFiles2.join(', ') + ' | 缺失: ' + failedFiles.join(', ')
: '配置文件均缺失: ' + failedFiles.join(', ') + ',请先在编辑器中生成配置';
showToast(msg, loadedFiles2.length > 0 ? 'info' : 'err');
}
cb();
}
}
requestFile('config/PLC/Reclose_logic.txt', function(content) {
if (!content) { failedFiles.push('Reclose_logic.txt'); tryCb(); return; }
parseLogicFile(content, 'Reclose_logic.txt');
loadedFiles2.push('Reclose_logic.txt');
tryCb();
});
requestFile('config/PLC/Reclose_logic.txt.bak', function(content) {
if (!content) { tryCb(); return; }
parseLogicFile(content, 'Reclose_logic.txt.bak');
loadedFiles2.push('Reclose_logic.txt.bak');
tryCb();
});
}
function loadLogicFile(cb) {
requestFile('config/PLC/Reclose_logic.txt', function(content) {
if (!content) { showToast('加载 Reclose_logic.txt 失败', 'err'); return; }
parseLogicFile(content, 'Reclose_logic.txt');
if (cb) cb();
});
}
function parseLogicFile(content, sourceName) {
// 先移除同源文件的旧解析结果,保留其他源的数据
logicGraphs = logicGraphs.filter(function(g) {
return g._source !== sourceName;
});
onlineCfgId = onlineCfgId || '';
try {
var clean = content.replace(/\s/g, '');
var data = JSON.parse(clean);
// 只在首次(非 bak加载时更新 onlineCfgId
if (sourceName.indexOf('.bak') < 0) {
onlineCfgId = data.online_cfgId || '';
}
for (var key in data) {
if (key === 'online_cfgId') continue;
var g = parseGraphData(key, data[key]);
if (g) { g._source = sourceName; logicGraphs.push(g); }
}
} catch(e) {
showToast('解析 ' + sourceName + ' 失败: ' + e.message, 'err');
}
}
function parseGraphData(key, dataStr) {
var params = {};
var pairs = dataStr.split('&');
for (var i = 0; i < pairs.length; i++) {
var idx = pairs[i].indexOf('=');
if (idx === -1) continue;
params[pairs[i].substring(0, idx)] = decodeURIComponent(pairs[i].substring(idx + 1));
}
var graph = { key: key, name: params.name || '', nodes: [], links: [] };
// 解析节点
if (params.node) {
var m = params.node.match(/\[(.*)\]/);
if (m) {
var toks = m[1].match(/\{[^}]+\}/g) || [];
for (var j = 0; j < toks.length; j++) {
var inner = toks[j].replace(/[{}]/g, '');
var parts = inner.split(':');
if (parts.length >= 4) {
var nodeData = {
type: parseInt(parts[0]),
id: parseInt(parts[1], 16),
x: parseInt(parts[2]),
y: parseInt(parts[3])
};
if (parts.length >= 5) nodeData.inPins = parseInt(parts[4]);
graph.nodes.push(nodeData);
}
}
}
}
// 解析链路
if (params.link) {
var m2 = params.link.match(/\[(.*)\]/);
if (m2) {
var toks2 = m2[1].match(/\{[^}]+\}/g) || [];
for (var k = 0; k < toks2.length; k++) {
var inner2 = toks2[k].replace(/[{}]/g, '');
var linkParts = inner2.split('-');
if (linkParts.length === 2) {
var src = linkParts[0].split(':');
var dst = linkParts[1].split(':');
var linkData = {
src_type: parseInt(src[0]),
src_id: parseInt(src[1], 16),
dst_type: parseInt(dst[0]),
dst_id: parseInt(dst[1], 16)
};
if (dst.length >= 3) linkData.dstPin = parseInt(dst[2]);
graph.links.push(linkData);
}
}
}
}
return graph;
}
function refreshGraphSelect() {
var sel = document.getElementById('plc-view-graph');
if (!sel) return;
sel.innerHTML = '<option value="">-- 选择逻辑图 --</option>';
for (var i = 0; i < logicGraphs.length; i++) {
var g = logicGraphs[i];
var bakTag = (g._source && g._source.indexOf(".bak") >= 0) ? " [备份]" : "";
var label = g.key + " — " + (g.name || "(未命名)") + bakTag + " (" + g.nodes.length + "节点 " + g.links.length + "连线)";
sel.innerHTML += '<option value="' + i + '">' + escHtml(label) + '</option>';
}
if (logicGraphs.length > 0) {
sel.value = '0';
setTimeout(function() { drawViewer(); }, 100);
}
}
/* ---- 查看器 Canvas ---- */
function setupViewerCanvas() {
var wrap = document.getElementById('plc-view-canvas-wrap');
var canvas = document.getElementById('plc-view-canvas');
if (!wrap || !canvas) return;
function resize() {
canvas.width = wrap.clientWidth;
canvas.height = wrap.clientHeight - 4;
viewAutoFit = true;
drawViewer();
}
resize();
window.addEventListener('resize', function() { resize(); });
// 鼠标事件
canvas.onmousedown = function(e) {
viewDrag = { startX: e.clientX, startY: e.clientY, panX: viewPanX, panY: viewPanY };
};
canvas.onmousemove = function(e) {
if (!viewDrag) return;
viewPanX = viewDrag.panX + (e.clientX - viewDrag.startX);
viewPanY = viewDrag.panY + (e.clientY - viewDrag.startY);
drawViewer();
};
canvas.onmouseup = function() { viewDrag = null; };
canvas.onmouseleave = function() { viewDrag = null; };
canvas.onwheel = function(e) {
e.preventDefault();
var delta = e.deltaY > 0 ? 0.9 : 1.1;
var newScale = Math.max(0.2, Math.min(3, viewScale * delta));
// 以鼠标位置为中心缩放
var rect = canvas.getBoundingClientRect();
var mx = e.clientX - rect.left;
var my = e.clientY - rect.top;
viewPanX = mx - (mx - viewPanX) * (newScale / viewScale);
viewPanY = my - (my - viewPanY) * (newScale / viewScale);
viewScale = newScale;
drawViewer();
};
}
function drawViewer() {
var canvas = document.getElementById('plc-view-canvas');
if (!canvas) return;
var ctx = canvas.getContext('2d');
var W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, W, H);
var sel = document.getElementById('plc-view-graph');
if (!sel || sel.value === '') {
ctx.fillStyle = '#888';
ctx.font = '16px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('请选择一个逻辑图', W/2, H/2);
return;
}
var g = logicGraphs[parseInt(sel.value)];
if (!g) return;
// 使用文件中保存的 x,y 坐标(与编辑器布局一致)
var viewNodes = [];
var bMinX = Infinity, bMaxX = -Infinity, bMinY = Infinity, bMaxY = -Infinity;
for (var vi = 0; vi < g.nodes.length; vi++) {
var vn = g.nodes[vi];
var vx = vn.x, vy = vn.y;
if (vx < bMinX) bMinX = vx; if (vx > bMaxX) bMaxX = vx;
if (vy < bMinY) bMinY = vy; if (vy > bMaxY) bMaxY = vy;
var vsig = findSignalByNo(vn.id);
viewNodes.push({
type: vn.type, id: vn.id, _lx: vx, _ly: vy,
saddr: (vsig && vsig.saddr) || '', desc: (vsig && vsig.desc) || '', no: vn.id
});
}
// 首次绘制 / resize 后自动适配缩放
if (viewAutoFit) {
viewAutoFit = false;
var cw2 = bMaxX - bMinX, ch2 = bMaxY - bMinY;
if (cw2 <= 0) cw2 = 800; if (ch2 <= 0) ch2 = 600;
var pad = 80;
viewScale = Math.min((W - pad * 2) / cw2, (H - pad * 2) / ch2, 1.2);
viewPanX = W / 2 - (bMinX + cw2 / 2) * viewScale;
viewPanY = H / 2 - (bMinY + ch2 / 2) * viewScale;
}
// 链路 —— 修复:首个命中而非最后命中(避免同名门 ID 歧义)
var viewLinks = [];
for (var vlj = 0; vlj < g.links.length; vlj++) {
var vl = g.links[vlj];
var vls = -1, vld = -1;
for (var vlk = 0; vlk < g.nodes.length; vlk++) {
var vln = g.nodes[vlk];
if (vls < 0 && vln.type === vl.src_type && (vln.type <= 1 ? vln.id === vl.src_id : (vln.id & 0xFF) === (vl.src_id & 0xFF))) vls = vlk;
if (vld < 0 && vln.type === vl.dst_type && (vln.type <= 1 ? vln.id === vl.dst_id : (vln.id & 0xFF) === (vl.dst_id & 0xFF))) vld = vlk;
}
if (vls >= 0 && vld >= 0) viewLinks.push({ srcIdx: vls, dstIdx: vld, dstPin: vl.dstPin || 0 });
}
// 网格
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
var gridSize = 50;
var startGx = Math.floor(-viewPanX / viewScale / gridSize) * gridSize;
var startGy = Math.floor(-viewPanY / viewScale / gridSize) * gridSize;
ctx.save();
ctx.translate(viewPanX, viewPanY);
ctx.scale(viewScale, viewScale);
for (var gx = startGx - gridSize; gx < startGx + W/viewScale + gridSize; gx += gridSize) {
ctx.beginPath(); ctx.moveTo(gx, startGy - gridSize); ctx.lineTo(gx, startGy + H/viewScale + gridSize); ctx.stroke();
}
for (var gy = startGy - gridSize; gy < startGy + H/viewScale + gridSize; gy += gridSize) {
ctx.beginPath(); ctx.moveTo(startGx - gridSize, gy); ctx.lineTo(startGx + W/viewScale + gridSize, gy); ctx.stroke();
}
drawGraphContent(ctx, viewNodes, viewLinks, -1, -1, null);
ctx.restore();
}
function calcLayout(g) {
// 自动布局:每条链路 (输入→运算→输出) 构成一条横向数据流链
// 节点类型常量复用的全局 NODE_INFO
var layout = {};
// 1. 构建邻接关系,按连线确定连通分量(每条链)
var adjOut = {}; for (var i = 0; i < g.nodes.length; i++) adjOut[i] = [];
var adjIn = {}; for (var i = 0; i < g.nodes.length; i++) adjIn[i] = [];
for (var j = 0; j < g.links.length; j++) {
var l = g.links[j];
var si = -1, di = -1;
for (var k = 0; k < g.nodes.length; k++) {
var n = g.nodes[k];
if (si < 0 && n.type === l.src_type && (n.type <= 1 ? n.id === l.src_id : (n.id & 0xFF) === (l.src_id & 0xFF))) si = k;
if (di < 0 && n.type === l.dst_type && (n.type <= 1 ? n.id === l.dst_id : (n.id & 0xFF) === (l.dst_id & 0xFF))) di = k;
}
if (si >= 0 && di >= 0) { adjOut[si].push(di); adjIn[di].push(si); }
}
// BFS 找连通分量
var visited = {};
var chains = [];
for (var idx = 0; idx < g.nodes.length; idx++) {
if (visited[idx]) continue;
var chain = [];
var queue = [idx];
visited[idx] = true;
while (queue.length > 0) {
var cur = queue.shift();
chain.push(cur);
var nbrs = adjOut[cur].concat(adjIn[cur]);
for (var nb = 0; nb < nbrs.length; nb++) {
if (!visited[nbrs[nb]]) { visited[nbrs[nb]] = true; queue.push(nbrs[nb]); }
}
}
chains.push(chain);
}
// 2. 计算每条链的实际竖向高度(含节点+间距)
var NODE_IO_H = 80; // 输入/输出节点高度
var NODE_GATE_H = 66; // 逻辑门节点高度
var ITEM_GAP = 28; // 同列节点纵向间距
function calcChainPixels(chain) {
var cin = 0, cg = 0, cout = 0;
for (var i = 0; i < chain.length; i++) {
var nd = g.nodes[chain[i]];
if (nd.type === NT_INPUT) cin++;
else if (nd.type === NT_OUTPUT) cout++;
else cg++;
}
// 每列独立高度
var hIn = cin > 0 ? cin * NODE_IO_H + (cin - 1) * ITEM_GAP : 0;
var hGate = cg > 0 ? cg * NODE_GATE_H + (cg - 1) * ITEM_GAP : 0;
var hOut = cout > 0 ? cout * NODE_IO_H + (cout - 1) * ITEM_GAP : 0;
return Math.max(hIn, hGate, hOut, 60); // 至少 60px 避免空链过扁
}
var CHAIN_GAP = 80; // 链间纵向间距
// 累计 Y 偏移
var chainHeights = [];
var totalH = 0;
for (var ci = 0; ci < chains.length; ci++) {
var h = calcChainPixels(chains[ci]);
chainHeights.push(h);
totalH += h;
}
totalH += (chains.length - 1) * CHAIN_GAP;
// 使链条在 Y 轴居中startY = -(totalH / 2) + (firstHalfHeight/2)
var startY = -totalH / 2;
var curY = startY;
// 3. 列布局 (输入 | 运算 | 输出)
var COL_INPUT = -420;
var COL_GATE = 0;
var COL_OUTPUT = 420;
for (var ci2 = 0; ci2 < chains.length; ci2++) {
var chain = chains[ci2];
var ch = chainHeights[ci2];
var cy = curY + ch / 2; // 链中心 Y
// 分类节点
var cInputs = [], cGates = [], cOutputs = [];
for (var n = 0; n < chain.length; n++) {
var nd = g.nodes[chain[n]];
if (nd.type === NT_INPUT) cInputs.push(chain[n]);
else if (nd.type === NT_OUTPUT) cOutputs.push(chain[n]);
else cGates.push(chain[n]);
}
// 计算每列起始 Y使该列节点在链高度内居中
function colTop(count, itemH) {
if (count === 0) return cy;
var colH = count * itemH + (count - 1) * ITEM_GAP;
return cy - colH / 2 + itemH / 2;
}
var topIn = colTop(cInputs.length, NODE_IO_H);
var topGate = colTop(cGates.length, NODE_GATE_H);
var topOut = colTop(cOutputs.length, NODE_IO_H);
for (var a = 0; a < cInputs.length; a++)
layout[cInputs[a]] = { x: COL_INPUT, y: topIn + a * (NODE_IO_H + ITEM_GAP) };
for (var b = 0; b < cGates.length; b++)
layout[cGates[b]] = { x: COL_GATE, y: topGate + b * (NODE_GATE_H + ITEM_GAP) };
for (var c = 0; c < cOutputs.length; c++)
layout[cOutputs[c]] = { x: COL_OUTPUT, y: topOut + c * (NODE_IO_H + ITEM_GAP) };
curY += ch + CHAIN_GAP;
}
// 暴露边界信息供 canvas 自适应
layout._bounds = {
minX: COL_INPUT - 130, maxX: COL_OUTPUT + 130,
minY: startY - 40, maxY: startY + totalH + 40
};
return layout;
}
/* ---- 通用绘制函数(编辑器 + 查看器共用) ---- */
function drawGraphContent(ctx, nodes, links, selectedIdx, selectedLinkIdx, wiring) {
// 节点渲染尺寸常量
var IO_W = 250, IO_H = 80; // 输入/输出
var GATE_W = 170, GATE_H = 66; // 逻辑门
var CORNER_R = 12;
var SHADOW_OFF = 3;
var LEFT_BAR_W = 8;
// ---- 连线层 ----
for (var i = 0; i < links.length; i++) {
var l = links[i];
if (l.srcIdx >= nodes.length || l.dstIdx >= nodes.length) continue;
var sn = nodes[l.srcIdx], dn = nodes[l.dstIdx];
var srcIsIO = (sn.type === NT_OUTPUT || sn.type === NT_INPUT);
var dstIsIO = (dn.type === NT_OUTPUT || dn.type === NT_INPUT);
var sW = srcIsIO ? IO_W : GATE_W, dW = dstIsIO ? IO_W : GATE_W;
var dH = dstIsIO ? IO_H : GATE_H;
var x1 = sn._lx + sW/2;
var y1 = sn._ly;
var dstInP = getNodeInPins(dn);
var effPinIdx = (l.dstPin !== undefined) ? l.dstPin : 0;
var y2 = dn._ly - dH/2 + dH * (effPinIdx + 1) / (dstInP + 1);
var x2 = dn._lx - dW/2;
var isActive = wiring && wiring.fromIdx === l.srcIdx;
var isLinkSelected = (selectedLinkIdx === i);
var midX = (x1 + x2) / 2;
var lineAlpha = (isActive || isLinkSelected) ? 1 : 0.6;
var lineW = (isActive || isLinkSelected) ? 3 : 2;
var lineColor = isLinkSelected ? '#FF5722' : (isActive ? '#FFC107' : 'rgba(180,200,220,' + lineAlpha + ')');
// 发光效果 (阴影线)
ctx.beginPath();
ctx.strokeStyle = isLinkSelected ? 'rgba(255,87,34,0.3)' : 'rgba(255,200,0,' + (lineAlpha * 0.2) + ')';
ctx.lineWidth = lineW + 4;
ctx.moveTo(x1, y1);
ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2);
ctx.stroke();
// 主线
ctx.beginPath();
ctx.strokeStyle = lineColor;
ctx.lineWidth = lineW;
ctx.moveTo(x1, y1);
ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2);
ctx.stroke();
// 箭头 (指向目标)
ctx.fillStyle = lineColor;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - 7, y2 - 3.5);
ctx.lineTo(x2 - 7, y2 + 3.5);
ctx.closePath();
ctx.fill();
// 选中连线删除按钮标记(×)
if (isLinkSelected) {
var labelX = (x1 + x2) / 2, labelY = (y1 + y2) / 2 - 18;
ctx.fillStyle = '#FF5722';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('✕ 右键删除', labelX, labelY);
}
}
// 临时连线(仅编辑器)
if (wiring && wiring.fromIdx < nodes.length) {
var fn = nodes[wiring.fromIdx];
var fnIsIO = (fn.type === NT_OUTPUT || fn.type === NT_INPUT);
var fW = fnIsIO ? IO_W : GATE_W;
var fx = (wiring.fromSide === 'out') ? fn._lx + fW/2 : fn._lx - fW/2;
var fy = fn._ly;
ctx.beginPath();
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.moveTo(fx, fy);
ctx.lineTo(fx + (wiring.fromSide === 'out' ? 100 : -100), fy);
ctx.stroke();
ctx.setLineDash([]);
}
// ---- 节点层 ----
for (var j = 0; j < nodes.length; j++) {
var n = nodes[j];
var info = NODE_INFO[n.type] || { name: '?', color: '#555', icon: '?' };
var isIO = (n.type === NT_OUTPUT || n.type === NT_INPUT);
var inP = getNodeInPins(n);
var nw = isIO ? IO_W : GATE_W;
var nh = isIO ? IO_H : (GATE_H + Math.max(0, inP - 2) * 14);
var x = n._lx, y = n._ly;
var halfW = nw/2, halfH = nh/2;
var isWiringSrc = wiring && wiring.fromIdx === j;
var isSelected = (selectedIdx === j);
// 发光描边宽度
var glowW = (isSelected || isWiringSrc) ? 3 : 1;
// --- 阴影 ---
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.beginPath();
ctx.roundRect(x - halfW + SHADOW_OFF, y - halfH + SHADOW_OFF, nw, nh, CORNER_R);
ctx.fill();
// --- 主体渐变 ---
var grad = ctx.createLinearGradient(x - halfW, y - halfH, x - halfW, y + halfH);
if (isSelected) {
grad.addColorStop(0, '#3b3b6b');
grad.addColorStop(1, '#282848');
} else if (isWiringSrc) {
grad.addColorStop(0, '#3c3c50');
grad.addColorStop(1, '#252538');
} else {
grad.addColorStop(0, '#2e2e4a');
grad.addColorStop(1, '#1e1e36');
}
ctx.fillStyle = grad;
ctx.beginPath();
ctx.roundRect(x - halfW, y - halfH, nw, nh, CORNER_R);
ctx.fill();
// --- 描边 ---
ctx.strokeStyle = isSelected ? '#FFD700' : (isWiringSrc ? '#FFC107' : info.color);
ctx.lineWidth = glowW;
ctx.beginPath();
ctx.roundRect(x - halfW, y - halfH, nw, nh, CORNER_R);
ctx.stroke();
// --- 左侧色条 ---
ctx.fillStyle = info.color;
ctx.beginPath();
ctx.roundRect(x - halfW, y - halfH, LEFT_BAR_W, nh, [CORNER_R, 0, 0, CORNER_R]);
ctx.fill();
// --- 顶部类型标签 ---
if (info.name) {
var labelBgX = x - halfW + LEFT_BAR_W;
var labelBgW = info.name.length * 7.5 + 18;
var labelBgY = y - halfH + 2;
var labelBgH = 16;
ctx.fillStyle = info.color;
ctx.globalAlpha = 0.25;
ctx.fillRect(labelBgX, labelBgY, Math.min(labelBgW, nw - LEFT_BAR_W - 4), labelBgH);
ctx.globalAlpha = 1;
ctx.fillStyle = isIO ? '#fff' : info.color;
ctx.font = '9px -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(info.name, labelBgX + 4, labelBgY + 2.5);
}
// --- 文本内容 ---
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (isIO) {
var decId = n.id;
var sig = n.saddr ? n : findSignalByNo(decId);
var prefix = (n.type === NT_INPUT) ? '📥' : '📤';
var roleLabel = (n.type === NT_INPUT) ? '输入点' : '输出点';
// ID 行
ctx.fillStyle = '#e0e0e0';
ctx.font = 'bold 12px -apple-system, sans-serif';
ctx.fillText(prefix + ' ' + roleLabel + ' #' + decId, x, y - 18);
// saddr 行
ctx.fillStyle = '#aaa';
ctx.font = '10px monospace';
var saddrText = (sig && sig.saddr) ? sig.saddr : '—';
ctx.fillText(saddrText, x, y);
// desc 行
var descText = (sig && sig.desc) ? sig.desc : '';
if (descText) {
ctx.fillStyle = '#888';
ctx.font = '9px -apple-system, sans-serif';
var truncated = descText.length > 18 ? descText.substring(0, 16) + '…' : descText;
ctx.fillText(truncated, x, y + 14);
}
} else {
// 逻辑门
var gateIdx = n.id & 0xFF;
var realIn = 0, realOut = 0;
for (var li = 0; li < links.length; li++) {
if (links[li].dstIdx === j) realIn++;
if (links[li].srcIdx === j) realOut++;
}
// 中部:名称 + 序号
ctx.fillStyle = '#e8e8e8';
ctx.font = 'bold 11px -apple-system, sans-serif';
ctx.fillText(info.icon + ' #' + gateIdx, x, y - 4);
// 连接数
ctx.fillStyle = '#999';
ctx.font = '9px monospace';
ctx.fillText(realIn + '入 ' + realOut + '出', x, y + 14);
}
// --- 输入引脚 ---
if (inP > 0) {
var renderPins = isIO ? inP : 1;
for (var p = 0; p < renderPins; p++) {
var py = y - nh/2 + nh * (p + 1) / (inP + 1);
// 外圈
ctx.fillStyle = '#2e2e4a';
ctx.beginPath();
ctx.arc(x - halfW, py, 7, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#4CAF50';
ctx.lineWidth = 2;
ctx.stroke();
// 内芯
ctx.fillStyle = '#4CAF50';
ctx.beginPath();
ctx.arc(x - halfW, py, 3.5, 0, Math.PI * 2);
ctx.fill();
}
}
// --- 输出引脚 ---
if (info.outPins > 0) {
// 外圈
ctx.fillStyle = '#2e2e4a';
ctx.beginPath();
ctx.arc(x + halfW, y, 7, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#FF9800';
ctx.lineWidth = 2;
ctx.stroke();
// 内芯
ctx.fillStyle = '#FF9800';
ctx.beginPath();
ctx.arc(x + halfW, y, 3.5, 0, Math.PI * 2);
ctx.fill();
}
}
}
function renderEditorTab(container) {
editNodes = [];
editLinks = [];
editKey = '';
editName = '';
editOnlineCfgId = '';
editSelectedNode = -1;
editSelectedLink = -1;
editWiring = null;
editDragNode = null;
editPanX = 0; editPanY = 0;
editScale = 1.0;
editLoadedFromFile = false;
editNextGateIdx = 0;
container.innerHTML = ''
+ '<div class="plc-editor-toolbar">'
+ '<input type="text" id="plc-edit-key" placeholder="逻辑图 Key" value="' + escHtml(editKey) + '" style="width:140px">'
+ '<input type="text" id="plc-edit-name" placeholder="逻辑图名称" value="' + escHtml(editName) + '" style="width:160px">'
+ '<span style="flex:1"></span>'
+ '<button class="btn btn-success" id="plc-edit-add-in">📥 新增输入</button>'
+ '<button class="btn btn-outline" id="plc-edit-add-gate">🧮 新增运算</button>'
+ '<button class="btn btn-info" id="plc-edit-add-out">📤 新增输出</button>'
+ '<button class="btn btn-outline" id="plc-edit-zoomin">🔍+</button>'
+ '<button class="btn btn-outline" id="plc-edit-zoomout">🔍-</button>'
+ '<button class="btn btn-warn" id="plc-edit-modify">✏️ 修改模块</button>'
+ '<button class="btn btn-danger" id="plc-edit-del">🗑 删除模块</button>'
+ '<button class="btn btn-primary" id="plc-edit-generate">✅ 生成配置</button>'
+ '</div>'
+ '<div class="plc-editor-main">'
+ '<div class="plc-canvas-wrap" id="plc-edit-canvas-wrap">'
+ '<canvas id="plc-edit-canvas" tabindex="0"></canvas>'
+ '</div>'
+ '</div>'
+ '<div class="plc-editor-info" id="plc-edit-info">'
+ '💡 双击模块可修改配置 | 单击选中/引脚连线 | 右键删除连线 | 滚轮缩放 | 按住空白拖动画布'
+ '</div>';
// 工具栏事件
document.getElementById('plc-edit-key').oninput = function() { editKey = this.value; };
document.getElementById('plc-edit-name').oninput = function() { editName = this.value; };
document.getElementById('plc-edit-add-in').onclick = function() { showAddInputModal(); };
document.getElementById('plc-edit-add-out').onclick = function() { showAddOutputModal(); };
document.getElementById('plc-edit-add-gate').onclick = function() { showAddGateModal(); };
document.getElementById('plc-edit-zoomin').onclick = function() { editScale = Math.min(3, editScale * 1.2); drawEditor(); };
document.getElementById('plc-edit-zoomout').onclick = function() { editScale = Math.max(0.2, editScale / 1.2); drawEditor(); };
document.getElementById('plc-edit-del').onclick = function() { deleteSelectedNode(); };
document.getElementById('plc-edit-modify').onclick = function() { modifySelectedNode(); };
document.getElementById('plc-edit-generate').onclick = function() { generateAndSaveConfig(); };
setupEditorCanvas();
drawEditor();
// 加载当前 Reclose_logic.txt 为编辑起点
function initEditor() {
loadLogicFile(function() {
if (logicGraphs.length > 0) {
// 优先使用非 bak 文件的最新配置
var g = null;
for (var gi = 0; gi < logicGraphs.length; gi++) {
if (!logicGraphs[gi]._source || logicGraphs[gi]._source.indexOf('.bak') < 0) {
g = logicGraphs[gi]; break;
}
}
if (!g && logicGraphs.length > 0) g = logicGraphs[0];
if (g) {
editKey = g.key;
editName = g.name || '';
editOnlineCfgId = onlineCfgId;
// 复制节点到编辑器
var maxGateIdx = 0;
for (var i = 0; i < g.nodes.length; i++) {
var n = g.nodes[i];
var newNode = {
type: n.type, id: n.id, x: n.x, y: n.y,
_lx: n.x, _ly: n.y,
saddr: '', desc: '', no: n.id
};
// 查找信号信息
var sig = findSignalByNo(n.id);
if (sig) { newNode.saddr = sig.saddr; newNode.desc = sig.desc; }
// 逻辑门 inPins — 优先使用文件中保存的配置
if (n.type !== NT_INPUT && n.type !== NT_OUTPUT) {
newNode.inPins = (n.inPins !== undefined) ? n.inPins : ((NODE_INFO[n.type] || {}).inPins || 2);
var idx = n.id & 0xFF;
if (idx > maxGateIdx) maxGateIdx = idx;
}
editNodes.push(newNode);
}
// 复制链路(按节点在新数组中的索引)
for (var j = 0; j < g.links.length; j++) {
var l = g.links[j];
var si = findEditNodeIndex(l.src_type, l.src_id);
var di = findEditNodeIndex(l.dst_type, l.dst_id);
if (si >= 0 && di >= 0) editLinks.push({ srcIdx: si, dstIdx: di });
}
editLoadedFromFile = true;
// 更新全局门计数器,避免后续新增门 ID 冲突
editNextGateIdx = maxGateIdx + 1;
// 消除已有的重复 type+id门电路限定确保保存后文件 ID 唯一
var seenIds = {};
for (var di = 0; di < editNodes.length; di++) {
var dn = editNodes[di];
if (dn.type === NT_INPUT || dn.type === NT_OUTPUT) continue;
var key2 = dn.type + ':' + (dn.id & 0xFF);
if (seenIds[key2]) {
editNodes[di].id = (dn.id & ~0xFF) | editNextGateIdx;
editNextGateIdx++;
} else {
seenIds[key2] = true;
}
}
// 更新输入框
var keyEl = document.getElementById('plc-edit-key');
var nameEl = document.getElementById('plc-edit-name');
if (keyEl) keyEl.value = editKey;
if (nameEl) nameEl.value = editName;
} else {
editKey = String(Date.now());
editName = '';
var keyEl = document.getElementById('plc-edit-key');
if (keyEl) keyEl.value = editKey;
}
drawEditor();
}
});
}
if (dcOutSignals.length === 0) {
loadDcOutSignals(function() { initEditor(); });
} else {
initEditor();
}
}
function findEditNodeIndex(type, id) {
for (var i = 0; i < editNodes.length; i++) {
var n = editNodes[i];
if (n.type === type) {
if (type <= 1 && n.id === id) return i;
if (type > 1 && (n.id & 0xFF) === (id & 0xFF)) return i;
}
}
return -1;
}
function deleteSelectedNode() {
if (editSelectedNode < 0 || editSelectedNode >= editNodes.length) {
showToast('请先在画布上双击或单击选中一个模块', 'err');
return;
}
var node = editNodes[editSelectedNode];
var idx = editSelectedNode;
var label = getNodeDisplayLabel(node);
var html = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:450px">'
+ '<h3>🗑 确认删除</h3>'
+ '<p style="margin:12px 0;font-size:14px">确定删除 <strong style="color:#f44336">' + escHtml(label) + '</strong> 及其所有连线?</p>'
+ '<div style="margin-top:16px;display:flex;gap:8px">'
+ '<button class="btn btn-danger" id="plc-modal-ok">🗑 确认删除</button>'
+ '<button class="btn btn-outline" id="plc-modal-cancel">取消</button>'
+ '</div></div></div>';
var div = document.createElement('div');
div.innerHTML = html;
document.body.appendChild(div.firstElementChild);
document.getElementById('plc-modal-cancel').onclick = closeModal;
document.getElementById('plc-modal-ok').onclick = function() {
closeModal();
editLinks = editLinks.filter(function(l) { return l.srcIdx !== idx && l.dstIdx !== idx; });
for (var i = 0; i < editLinks.length; i++) {
if (editLinks[i].srcIdx > idx) editLinks[i].srcIdx--;
if (editLinks[i].dstIdx > idx) editLinks[i].dstIdx--;
}
editNodes.splice(idx, 1);
editSelectedNode = -1;
drawEditor();
showToast('模块已删除', 'ok');
};
}
function modifySelectedNode() {
if (editSelectedNode < 0 || editSelectedNode >= editNodes.length) {
showToast('请先在画布上双击或单击选中一个模块', 'err');
return;
}
var idx = editSelectedNode;
var node = editNodes[idx];
if (node.type === NT_INPUT || node.type === NT_OUTPUT) {
showRebindSignalModal(idx);
} else if (node.type === NT_AND || node.type === NT_OR) {
showGateConfigModal(idx);
} else {
// 非门、62p、62d、上升沿、下降沿、SR、RS — 也可配置
if (node.type === NT_NOT || node.type === NT_62P || node.type === NT_62D ||
node.type === NT_RISING || node.type === NT_FALLING ||
node.type === NT_SR || node.type === NT_RS) {
showGateConfigModal(idx);
} else {
showToast(getNodeDisplayLabel(node) + ' (无需额外配置)', 'info');
}
}
}
// 节点双击 — 根据类型弹出编辑框
function onNodeDoubleClicked(idx) {
var node = editNodes[idx];
editSelectedNode = idx;
drawEditor();
if (node.type === NT_INPUT || node.type === NT_OUTPUT) {
showRebindSignalModal(idx);
} else if (node.type === NT_AND || node.type === NT_OR) {
showGateConfigModal(idx);
} else {
showToast(getNodeDisplayLabel(node) + ' (无需额外配置)', 'info');
}
}
// 重新绑定 I/O 节点信号
function showRebindSignalModal(nodeIdx) {
var node = editNodes[nodeIdx];
var signals = dcOutSignals;
if (signals.length === 0) { showToast('信号列表为空,请先加载', 'err'); return; }
var title = (node.type === NT_INPUT) ? '📥 重新绑定输入信号' : '📤 重新绑定输出信号';
var html = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:650px">'
+ '<h3>' + title + '</h3>'
+ '<p style="color:#999">当前: ' + escHtml(getNodeDisplayLabel(node)) + '</p>'
+ '<input type="text" id="plc-modal-search" placeholder="搜索信号..." style="width:100%;margin:8px 0;padding:8px;border:1px solid #ddd;border-radius:4px">'
+ '<div style="max-height:350px;overflow-y:auto" id="plc-modal-list">';
for (var j = 0; j < signals.length; j++) {
var s = signals[j];
var isCurrent = (node.no === s.no);
html += '<div class="plc-modal-row' + (isCurrent ? ' plc-modal-current' : '') + '" data-no="' + s.no + '" data-saddr="' + escHtml(s.saddr) + '" data-desc="' + escHtml(s.desc) + '">'
+ '<strong>#' + s.no + ' ' + escHtml(s.saddr) + '</strong>'
+ ' <span style="color:#999">' + escHtml(s.desc) + '</span>'
+ (isCurrent ? '<span style="float:right;color:#2196F3">✓ 当前</span>' : '')
+ '</div>';
}
html += '</div><button class="btn btn-outline" id="plc-modal-cancel">取消</button></div></div>';
showModalCustom(html, function(no, saddr, desc) {
var sig = findSignalByNo(no);
editNodes[nodeIdx].no = no;
editNodes[nodeIdx].id = no;
editNodes[nodeIdx].saddr = saddr;
editNodes[nodeIdx].desc = desc;
drawEditor();
showToast('已绑定: ' + saddr, 'ok');
return true;
});
}
// 配置门电路引脚数
function showGateConfigModal(nodeIdx) {
var node = editNodes[nodeIdx];
var curPins = getNodeInPins(node);
var info = NODE_INFO[node.type] || {};
var maxPins = info.inMax || 99;
var minPins = 1;
if (node.type === NT_AND || node.type === NT_OR) minPins = 1;
else if (node.type === NT_NOT) minPins = 1;
else minPins = 1;
var html = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:400px">'
+ '<h3>' + info.icon + ' ' + info.name + ' 配置</h3>'
+ '<p style="color:#999">当前输入节点数: <strong>' + curPins + '</strong></p>'
+ '<label style="display:block;margin:12px 0 4px">输入引脚数量 (' + minPins + '~' + maxPins + '):</label>'
+ '<input type="number" id="plc-gate-pins" value="' + curPins + '" min="' + minPins + '" max="' + maxPins + '" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px;font-size:16px">'
+ '<div style="margin-top:16px;display:flex;gap:8px">'
+ '<button class="btn btn-primary" id="plc-modal-save">💾 保存</button>'
+ '<button class="btn btn-outline" id="plc-modal-cancel">取消</button>'
+ '</div></div></div>';
var div = document.createElement('div');
div.innerHTML = html;
document.body.appendChild(div.firstElementChild);
document.getElementById('plc-modal-cancel').onclick = closeModal;
document.getElementById('plc-modal-save').onclick = function() {
var v = parseInt(document.getElementById('plc-gate-pins').value);
if (isNaN(v) || v < minPins) { showToast('最少 ' + minPins + ' 个输入引脚', 'err'); return; }
if (v > maxPins) { showToast('最多 ' + maxPins + ' 个输入引脚', 'err'); return; }
editNodes[nodeIdx].inPins = v;
closeModal();
drawEditor();
showToast(info.name + ' 输入引脚已设为 ' + v, 'ok');
};
}
/* ---- 编辑器 Canvas ---- */
function setupEditorCanvas() {
var wrap = document.getElementById('plc-edit-canvas-wrap');
var canvas = document.getElementById('plc-edit-canvas');
if (!wrap || !canvas) return;
function resize() {
canvas.width = wrap.clientWidth;
canvas.height = Math.max(500, wrap.clientHeight - 4);
// 居中画布
editScale = 0.7;
editPanX = canvas.width / 2;
editPanY = canvas.height / 2;
drawEditor();
}
setTimeout(resize, 50);
window.addEventListener('resize', function() { resize(); });
canvas.onmousedown = function(e) {
var rect = canvas.getBoundingClientRect();
var mx = (e.clientX - rect.left - editPanX) / editScale;
var my = (e.clientY - rect.top - editPanY) / editScale;
// 检查是否点击了引脚 → 连线操作
var pinHit = hitTestPin(mx, my);
if (pinHit) {
if (editWiring) {
// 完成连线
if (editWiring.fromIdx !== pinHit.nodeIdx) {
if (editWiring.fromSide === 'out' && pinHit.side === 'in') {
var srcInfo = NODE_INFO[editNodes[editWiring.fromIdx].type] || {};
var outMax = srcInfo.outMax || 99;
var outCount = 0;
for (var lc = 0; lc < editLinks.length; lc++) {
if (editLinks[lc].srcIdx === editWiring.fromIdx) outCount++;
}
if (outCount >= outMax) {
showToast('该模块输出已达上限 (' + outMax + ')', 'err');
editWiring = null;
editSelectedNode = -1;
drawEditor();
return;
}
var link = { srcIdx: editWiring.fromIdx, dstIdx: pinHit.nodeIdx, dstPin: pinHit.pinIdx };
editLinks.push(link);
showToast('连线已添加', 'ok');
} else if (editWiring.fromSide === 'in' && pinHit.side === 'out') {
editLinks.push({ srcIdx: pinHit.nodeIdx, dstIdx: editWiring.fromIdx, dstPin: 0 });
showToast('连线已添加', 'ok');
} else {
showToast('不能这样连线', 'err');
}
}
editWiring = null;
editSelectedNode = -1;
editSelectedLink = -1;
drawEditor();
return;
} else {
editWiring = { fromIdx: pinHit.nodeIdx, fromSide: pinHit.side, fromPin: pinHit.pinIdx };
editSelectedNode = -1;
editSelectedLink = -1;
drawEditor();
return;
}
}
// 检查是否点击了连线 → 选中高亮(左键选择,右键删除)
var linkHit = hitTestLink(mx, my);
if (linkHit >= 0) {
editSelectedLink = linkHit;
editSelectedNode = -1;
editDragNode = null;
if (editWiring) { editWiring = null; }
drawEditor();
return;
}
// 检查是否点击了节点 → 拖拽或选中
var nodeHit = hitTestNode(mx, my);
if (nodeHit >= 0) {
editSelectedNode = nodeHit;
editSelectedLink = -1;
editDragNode = nodeHit;
editDragOffX = mx - editNodes[nodeHit]._lx;
editDragOffY = my - editNodes[nodeHit]._ly;
drawEditor();
return;
}
// 空白处取消选中和连线,并开始画布拖拽
editSelectedNode = -1;
editSelectedLink = -1;
if (editWiring) { editWiring = null; }
editDragCanvas = { startX: e.clientX, startY: e.clientY, panX: editPanX, panY: editPanY };
canvas.style.cursor = 'grabbing';
drawEditor();
};
canvas.onmousemove = function(e) {
var rect = canvas.getBoundingClientRect();
var mx = (e.clientX - rect.left - editPanX) / editScale;
var my = (e.clientY - rect.top - editPanY) / editScale;
if (editDragCanvas) {
editPanX = editDragCanvas.panX + (e.clientX - editDragCanvas.startX);
editPanY = editDragCanvas.panY + (e.clientY - editDragCanvas.startY);
drawEditor();
return;
}
if (editDragNode !== null) {
editNodes[editDragNode]._lx = mx - editDragOffX;
editNodes[editDragNode]._ly = my - editDragOffY;
drawEditor();
return;
}
if (editWiring && editWiring.fromIdx < editNodes.length) {
drawEditor();
}
var pin = hitTestPin(mx, my);
canvas.style.cursor = pin ? 'crosshair' : (hitTestNode(mx, my) >= 0 ? 'grab' : 'default');
};
canvas.onmouseup = function(e) {
if (editDragCanvas) {
editDragCanvas = null;
canvas.style.cursor = 'default';
return;
}
if (editDragNode !== null) {
var node = editNodes[editDragNode];
var nodeIdx = editDragNode;
var dx = Math.abs((e.clientX - canvas.getBoundingClientRect().left - editPanX) / editScale - node._lx - editDragOffX);
var dy = Math.abs((e.clientY - canvas.getBoundingClientRect().top - editPanY) / editScale - node._ly - editDragOffY);
// 小于 3px 视为点击(非拖拽)
if (dx < 3 && dy < 3) {
// 双击检测:同节点 400ms 内连续点击
var now = Date.now();
if (nodeIdx === editLastClickNode && (now - editLastClickTime) < 400) {
onNodeDoubleClicked(nodeIdx);
editLastClickTime = 0;
editLastClickNode = -1;
} else {
editLastClickTime = now;
editLastClickNode = nodeIdx;
}
}
}
editDragNode = null;
};
canvas.onmouseleave = function() { editDragNode = null; editDragCanvas = null; canvas.style.cursor = 'default'; };
canvas.onwheel = function(e) {
e.preventDefault();
var delta = e.deltaY > 0 ? 0.9 : 1.1;
var rect = canvas.getBoundingClientRect();
var mx = e.clientX - rect.left;
var my = e.clientY - rect.top;
var ns = Math.max(0.2, Math.min(3, editScale * delta));
editPanX = mx - (mx - editPanX) * (ns / editScale);
editPanY = my - (my - editPanY) * (ns / editScale);
editScale = ns;
drawEditor();
};
// 右键 —— 删除选中的连线 / 节点
canvas.oncontextmenu = function(e) {
e.preventDefault();
if (editSelectedLink >= 0) {
editLinks.splice(editSelectedLink, 1);
editSelectedLink = -1;
editSelectedNode = -1;
showToast('连线已删除', 'ok');
drawEditor();
return;
}
// 如果选中了节点也可以用右键删除
if (editSelectedNode >= 0) {
deleteSelectedNode();
return;
}
};
canvas.tabIndex = 0;
canvas.onkeydown = function(e) {
if (e.key === 'Escape') { editWiring = null; editSelectedNode = -1; editSelectedLink = -1; drawEditor(); }
if (e.key === 'Delete' && editSelectedNode >= 0) { deleteSelectedNode(); }
if (e.key === 'Delete' && editSelectedLink >= 0) { editLinks.splice(editSelectedLink, 1); editSelectedLink = -1; showToast('连线已删除', 'ok'); drawEditor(); }
};
}
/* ---- 获取节点的实际 inPins优先使用节点自身的配置 ---- */
function getNodeInPins(node) {
if (node.inPins !== undefined && node.inPins > 0) return node.inPins;
var info = NODE_INFO[node.type] || {};
return info.inPins || 0;
}
function getNodeSize(node) {
var isIO = (node.type === NT_OUTPUT || node.type === NT_INPUT);
var inP = getNodeInPins(node);
var h = isIO ? 80 : (66 + Math.max(0, inP - 2) * 14);
var w = isIO ? 250 : 170;
return { w: w, h: h };
}
function hitTestNode(mx, my) {
for (var i = editNodes.length - 1; i >= 0; i--) {
var n = editNodes[i];
var sz = getNodeSize(n);
var x = n._lx, y = n._ly;
if (mx > x - sz.w/2 && mx < x + sz.w/2 && my > y - sz.h/2 && my < y + sz.h/2) return i;
}
return -1;
}
function hitTestPin(mx, my) {
var pinR = 8;
for (var i = editNodes.length - 1; i >= 0; i--) {
var n = editNodes[i];
var sz = getNodeSize(n);
var info = NODE_INFO[n.type] || NODE_INFO[0];
var x = n._lx, y = n._ly;
var isIO = (n.type === NT_INPUT || n.type === NT_OUTPUT);
var inP = getNodeInPins(n);
// 输入引脚(左侧)
if (info.inPins > 0) {
var testPins = isIO ? inP : 1;
for (var p = 0; p < testPins; p++) {
var py = y - sz.h/2 + sz.h * (p + 1) / (inP + 1);
var dx = mx - (x - sz.w/2), dy = my - py;
if (Math.sqrt(dx*dx + dy*dy) <= pinR) return { nodeIdx: i, side: 'in', pinIdx: p };
}
}
// 输出引脚(右侧)
if (info.outPins > 0) {
var dx = mx - (x + sz.w/2), dy = my - y;
if (Math.sqrt(dx*dx + dy*dy) <= pinR) return { nodeIdx: i, side: 'out', pinIdx: 0 };
}
}
return null;
}
function hitTestLink(mx, my) {
for (var i = 0; i < editLinks.length; i++) {
var l = editLinks[i];
if (l.srcIdx >= editNodes.length || l.dstIdx >= editNodes.length) continue;
var sn = editNodes[l.srcIdx], dn = editNodes[l.dstIdx];
var szSrc = getNodeSize(sn), szDst = getNodeSize(dn);
var x1 = sn._lx + szSrc.w/2, y1 = sn._ly;
var dstInPins = getNodeInPins(dn);
var gateInPins = getNodeInPins(dn);
var effInPins = (dn.type !== NT_INPUT && dn.type !== NT_OUTPUT) ? 1 : gateInPins;
var dstPinIdx2 = (l.dstPin !== undefined) ? l.dstPin : 0;
var y2 = dn._ly - szDst.h/2 + szDst.h * (dstPinIdx2 + 1) / (effInPins + 1);
var x2 = dn._lx - szDst.w/2;
var t = ((mx - x1)*(x2 - x1) + (my - y1)*(y2 - y1)) / ((x2 - x1)*(x2 - x1) + (y2 - y1)*(y2 - y1));
t = Math.max(0, Math.min(1, t));
var px = x1 + t * (x2 - x1), py = y1 + t * (y2 - y1);
if (Math.sqrt((mx - px)*(mx - px) + (my - py)*(my - py)) < 10) return i;
}
return -1;
}
function getNodeLabel(n) {
var info = NODE_INFO[n.type] || {};
var label = (info.name || '?');
if (n.saddr) label += ' ' + n.saddr;
else if (n.type <= 1) label += ' #' + n.id;
else label += ' #' + (n.id & 0xFF);
return label;
}
/* ---- 编辑器绘制 ---- */
function drawEditor() {
var canvas = document.getElementById('plc-edit-canvas');
if (!canvas) return;
var ctx = canvas.getContext('2d');
var W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.translate(editPanX, editPanY);
ctx.scale(editScale, editScale);
// 网格
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 1;
var gridSize = 40;
var startX = Math.floor(-editPanX / editScale / gridSize) * gridSize;
var startY = Math.floor(-editPanY / editScale / gridSize) * gridSize;
for (var gx = startX - gridSize; gx < startX + W/editScale + gridSize; gx += gridSize) {
ctx.beginPath(); ctx.moveTo(gx, startY - gridSize); ctx.lineTo(gx, startY + H/editScale + gridSize); ctx.stroke();
}
for (var gy = startY - gridSize; gy < startY + H/editScale + gridSize; gy += gridSize) {
ctx.beginPath(); ctx.moveTo(startX - gridSize, gy); ctx.lineTo(startX + W/editScale + gridSize, gy); ctx.stroke();
}
// 区域标签
ctx.fillStyle = 'rgba(255,255,255,0.08)';
ctx.font = '28px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('📥 输入信号区', -250, -200);
ctx.fillText('🧮 逻辑运算区', 0, -200);
ctx.fillText('📤 输出信号区', 250, -200);
// 共用绘制
drawGraphContent(ctx, editNodes, editLinks, editSelectedNode, editSelectedLink, editWiring);
ctx.restore();
// 更新信息栏
var infoEl = document.getElementById('plc-edit-info');
if (infoEl) {
var selInfo = '';
if (editSelectedNode >= 0 && editSelectedNode < editNodes.length) {
selInfo = ' | 🔍 选中: ' + getNodeDisplayLabel(editNodes[editSelectedNode]);
}
infoEl.innerHTML = '节点: ' + editNodes.length + ' | 连线: ' + editLinks.length
+ ' | Key: ' + escHtml(editKey) + ' | 名称: ' + escHtml(editName || '(未设置)')
+ (editWiring ? ' | 🔗 连线中... (点击目标引脚完成ESC取消)' : '')
+ selInfo
+ ' | 右键删除连线 | 滚轮缩放 | Delete键删除选中';
}
}
/* ---- 添加输入/输出/运算弹窗 ---- */
function showAddInputModal() {
if (dcOutSignals.length === 0) { showToast('请先加载信号', 'err'); return; }
var signals = dcOutSignals;
var html = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:650px">'
+ '<h3>📥 绑定输入信号</h3>'
+ '<input type="text" id="plc-modal-search" placeholder="搜索信号 (no/saddr/desc)..." style="width:100%;margin:8px 0;padding:8px;border:1px solid #ddd;border-radius:4px">'
+ '<div style="max-height:350px;overflow-y:auto" id="plc-modal-list">';
for (var j = 0; j < signals.length; j++) {
var s = signals[j];
html += '<div class="plc-modal-row" data-no="' + s.no + '" data-saddr="' + escHtml(s.saddr) + '" data-desc="' + escHtml(s.desc) + '">'
+ '<strong>#' + s.no + ' ' + escHtml(s.saddr) + '</strong>'
+ ' <span style="color:#999">' + escHtml(s.desc) + '</span></div>';
}
html += '</div><button class="btn btn-outline" id="plc-modal-cancel">取消</button></div></div>';
showModalCustom(html, function(no, saddr, desc) {
editNodes.push({
type: NT_INPUT, id: no, x: 0, y: 0,
_lx: -260, _ly: editNodes.filter(function(n){return n.type===NT_INPUT;}).length * 110,
saddr: saddr, desc: desc, no: no
});
drawEditor();
showToast('已添加输入: ' + saddr, 'ok');
return true;
});
}
function showAddOutputModal() {
if (dcOutSignals.length === 0) { showToast('请先加载信号', 'err'); return; }
var signals = dcOutSignals;
var html = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:650px">'
+ '<h3>📤 绑定输出信号</h3>'
+ '<input type="text" id="plc-modal-search" placeholder="搜索信号 (no/saddr/desc)..." style="width:100%;margin:8px 0;padding:8px;border:1px solid #ddd;border-radius:4px">'
+ '<div style="max-height:350px;overflow-y:auto" id="plc-modal-list">';
for (var j = 0; j < signals.length; j++) {
var s = signals[j];
html += '<div class="plc-modal-row" data-no="' + s.no + '" data-saddr="' + escHtml(s.saddr) + '" data-desc="' + escHtml(s.desc) + '">'
+ '<strong>#' + s.no + ' ' + escHtml(s.saddr) + '</strong>'
+ ' <span style="color:#999">' + escHtml(s.desc) + '</span></div>';
}
html += '</div><button class="btn btn-outline" id="plc-modal-cancel">取消</button></div></div>';
showModalCustom(html, function(no, saddr, desc) {
editNodes.push({
type: NT_OUTPUT, id: no, x: 0, y: 0,
_lx: 260, _ly: editNodes.filter(function(n){return n.type===NT_OUTPUT;}).length * 110,
saddr: saddr, desc: desc, no: no
});
drawEditor();
showToast('已添加输出: ' + saddr, 'ok');
return true;
});
}
function showAddGateModal() {
var gates = [
{ type: NT_AND, name: '与门 (AND)', desc: '2输入1输出所有输入为1时输出1', icon: '🔷' },
{ type: NT_OR, name: '或门 (OR)', desc: '2输入1输出任一输入为1时输出1', icon: '🔶' },
{ type: NT_NOT, name: '非门 (NOT)', desc: '1输入1输出输入取反', icon: '🔺' },
{ type: NT_62P, name: '62p 延时', desc: '输入1延时输出1输入0立即输出0', icon: '⏱️' },
{ type: NT_62D, name: '62d 延时', desc: '输入1立即输出1输入0延时输出0', icon: '⏱️' },
{ type: NT_RISING, name: '上升沿', desc: '0→1跳变时输出脉冲', icon: '⬆️' },
{ type: NT_FALLING, name: '下降沿', desc: '1→0跳变时输出脉冲', icon: '⬇️' },
{ type: NT_SR, name: 'SR触发器', desc: 'S置位优先S=1置位 R=1复位', icon: '🔒' },
{ type: NT_RS, name: 'RS触发器', desc: 'R复位优先R=1复位 S=1置位', icon: '🔒' }
];
var html = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:500px">'
+ '<h3>🧮 选择运算模块</h3>'
+ '<div style="max-height:400px;overflow-y:auto">';
for (var i = 0; i < gates.length; i++) {
var g = gates[i];
html += '<div class="plc-modal-row" data-gate-type="' + g.type + '">'
+ '<strong>' + g.icon + ' ' + g.name + '</strong>'
+ '<br><span style="color:#999;font-size:12px">' + g.desc + '</span>'
+ '</div>';
}
html += '</div><button class="btn btn-outline" id="plc-modal-cancel">取消</button></div></div>';
// 自定义 modal 处理
var div = document.createElement('div');
div.innerHTML = html;
document.body.appendChild(div.firstElementChild);
document.getElementById('plc-modal-cancel').onclick = closeModal;
var rows = document.querySelectorAll('#plc-modal .plc-modal-row');
for (var j = 0; j < rows.length; j++) {
rows[j].onclick = function() {
var gateType = parseInt(this.getAttribute('data-gate-type'));
var info = NODE_INFO[gateType];
var gateY = 100 + editNodes.filter(function(n) {
return n.type !== NT_INPUT && n.type !== NT_OUTPUT;
}).length * 120;
// 62p/62d 需要延时参数
var gateId = editNextGateIdx;
if (gateType === NT_62P || gateType === NT_62D) {
var delayMs = prompt('请输入延时毫秒数 (默认 1000ms):', '1000');
if (!delayMs) { closeModal(); return; }
gateId = (parseInt(delayMs) << 8) | editNextGateIdx;
}
editNodes.push({
type: gateType, id: gateId, x: 0, y: 0,
_lx: 0, _ly: gateY,
saddr: '', desc: '', inf: ''
});
editNextGateIdx++;
closeModal();
drawEditor();
showToast('已添加: ' + info.name, 'ok');
};
}
}
function showModal(html, onSelect) {
var div = document.createElement('div');
div.innerHTML = html;
document.body.appendChild(div.firstElementChild);
document.getElementById('plc-modal-cancel').onclick = closeModal;
// 搜索
var searchInput = document.getElementById('plc-modal-search');
if (searchInput) {
searchInput.oninput = function() {
var q = this.value.toLowerCase();
var rows = document.querySelectorAll('#plc-modal .plc-modal-row');
for (var r = 0; r < rows.length; r++) {
var text = (rows[r].textContent || '').toLowerCase();
rows[r].style.display = (text.indexOf(q) !== -1) ? '' : 'none';
}
};
}
var rows = document.querySelectorAll('#plc-modal .plc-modal-row');
for (var j = 0; j < rows.length; j++) {
rows[j].onclick = function() {
if (this.classList.contains('plc-modal-used')) {
showToast('该信号已被使用', 'err');
return;
}
var noAttr = this.getAttribute('data-no');
var saddr = this.getAttribute('data-saddr');
var desc = this.getAttribute('data-desc') || '';
var no = parseInt(noAttr) || 0;
var result = onSelect(no, saddr, desc);
if (result !== false) closeModal();
};
}
}
function showModalCustom(html, onSelect) {
var div = document.createElement('div');
div.innerHTML = html;
document.body.appendChild(div.firstElementChild);
document.getElementById('plc-modal-cancel').onclick = closeModal;
var searchInput = document.getElementById('plc-modal-search');
if (searchInput) {
searchInput.oninput = function() {
var q = this.value.toLowerCase();
var rows = document.querySelectorAll('#plc-modal .plc-modal-row');
for (var r = 0; r < rows.length; r++) {
var text = (rows[r].textContent || '').toLowerCase();
rows[r].style.display = (text.indexOf(q) !== -1) ? '' : 'none';
}
};
}
var rows = document.querySelectorAll('#plc-modal .plc-modal-row');
for (var j = 0; j < rows.length; j++) {
rows[j].onclick = function() {
if (this.classList.contains('plc-modal-used')) {
showToast('该信号已被使用', 'err');
return;
}
var noAttr = this.getAttribute('data-no');
var saddr = this.getAttribute('data-saddr');
var desc = this.getAttribute('data-desc') || '';
var no = parseInt(noAttr) || 0;
var result = onSelect(no, saddr, desc);
if (result !== false) closeModal();
};
}
}
function closeModal() {
var m = document.getElementById('plc-modal');
if (m) m.remove();
}
/* ---- 生成并保存配置 ---- */
function generateAndSaveConfig() {
if (editNodes.length === 0) { showToast('请先添加节点', 'err'); return; }
// 验证
var hasInput = editNodes.some(function(n) { return n.type === NT_INPUT; });
var hasOutput = editNodes.some(function(n) { return n.type === NT_OUTPUT; });
if (!hasInput || !hasOutput) { showToast('至少需要一个输入和一个输出', 'err'); return; }
if (editLinks.length === 0) { showToast('请先连线', 'err'); return; }
// 保存前去重:确保门电路 type+id 唯一
var seenIds = {};
for (var di = 0; di < editNodes.length; di++) {
var dn = editNodes[di];
if (dn.type === NT_INPUT || dn.type === NT_OUTPUT) continue;
var key2 = dn.type + ':' + (dn.id & 0xFF);
if (seenIds[key2]) {
editNodes[di].id = (dn.id & ~0xFF) | editNextGateIdx;
editNextGateIdx++;
showToast('自动修正重复 ID: ' + NODE_INFO[dn.type].name + ' → #' + (editNextGateIdx - 1), 'info');
} else {
seenIds[key2] = true;
}
}
// 生成 Reclose_logic.txt 内容
var nodeParts = [];
for (var i = 0; i < editNodes.length; i++) {
var n = editNodes[i];
// 文件中 ID 保持十六进制,格式: {type:id:x:y[:inPins]}
var nodeStr = '{' + n.type + ':' + n.id.toString(16) + ':' + Math.round(n._lx) + ':' + Math.round(n._ly);
if (n.type > NT_INPUT && n.inPins !== undefined && n.inPins > (NODE_INFO[n.type]||{}).inPins) nodeStr += ':' + n.inPins;
nodeStr += '}';
nodeParts.push(nodeStr);
}
var linkParts = [];
for (var j = 0; j < editLinks.length; j++) {
var l = editLinks[j];
var sn = editNodes[l.srcIdx], dn = editNodes[l.dstIdx];
var linkStr = '{' + sn.type + ':' + sn.id.toString(16) + '-' + dn.type + ':' + dn.id.toString(16);
if (l.dstPin !== undefined && l.dstPin > 0) linkStr += ':' + l.dstPin;
linkStr += '}';
linkParts.push(linkStr);
}
var params = [];
params.push('key=' + editKey);
params.push('name=' + encodeURIComponent(editName || 'PLC Logic'));
params.push('desc=' + encodeURIComponent('desc'));
params.push('desc_pos_x=0');
params.push('desc_pos_y=0');
params.push('node=[' + nodeParts.join(',') + ']');
params.push('link=[' + linkParts.join(',') + ']');
var graphData = params.join('&');
// 构建完整文件
var fileObj = {};
fileObj[editKey] = graphData;
fileObj.online_cfgId = editOnlineCfgId || '00000000';
var fileContent = JSON.stringify(fileObj);
// 显示预览
var previewHtml = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:700px">'
+ '<h3>✅ 配置预览</h3>'
+ '<p>Key: <strong>' + escHtml(editKey) + '</strong> | 名称: <strong>' + escHtml(editName || 'PLC Logic') + '</strong></p>'
+ '<p>节点: ' + editNodes.length + ' | 连线: ' + editLinks.length + '</p>'
+ '<div style="max-height:200px;overflow:auto;background:#1a1a2e;color:#0f0;padding:12px;border-radius:6px;font-family:monospace;font-size:12px;word-break:break-all;white-space:pre-wrap">' + escHtml(fileContent) + '</div>'
+ '<div style="margin-top:12px;display:flex;gap:8px">'
+ '<button class="btn btn-primary" id="plc-modal-save">💾 保存并替换 Reclose_logic.txt</button>'
+ '<button class="btn btn-outline" id="plc-modal-cancel">取消</button>'
+ '</div></div></div>';
var div = document.createElement('div');
div.innerHTML = previewHtml;
document.body.appendChild(div.firstElementChild);
document.getElementById('plc-modal-cancel').onclick = closeModal;
document.getElementById('plc-modal-save').onclick = function() {
closeModal();
var oldContent = loadedFiles['config/PLC/Reclose_logic.txt'] || '';
if (oldContent && oldContent.length > 10) {
WsClient.send({ type: 'set_file', path: 'config/PLC/Reclose_logic.txt.bak', content: oldContent });
}
var sent = WsClient.send({
type: 'set_file',
path: 'config/PLC/Reclose_logic.txt',
content: fileContent
});
if (sent) {
showToast('配置已保存!请重启 PLC 模块生效', 'ok');
// 清除缓存以便下次查看器重新加载
loadedFiles['config/PLC/Reclose_logic.txt'] = null;
loadedFiles['config/PLC/Reclose_logic.txt.bak'] = null;
logicGraphs = [];
} else {
showToast('保存失败WebSocket 未连接', 'err');
}
};
}
/* ========== 页面生命周期 ========== */
function onEnter() {
// 重试之前因WS未连接而失败的文件请求
if (PlcConfigPage._retryList && PlcConfigPage._retryList.length > 0) {
var retry = PlcConfigPage._retryList.slice();
PlcConfigPage._retryList = [];
for (var ri = 0; ri < retry.length; ri++) {
requestFile(retry[ri], PlcConfigPage._pending[retry[ri]] || function(){});
}
}
// 自动加载 datacenter out 信号
if (dcOutSignals.length === 0) {
loadDcOutSignals();
}
}
function onLeave() {
editWiring = null;
editDragNode = null;
editSelectedNode = -1;
editSelectedLink = -1;
}
function onData(data) {
if (!data || typeof data !== 'object') return;
// 处理 file_content 响应
if (data.type === 'file_content') {
var path = data.path;
var content = data.content || '';
var error = data.error || '';
if (error) {
showToast('读取文件失败: ' + path + ' — ' + error, 'err');
} else {
loadedFiles[path] = content;
// 触发回调
if (PlcConfigPage._pending && PlcConfigPage._pending[path]) {
PlcConfigPage._pending[path](content);
delete PlcConfigPage._pending[path];
}
}
// 重试队列中移除
if (PlcConfigPage._retryList) {
var rIdx = PlcConfigPage._retryList.indexOf(path);
if (rIdx >= 0) PlcConfigPage._retryList.splice(rIdx, 1);
}
return;
}
// 处理 file_saved 响应
if (data.type === 'file_saved') {
if (data.success) {
showToast('文件已保存: ' + data.path, 'ok');
} else {
showToast('保存失败: ' + (data.error || '未知错误'), 'err');
}
return;
}
}
return {
render: render,
onEnter: onEnter,
onLeave: onLeave,
onData: onData,
_pending: {}
};
})();