1948 lines
71 KiB
JavaScript
1948 lines
71 KiB
JavaScript
/* ========================================================================
|
||
* 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
|
||
/* ---- 通过节点 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: {}
|
||
};
|
||
})();
|