273 lines
15 KiB
JavaScript
273 lines
15 KiB
JavaScript
/**
|
||
* @file ui.js
|
||
* @brief 通用 UI 组件 — Toast, Modal, Table, Icon, Utilities
|
||
*/
|
||
|
||
|
||
/* ==================================================================
|
||
* SVG 内联图标 (Lucide 风格, 18×18)
|
||
* ================================================================== */
|
||
var Icon = {
|
||
_map: {
|
||
monitor: '<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>',
|
||
dashboard: '<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>',
|
||
signal: '<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>',
|
||
link: '<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>',
|
||
zap: '<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
|
||
sliders: '<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>',
|
||
settings: '<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>',
|
||
database: '<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>',
|
||
bug: '<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="12" rx="3"/><path d="M8 5V3h8v2M12 12v4M8 12h.01M16 12h.01"/></svg>',
|
||
cpu: '<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3"/></svg>',
|
||
activity: '<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>',
|
||
terminal: '<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>',
|
||
check: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>',
|
||
plus: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
|
||
trash: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
|
||
edit: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
|
||
alert: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ff4757" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>',
|
||
},
|
||
|
||
get: function(name, cls) {
|
||
return (this._map[name] || '').replace('class="icon-svg"', 'class="' + (cls || 'icon-svg') + '"');
|
||
}
|
||
};
|
||
|
||
/* ==================================================================
|
||
* Toast 通知
|
||
* ================================================================== */
|
||
function toast(msg, type) {
|
||
type = type || 'info';
|
||
var el = document.createElement('div');
|
||
el.className = 'toast toast-' + type;
|
||
el.textContent = msg;
|
||
document.getElementById('toast-container').appendChild(el);
|
||
setTimeout(function() { el.style.opacity = '0'; el.style.transition = 'opacity 0.3s'; }, 2500);
|
||
setTimeout(function() { el.remove(); }, 2800);
|
||
}
|
||
|
||
/* ==================================================================
|
||
* Modal 模态框
|
||
* ================================================================== */
|
||
function showModal(title, bodyHtml, onOk, okText) {
|
||
okText = okText || I18n.t('toast.confirm');
|
||
|
||
var overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
overlay.innerHTML = '<div class="modal-box">'
|
||
+ '<div class="modal-title">' + title + '</div>'
|
||
+ '<div class="modal-body">' + bodyHtml + '</div>'
|
||
+ '<div class="modal-actions">'
|
||
+ '<button class="btn btn-cancel">' + I18n.t('toast.cancel') + '</button>'
|
||
+ '<button class="btn btn-primary btn-ok">' + okText + '</button>'
|
||
+ '</div></div>';
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
overlay.querySelector('.btn-cancel').onclick = function() { overlay.remove(); };
|
||
overlay.querySelector('.btn-ok').onclick = function() { onOk(overlay); };
|
||
overlay.addEventListener('click', function(e) {
|
||
if (e.target === overlay) { overlay.remove(); }
|
||
});
|
||
|
||
return overlay;
|
||
}
|
||
|
||
function getModalVal(overlay, selector) {
|
||
var el = overlay.querySelector(selector);
|
||
return el ? el.value : '';
|
||
}
|
||
|
||
/* ==================================================================
|
||
* Table 渲染工具
|
||
* ================================================================== */
|
||
function esc(s) {
|
||
return String(s || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function fmtVal(v) {
|
||
return (v == null || v === '') ? '\u2014' : esc(String(v));
|
||
}
|
||
|
||
function td(s, cls) {
|
||
return '<td class="' + (cls || '') + '">' + s + '</td>';
|
||
}
|
||
|
||
function th(s) {
|
||
return '<th>' + s + '</th>';
|
||
}
|
||
|
||
/**
|
||
* @brief 渲染信号管理页面通用模板
|
||
*/
|
||
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="' + colCount + '">' + I18n.t('signal.no_data') + '</td></tr>';
|
||
}
|
||
else
|
||
{
|
||
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>'
|
||
+ '<td class="cell-mono">' + esc(s.type || '?') + '</td>'
|
||
+ '<td class="cell-val">' + fmtVal(s.val) + '</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() + ' — ' + 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>' + headerHtml + '</tr></thead>'
|
||
+ '<tbody id="tbody-' + type + '" data-type="' + type + '">' + rows + '</tbody>'
|
||
+ '</table></div></div>';
|
||
}
|
||
|
||
|
||
/* ==================================================================
|
||
* 信号 CRUD 操作
|
||
* ================================================================== */
|
||
var SignalActions = {
|
||
add: function(type) {
|
||
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>';
|
||
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') {
|
||
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' };
|
||
WsClient.send(msg);
|
||
toast(I18n.t('toast.add_sent') + ': ' + saddr, 'success');
|
||
ov.remove();
|
||
});
|
||
},
|
||
|
||
del: function(type, saddr) {
|
||
showModal(I18n.t('signal.confirm_del'),
|
||
'<p>' + I18n.t('signal.confirm_del_msg') + ' <strong>' + esc(saddr) + '</strong>?</p>',
|
||
function(ov) {
|
||
WsClient.send({ saddr: saddr, signal_type: type, curd: 'del', setting_zone: '0' });
|
||
SignalStore.remove(type, saddr);
|
||
toast(I18n.t('toast.del_sent') + ': ' + saddr, 'info');
|
||
ov.remove();
|
||
}, Icon.get('trash') + ' ' + I18n.t('signal.delete'));
|
||
},
|
||
|
||
batchDel: function(type) {
|
||
var checked = [].slice.call(document.querySelectorAll('#tbody-' + type + ' input[type=checkbox]:checked'));
|
||
if (checked.length === 0) { toast(I18n.t('toast.no_selected'), 'error'); return; }
|
||
showModal(I18n.t('signal.confirm_batch_del'),
|
||
'<p>' + I18n.t('signal.confirm_batch_msg') + ' ' + checked.length + ' ' + I18n.t('signal.confirm_batch_del') + '?</p>',
|
||
function(ov) {
|
||
checked.forEach(function(cb) {
|
||
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();
|
||
}, Icon.get('trash') + ' ' + I18n.t('signal.delete') + ' ' + checked.length);
|
||
},
|
||
|
||
set: function(type, saddr) {
|
||
showModal(I18n.t('signal.set_title') + ' \u2014 ' + esc(saddr),
|
||
'<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: type, curd: 'set', signal_data: val, setting_zone: '0' });
|
||
toast(I18n.t('toast.set_sent') + ': ' + saddr + ' = ' + val, 'success');
|
||
ov.remove();
|
||
}, 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();
|
||
}
|
||
});
|