feat(frontend): 信号管理页面重构 + 增量刷新 + 删除即时生效

核心变更:
- signal-store: 移除 localStorage 持久化, WS 断连清空;
  update() 新增增量 DOM 刷新 (仅更新变化的单元格);
  remove() 直接移除 DOM 行 (动画 fade out)
- router: 新增 reload() 强制重绘当前页
- ui: renderSignalPage 重写为统一列格式 #|saddr|desc|type|val|[ctrl]|操作;
  事件委托替代 inline onclick; 内联输入框替代弹窗设值
- datacenter: 改用后端 dc_data JSON 替代文本解析 (修复 desc 含空格错位);
  移除无用的 ctrl 列
- plc_config: 完整重写 — 查看/编辑双模式, 左右分栏布局,
  信号面板+逻辑图查看(当前/备份)+编辑器基础选择
- plc_debug: 切换按钮后同步更新 SignalStore (修复 LED 延迟刷新)
- param: 内联输入框 + 序号列

修复:
- 删除信号后 DOM 行立即消失 (之前需切页才能看到)
- WS 推送到达时增量刷新, 不影响正在编辑的输入框
- 批量删除时每项都调用 SignalStore.remove
This commit is contained in:
ypc 2026-07-14 18:22:17 +08:00
parent ccb26007e2
commit e27b01943c
6 changed files with 543 additions and 2444 deletions

View File

@ -208,15 +208,15 @@ a { color: var(--accent); text-decoration: none; }
Tables
============================================================ */
.table-wrap { overflow-x: auto; border-radius: var(--radius-md); border: 1px solid var(--border); }
.signal-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.signal-table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
.signal-table th {
background: var(--bg-surface); padding: 10px 14px;
background: var(--bg-surface); padding: 10px 8px;
text-align: left; font-weight: 600; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.5px;
color: var(--text-secondary); border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 1;
position: sticky; top: 0; z-index: 1; white-space: nowrap;
}
.signal-table td { padding: 9px 14px; border-bottom: 1px solid rgba(30,48,64,0.6); }
.signal-table td { padding: 9px 8px; border-bottom: 1px solid rgba(30,48,64,0.6); overflow: hidden; text-overflow: ellipsis; }
.signal-table tbody tr { transition: background var(--transition); }
.signal-table tbody tr:hover { background: var(--bg-hover); }
.signal-table .empty-row td { text-align: center; color: var(--text-muted); padding: 40px; }
@ -537,14 +537,48 @@ a { color: var(--accent); text-decoration: none; }
#plc-zoom-val { font-family: var(--font-mono); font-weight: 600; color: var(--text-primary); }
/* ============================================================
PLC Config (stub - preserved for backward compat)
PLC Config 信号面板 / 逻辑图查看器 / 编辑器
============================================================ */
.plc-canvas-wrap {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); height: calc(100vh - 200px);
display: flex; align-items: center; justify-content: center;
color: var(--text-muted); font-size: 14px;
.plc-sig-row {
display: flex; align-items: center; gap: 6px;
padding: 4px 6px; margin: 2px 0; border-radius: 4px;
cursor: pointer; transition: background var(--transition);
}
.plc-sig-row:hover { background: var(--bg-hover); }
.plc-modal-row {
padding: 8px 10px; margin: 2px 0; border-radius: 4px;
cursor: pointer; transition: background var(--transition);
border: 1px solid transparent;
}
.plc-modal-row:hover { background: var(--bg-hover); border-color: var(--border); }
.plc-modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6); display: flex;
align-items: center; justify-content: center;
z-index: 99990;
}
.plc-modal-box {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 24px;
box-shadow: var(--shadow); color: var(--text-primary);
max-height: 80vh; overflow-y: auto;
}
.plc-modal-box h3 { margin-bottom: 12px; font-size: 16px; font-weight: 600; }
.plc-cfg-tabs { display: flex; gap: 0; margin-bottom: 12px; }
.plc-cfg-tab {
padding: 8px 16px; background: var(--bg-surface);
border: 1px solid var(--border); border-right: none;
cursor: pointer; font-size: 13px; color: var(--text-secondary);
transition: all var(--transition);
}
.plc-cfg-tab:first-child { border-radius: var(--radius-md) 0 0 var(--radius-md); }
.plc-cfg-tab:last-child { border-radius: 0 var(--radius-md) var(--radius-md) 0; border-right: 1px solid var(--border); }
.plc-cfg-tab.active { background: var(--accent); color: var(--bg-deep); border-color: var(--accent); font-weight: 600; }
.plc-sig-search { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.plc-sig-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.plc-sig-table th { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); color: var(--text-secondary); font-weight: 600; }
.plc-sig-table td { padding: 4px 8px; border-bottom: 1px solid rgba(255,255,255,0.04); }
.plc-sig-table tr:hover td { background: var(--bg-hover); }
/* ============================================================
Login Page

View File

@ -1,58 +1,83 @@
/**
* @file param.js
* @brief 定值管理页面 多定值区展开
* @brief 定值管理页面 多定值区展开 + 内联输入框
*/
Router.register('#param', function(c) {
var signals = SignalStore.get('param');
var rows = '';
if (signals.length === 0)
{
rows = '<tr class="empty-row"><td colspan="5">' + I18n.t('signal.no_data') + '</td></tr>';
rows = '<tr class="empty-row"><td colspan="6">' + I18n.t('signal.no_data') + '</td></tr>';
}
else
{
signals.forEach(function(s) {
signals.forEach(function(s, idx) {
var zones = s.setting_zone_list || [];
var zoneHtml = '';
zones.forEach(function(z) {
zoneHtml += '<div style="display:flex;gap:8px;align-items:center;padding:2px 0;">'
+ '<span style="color:var(--text-muted);font-family:var(--font-mono);width:40px;">#' + esc(z.id) + '</span>'
+ '<span style="font-family:var(--font-mono);color:var(--accent);">' + esc(z.val) + '</span>'
+ '<button class="btn btn-sm" onclick="ParamActions.set(\'' + esc(s.saddr) + '\',\'' + esc(z.id) + '\')">' + I18n.t('signal.set') + '</button>'
zoneHtml += '<div style="display:flex;gap:6px;align-items:center;padding:2px 0;">'
+ '<span style="color:var(--text-muted);font-family:var(--font-mono);width:28px;font-size:11px">#' + esc(z.id) + '</span>'
+ '<span class="param-zone-val" data-zone="' + esc(z.id) + '" style="font-family:var(--font-mono);color:var(--accent);font-size:12px">' + esc(z.val) + '</span>'
+ '<input class="param-zone-input" data-action="param-set" data-saddr="' + esc(s.saddr) + '" data-zone="' + esc(z.id) + '" placeholder="new" style="width:50px;padding:1px 4px;font-size:11px;border:1px solid var(--border);border-radius:3px;background:var(--bg-input);color:var(--text-primary);font-family:var(--font-mono)">'
+ '<button class="btn btn-sm" data-action="param-set-go" data-saddr="' + esc(s.saddr) + '" data-zone="' + esc(z.id) + '" style="padding:1px 5px;font-size:11px">' + I18n.t('signal.set') + '</button>'
+ '</div>';
});
rows += '<tr>'
rows += '<tr data-saddr="' + esc(s.saddr) + '">'
+ '<td class="cell-mono" style="color:var(--text-muted)">' + (idx + 1) + '</td>'
+ '<td class="cell-mono">' + esc(s.saddr) + '</td>'
+ '<td class="cell-desc">' + esc(s.desc || '') + '</td>'
+ '<td class="cell-mono">' + esc(s.type || '?') + '</td>'
+ '<td>' + (zoneHtml || '\u2014') + '</td>'
+ '<td><button class="btn btn-danger btn-sm" onclick="SignalActions.del(\'param\',\'' + esc(s.saddr) + '\')">' + Icon.get('trash') + '</button></td>'
+ '<td><button class="btn btn-danger btn-sm" data-action="del" data-saddr="' + esc(s.saddr) + '">' + Icon.get('trash') + '</button></td>'
+ '</tr>';
});
}
c.innerHTML = '<div class="card">'
+ '<div class="card-header"><span>PARAM \u2014 ' + signals.length + ' signals</span>'
+ '<div class="card-header"><span>PARAM ' + signals.length + ' signals</span>'
+ '<button class="btn btn-primary btn-sm" onclick="SignalActions.add(\'param\')">' + Icon.get('plus') + ' ' + I18n.t('signal.add') + '</button>'
+ '</div>'
+ '<div class="table-wrap"><table class="signal-table">'
+ '<thead><tr><th>' + I18n.t('signal.saddr') + '</th><th>' + I18n.t('signal.desc') + '</th><th>' + I18n.t('signal.type') + '</th><th>' + I18n.t('signal.zone') + '</th><th>' + I18n.t('signal.action') + '</th></tr></thead>'
+ '<tbody>' + rows + '</tbody>'
+ '<thead><tr><th style="width:36px">#</th><th>' + I18n.t('signal.saddr') + '</th><th>' + I18n.t('signal.desc') + '</th><th>' + I18n.t('signal.type') + '</th><th>' + I18n.t('signal.zone') + '</th><th>' + I18n.t('signal.action') + '</th></tr></thead>'
+ '<tbody data-type="param">' + rows + '</tbody>'
+ '</table></div></div>';
});
var ParamActions = {
set: function(saddr, zoneId) {
showModal(I18n.t('signal.set_title') + ' \u2014 ' + esc(saddr) + ' ' + I18n.t('signal.zone') + ' ' + zoneId,
'<div class="form-group"><label class="form-label">' + I18n.t('signal.new_val') + '</label><input class="form-input" id="mf-val" placeholder="Value"></div>',
function(ov) {
var val = getModalVal(ov, '#mf-val');
WsClient.send({ saddr: saddr, signal_type: 'param', curd: 'set', signal_data: val, setting_zone: zoneId });
toast(I18n.t('toast.set_sent') + ': ' + saddr + '#' + zoneId + ' = ' + val, 'success');
ov.remove();
}, I18n.t('signal.set'));
/* Param event delegation */
document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action="param-set-go"]');
if (!btn) return;
var saddr = btn.getAttribute('data-saddr');
var zone = btn.getAttribute('data-zone') || '0';
var row = btn.closest('tr');
var inp = row ? row.querySelector('[data-action="param-set"][data-zone="' + zone + '"]') : null;
var val = inp ? inp.value.trim() : '';
if (!val) { toast(I18n.t('toast.val_required'), 'error'); return; }
WsClient.send({ saddr: saddr, signal_type: 'param', curd: 'set', signal_data: val, setting_zone: zone });
/* 立即更新本地 SignalStore + DOM */
var arr2 = SignalStore.get('param');
for (var k2 = 0; k2 < arr2.length; k2++) {
if (arr2[k2].saddr === saddr && arr2[k2].setting_zone_list) {
for (var z = 0; z < arr2[k2].setting_zone_list.length; z++) {
if (arr2[k2].setting_zone_list[z].id === zone) { arr2[k2].setting_zone_list[z].val = val; break; }
}
};
break;
}
}
var zc = row.querySelector('.param-zone-val[data-zone="' + zone + '"]');
if (zc) { zc.textContent = val; zc.style.transition = 'color 0.3s'; zc.style.color = '#2ed573'; setTimeout(function(){zc.style.color='';}, 800); }
inp.value = '';
});
/* Enter key on param inline input */
document.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && e.target.classList.contains('param-zone-input')) {
e.preventDefault();
var inp = e.target;
var row = inp.closest('tr');
var zone = inp.getAttribute('data-zone');
var btn = row ? row.querySelector('[data-action="param-set-go"][data-zone="' + zone + '"]') : null;
if (btn) btn.click();
}
});

File diff suppressed because one or more lines are too long

View File

@ -192,7 +192,13 @@ var PlcDebugPage = (function() {
if (!b.saddr) { showPicker(function(sa, de) { b.saddr = sa; b.desc = de; var f = findSig(sa); if (f) b.val = f.val; renderAll(); }); return; }
var nv = parseFloat(b.val) > 0 ? 0 : 1;
WsClient.send({ curd: 'set', signal_type: 'out', saddr: b.saddr, signal_data: String(nv), setting_zone: '0' });
b.val = nv; renderAll();
b.val = nv;
/* 同步写入 SignalStore确保 refreshData() 不覆盖新值 */
var outs = SignalStore.get('out');
for (var i = 0; i < outs.length; i++) {
if (outs[i].saddr === b.saddr) { outs[i].val = String(nv); break; }
}
renderAll();
},
_toggleLed: function(i, v) { ledChecked[i] = v; updateDelBtn(); },
_toggleBtnChk: function(j, v) { btnChecked[j] = v; updateDelBtn(); },
@ -210,6 +216,7 @@ var PlcDebugPage = (function() {
sigChecked = {}; renderAll();
},
_refresh: function() { refreshData(); incrRefresh(); },
incrRefresh: function() { refreshData(); incrRefresh(); },
_delChecked: delChecked,
};
})();

View File

@ -104,41 +104,60 @@ function th(s) {
/**
* @brief 渲染信号管理页面通用模板
*/
function renderSignalPage(type, headers, extraCols, actionsForRow) {
function renderSignalPage(type, showCheckbox, extraHeader, extraCell) {
var signals = SignalStore.get(type);
var rows = '';
var colCount = (showCheckbox ? 1 : 0) + 5 + (extraHeader ? 1 : 0) + 1;
if (signals.length === 0)
{
rows = '<tr class="empty-row"><td colspan="' + headers.length + '">' + I18n.t('signal.no_data') + '</td></tr>';
rows = '<tr class="empty-row"><td colspan="' + colCount + '">' + I18n.t('signal.no_data') + '</td></tr>';
}
else
{
signals.forEach(function(s) {
var xtra = extraCols ? extraCols(s) : '';
var acts = actionsForRow ? actionsForRow(s) : '';
rows += '<tr>'
signals.forEach(function(s, idx) {
var xtra = extraCell ? extraCell(s) : '';
var acts = (type === 'out' || type === 'yk' || type === 'ao')
? '<input class="sig-set-input" data-action="set-inline" data-saddr="' + esc(s.saddr) + '" placeholder="value" style="width:55px;padding:2px 4px;font-size:12px;border:1px solid var(--border);border-radius:3px;background:var(--bg-input);color:var(--text-primary);font-family:var(--font-mono)">'
+ '<button class="btn btn-sm" data-action="set-go" data-saddr="' + esc(s.saddr) + '" style="padding:2px 6px;font-size:12px">' + I18n.t('signal.set') + '</button>'
+ '<button class="btn btn-danger btn-sm" data-action="del" data-saddr="' + esc(s.saddr) + '">' + Icon.get('trash') + '</button>'
: '<button class="btn btn-danger btn-sm" data-action="del" data-saddr="' + esc(s.saddr) + '">' + Icon.get('trash') + '</button>';
rows += '<tr data-saddr="' + esc(s.saddr) + '">'
+ (showCheckbox ? '<td><input type="checkbox"></td>' : '')
+ '<td class="cell-mono" style="color:var(--text-muted)">' + (idx + 1) + '</td>'
+ '<td class="cell-mono">' + esc(s.saddr) + '</td>'
+ '<td class="cell-desc">' + esc(s.desc || '') + '</td>'
+ xtra
+ '<td class="cell-mono">' + esc(s.type || '?') + '</td>'
+ '<td class="cell-val">' + fmtVal(s.val) + '</td>'
+ '<td>' + acts + '</td>'
+ xtra
+ '<td class="cell-acts" data-saddr="' + esc(s.saddr) + '">' + acts + '</td>'
+ '</tr>';
});
}
var headerHtml = '';
if (showCheckbox) headerHtml += '<th style="width:36px"><input type="checkbox" id="cb-all-' + type + '" onclick="var v=this.checked;document.querySelectorAll(\'#tbody-' + type + ' input[type=checkbox]\').forEach(function(c){c.checked=v;})"></th>';
headerHtml += '<th style="width:36px">#</th>';
headerHtml += '<th>' + I18n.t('signal.saddr') + '</th>';
headerHtml += '<th>' + I18n.t('signal.desc') + '</th>';
headerHtml += '<th>' + I18n.t('signal.type') + '</th>';
headerHtml += '<th>' + I18n.t('signal.val') + '</th>';
if (extraHeader) headerHtml += '<th>' + extraHeader + '</th>';
headerHtml += '<th>' + I18n.t('signal.action') + '</th>';
return '<div class="card">'
+ '<div class="card-header"><span>' + type.toUpperCase() + ' \u2014 ' + signals.length + ' signals</span>'
+ '<div class="card-header"><span>' + type.toUpperCase() + ' ' + signals.length + ' signals</span>'
+ '<div class="btn-group">'
+ '<button class="btn btn-primary btn-sm" onclick="SignalActions.add(\'' + type + '\')">' + Icon.get('plus') + ' ' + I18n.t('signal.add') + '</button>'
+ '<button class="btn btn-danger btn-sm" onclick="SignalActions.batchDel(\'' + type + '\')">' + Icon.get('trash') + ' ' + I18n.t('signal.delete_sel') + '</button>'
+ '</div></div>'
+ '<div class="table-wrap"><table class="signal-table">'
+ '<thead><tr>' + headers.map(function(h) { return th(h); }).join('') + '<th>' + I18n.t('signal.val') + '</th><th>' + I18n.t('signal.action') + '</th></tr></thead>'
+ '<tbody id="tbody-' + type + '">' + rows + '</tbody>'
+ '<thead><tr>' + headerHtml + '</tr></thead>'
+ '<tbody id="tbody-' + type + '" data-type="' + type + '">' + rows + '</tbody>'
+ '</table></div></div>';
}
/* ==================================================================
* 信号 CRUD 操作
* ================================================================== */
@ -147,33 +166,21 @@ var SignalActions = {
var body = '';
body += '<div class="form-group"><label class="form-label">' + I18n.t('signal.saddr') + '</label><input class="form-input" id="mf-saddr" placeholder="e.g. plc.st.out.0"></div>';
body += '<div class="form-group"><label class="form-label">' + I18n.t('signal.desc') + '</label><input class="form-input" id="mf-desc" placeholder="Signal description"></div>';
if (type === 'in')
{
body += '<div class="form-group"><label class="form-label">' + I18n.t('signal.link') + '</label><input class="form-input" id="mf-link" placeholder="e.g. self_ptl.st.0"></div>';
}
else
{
body += '<div class="form-group"><label class="form-label">' + I18n.t('signal.type') + '</label>'
+ '<select class="form-select" id="mf-dtype">'
+ '<option value="U8">U8</option><option value="U16">U16</option>'
+ '<option value="U32">U32</option><option value="F32">F32</option>'
+ '<option value="STR">STR</option></select></div>';
}
if (type === 'yk' || type === 'ao' || type === 'param')
{
if (type === 'yk' || type === 'ao' || type === 'param') {
body += '<div class="form-group"><label class="form-label">' + I18n.t('signal.ctrl') + '</label>'
+ '<select class="form-select" id="mf-ctrl">'
+ '<option value="0">' + I18n.t('signal.ctrl_direct') + '</option>'
+ '<option value="1">' + I18n.t('signal.ctrl_sbo') + '</option></select></div>';
}
showModal(I18n.t('signal.add_title') + ' (' + type.toUpperCase() + ')', body, function(ov) {
var saddr = getModalVal(ov, '#mf-saddr');
if (!saddr) { toast(I18n.t('toast.saddr_required'), 'error'); return; }
var msg = { saddr: saddr, signal_type: type, curd: 'add', signal_data: '', setting_zone: '0' };
if (type === 'in') { msg.link_saddr = getModalVal(ov, '#mf-link'); }
WsClient.send(msg);
toast(I18n.t('toast.add_sent') + ': ' + saddr, 'success');
ov.remove();
@ -186,7 +193,6 @@ var SignalActions = {
function(ov) {
WsClient.send({ saddr: saddr, signal_type: type, curd: 'del', setting_zone: '0' });
SignalStore.remove(type, saddr);
Router.dispatch(Router.currentHash);
toast(I18n.t('toast.del_sent') + ': ' + saddr, 'info');
ov.remove();
}, Icon.get('trash') + ' ' + I18n.t('signal.delete'));
@ -199,8 +205,12 @@ var SignalActions = {
'<p>' + I18n.t('signal.confirm_batch_msg') + ' ' + checked.length + ' ' + I18n.t('signal.confirm_batch_del') + '?</p>',
function(ov) {
checked.forEach(function(cb) {
var saddr = cb.closest('tr').dataset.saddr;
var tr = cb.closest('tr');
var saddr = tr ? tr.getAttribute('data-saddr') : '';
if (saddr) {
WsClient.send({ saddr: saddr, signal_type: type, curd: 'del', setting_zone: '0' });
SignalStore.remove(type, saddr);
}
});
toast(checked.length + ' ' + I18n.t('toast.batch_del_sent'), 'info');
ov.remove();
@ -218,3 +228,45 @@ var SignalActions = {
}, I18n.t('signal.set'));
}
};
/* ---- Event delegation: data-action buttons ---- */
document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var action = btn.getAttribute('data-action');
var saddr = btn.getAttribute('data-saddr');
var type = btn.getAttribute('data-page-type');
if (!type) {
var tbody = e.target.closest('tbody[data-type]');
if (tbody) type = tbody.getAttribute('data-type');
}
if (!type) return;
if (action === 'set-go') {
e.preventDefault();
var row = btn.closest('tr');
var inp = row ? row.querySelector('.sig-set-input') : null;
var val = inp ? inp.value.trim() : '';
if (!val) { toast(I18n.t('toast.val_required'), 'error'); return; }
WsClient.send({ saddr: saddr, signal_type: type, curd: 'set', signal_data: val, setting_zone: '0' });
/* 立即更新本地 SignalStore 和 DOM推送到达前先显示 */
var arr = SignalStore.get(type);
for (var k = 0; k < arr.length; k++) { if (arr[k].saddr === saddr) { arr[k].val = val; break; } }
/* 直接更新当前行的值单元格 */
var vc = row.querySelector('.cell-val');
if (vc) { vc.textContent = val; vc.classList.add('changed'); setTimeout(function(){vc.classList.remove('changed');},600); }
inp.value = '';
} else if (action === 'del') {
e.preventDefault();
SignalActions.del(type, saddr);
}
});
/* Enter key on inline input triggers set */
document.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && e.target.classList.contains('sig-set-input')) {
e.preventDefault();
var inp = e.target;
var row = inp.closest('tr');
var btn = row ? row.querySelector('[data-action="set-go"]') : null;
if (btn) btn.click();
}
});

View File

@ -43,6 +43,10 @@ var WsClient = {
var self = WsClient;
self._setStatus('disconnected');
self.ws = null;
/* 清空纯内存信号缓存 (后端会话已销毁, 前端不应保留旧数据) */
if (typeof SignalStore !== 'undefined' && SignalStore.clear) {
SignalStore.clear();
}
self._schedule();
};