webserver: 前端命令面板 + 后端 PLC Config 支持

后端:
- ws_method.cpp cmd 处理增强:datacenter 命令自动调用 dc_save_out_signals_xml() 导出 XML
- ws_method.cpp cmd 处理增强:自动构建 dc_data JSON 返回信号元数据
- web_server.cpp: EV_STOP 优雅退出支持

前端:
- terminal.js: 命令面板模式,按钮显示命令名+描述,按模块分组
- terminal.js: 点击填充到输入框,可继续输入子命令参数
- index.html: 对接新 terminal 函数(renderCmdPanels/termOnBinary)
- style.css: 命令面板按钮、输入框样式
This commit is contained in:
ypc 2026-07-14 11:01:33 +08:00
parent 47d9c6273d
commit 06fc257696
18 changed files with 5281 additions and 6 deletions

576
config/web/css/style.css Normal file
View File

@ -0,0 +1,576 @@
/**
* @file style.css
* @brief RTU Web Manager Dark Industrial Dashboard Stylesheet
*/
/* ============================================================
Design Tokens (CSS Variables)
============================================================ */
:root {
--bg-deep: #0a0f16;
--bg-card: #111922;
--bg-surface: #192230;
--bg-hover: #1e2a3a;
--bg-input: #0d1520;
--border: #1e3040;
--border-accent: #1a4a60;
--text-primary: #e4ecf2;
--text-secondary: #8899aa;
--text-muted: #556677;
--accent: #00d4aa;
--accent-glow: rgba(0,212,170,0.15);
--accent2: #00a8e8;
--danger: #ff4757;
--warning: #ffa502;
--success: #2ed573;
--online: #2ed573;
--offline: #ff4757;
--nav-width: 240px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow: 0 4px 24px rgba(0,0,0,0.4);
--transition: 180ms ease-out;
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
}
/* ============================================================
Reset & Base
============================================================ */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
font-family: var(--font-ui);
font-size: 14px;
color: var(--text-primary);
background: var(--bg-deep);
display: flex;
line-height: 1.5;
}
a { color: var(--accent); text-decoration: none; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg-deep); }
::-webkit-scrollbar-thumb { background: var(--text-muted); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
/* ============================================================
Navigation Sidebar
============================================================ */
#nav-sidebar {
width: var(--nav-width); min-width: var(--nav-width);
background: var(--bg-card);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
z-index: 100; transition: transform var(--transition);
}
.nav-header {
padding: 20px 20px 16px;
border-bottom: 1px solid var(--border);
}
.nav-logo {
display: flex; align-items: center; gap: 10px;
font-size: 18px; font-weight: 700; letter-spacing: -0.3px;
}
.nav-logo svg { flex-shrink: 0; }
.nav-status {
display: flex; align-items: center; gap: 8px;
margin-top: 12px; font-size: 12px; color: var(--text-secondary);
}
.nav-status-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
transition: background var(--transition);
}
.nav-status-dot.connected { background: var(--online); box-shadow: 0 0 8px var(--online); }
.nav-status-dot.disconnected { background: var(--offline); }
.nav-status-dot.connecting { background: var(--warning); animation: pulse 1s infinite; }
@keyframes pulse { 50% { opacity: 0.4; } }
.nav-section-title {
padding: 16px 20px 6px;
font-size: 10px; text-transform: uppercase;
letter-spacing: 1.2px; color: var(--text-muted); font-weight: 600;
}
#nav-links { flex: 1; overflow-y: auto; padding: 4px 0; }
#nav-links a {
display: flex; align-items: center; gap: 10px;
padding: 9px 20px; margin: 1px 8px; border-radius: var(--radius-sm);
font-size: 13px; color: var(--text-secondary); font-weight: 500;
transition: all var(--transition); cursor: pointer; position: relative;
}
#nav-links a:hover { background: var(--bg-hover); color: var(--text-primary); }
#nav-links a.active {
background: var(--accent-glow); color: var(--accent); font-weight: 600;
}
#nav-links a.active::before {
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: 3px; height: 20px; background: var(--accent); border-radius: 0 2px 2px 0;
}
.icon-svg { width: 13px; height: 13px; flex-shrink: 0; opacity: 0.6; margin-right: 2px; }
#nav-links a.active .icon-svg { opacity: 1; }
.nav-icon { width: 18px; height: 18px; flex-shrink: 0; opacity: 0.65; }
#nav-links a.active .nav-icon { opacity: 1; color: var(--accent); }
/* ---- Language Switcher (Dropdown) ---- */
.nav-lang {
padding: 10px 20px; border-top: 1px solid var(--border);
}
.lang-select {
width: 100%; padding: 7px 10px;
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius-sm); color: var(--text-primary);
font-size: 12px; font-family: var(--font-ui); cursor: pointer;
transition: border-color var(--transition);
}
.lang-select:focus { outline: none; border-color: var(--accent); }
.nav-footer {
padding: 12px 20px; border-top: 1px solid var(--border);
font-size: 11px; color: var(--text-muted);
display: flex; justify-content: space-between;
}
/* ============================================================
Mobile Toggle
============================================================ */
#nav-toggle {
display: none; position: fixed; top: 12px; left: 12px; z-index: 200;
width: 36px; height: 36px; border: 1px solid var(--border);
background: var(--bg-card); border-radius: var(--radius-sm);
color: var(--text-primary); cursor: pointer; align-items: center; justify-content: center;
}
/* ============================================================
Main Content
============================================================ */
#main-content {
flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0;
}
#page-header {
padding: 16px 28px; border-bottom: 1px solid var(--border);
background: var(--bg-card); display: flex; align-items: center; gap: 12px;
min-height: 56px;
}
#page-title { font-size: 17px; font-weight: 700; }
#page-subtitle { font-size: 12px; color: var(--text-secondary); margin-left: 8px; }
#page-container { flex: 1; overflow-y: auto; padding: 24px 28px; }
/* ============================================================
Cards
============================================================ */
.card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 20px; margin-bottom: 16px;
transition: border-color var(--transition);
}
.card:hover { border-color: var(--border-accent); }
.card-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 16px; font-weight: 600; font-size: 15px;
}
/* ============================================================
Buttons
============================================================ */
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border: 1px solid var(--border);
border-radius: var(--radius-sm); font-size: 13px; font-weight: 500;
cursor: pointer; transition: all var(--transition);
background: var(--bg-surface); color: var(--text-primary);
font-family: var(--font-ui);
}
.btn:hover { background: var(--bg-hover); border-color: var(--text-muted); }
.btn-primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
.btn-primary:hover { background: #00e6bc; box-shadow: 0 0 16px var(--accent-glow); }
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
.btn-danger:hover { background: rgba(255,71,87,0.12); }
.btn-sm { padding: 5px 10px; font-size: 12px; }
.btn-icon { padding: 6px; line-height: 1; }
.btn-group { display: flex; gap: 8px; flex-wrap: wrap; }
/* ============================================================
Forms
============================================================ */
.form-group { margin-bottom: 14px; }
.form-label { display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; font-weight: 500; }
.form-input, .form-select {
width: 100%; padding: 9px 12px;
background: var(--bg-input); border: 1px solid var(--border);
border-radius: var(--radius-sm); color: var(--text-primary);
font-size: 13px; font-family: var(--font-ui);
transition: border-color var(--transition);
}
.form-input:focus, .form-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-glow); }
.form-input::placeholder { color: var(--text-muted); }
/* ============================================================
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 th {
background: var(--bg-surface); padding: 10px 14px;
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;
}
.signal-table td { padding: 9px 14px; border-bottom: 1px solid rgba(30,48,64,0.6); }
.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; }
.cell-mono { font-family: var(--font-mono); font-size: 12px; color: var(--accent); }
.cell-desc { color: var(--text-secondary); font-size: 12px; }
.cell-val { font-family: var(--font-mono); font-weight: 600; }
.cell-val.changed { animation: flash-val 0.6s ease-out; }
@keyframes flash-val { 0% { color: var(--accent); } 100% { color: inherit; } }
/* ============================================================
Dashboard Cards
============================================================ */
.dash-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
.dash-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 20px; cursor: pointer;
transition: all var(--transition);
}
.dash-card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: var(--shadow); }
.dash-card-icon { margin-bottom: 10px; }
.dash-card-count { font-size: 32px; font-weight: 700; color: var(--accent); }
.dash-card-label { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
/* ============================================================
Modal
============================================================ */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center;
z-index: 500; animation: fadeIn 0.15s ease-out;
}
.modal-box {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 24px;
width: 90%; max-width: 480px; max-height: 80vh; overflow-y: auto;
box-shadow: var(--shadow); animation: slideUp 0.2s ease-out;
}
.modal-title { font-size: 16px; font-weight: 700; margin-bottom: 20px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(16px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
/* ============================================================
Toast
============================================================ */
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 1000; display: flex; flex-direction: column; gap: 8px; }
.toast {
padding: 12px 20px; border-radius: var(--radius-md); font-size: 13px;
font-weight: 500; animation: slideIn 0.25s ease-out;
box-shadow: var(--shadow); max-width: 360px;
}
.toast-success { background: #0d3320; border: 1px solid var(--success); color: var(--success); }
.toast-error { background: #330d15; border: 1px solid var(--danger); color: var(--danger); }
.toast-info { background: #0d2033; border: 1px solid var(--accent2); color: var(--accent2); }
@keyframes slideIn { from { transform: translateX(100px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
/* ============================================================
Terminal 命令面板 + 输出窗口 + 输入框
============================================================ */
.terminal-wrap {
background: #0a0d12; border: 1px solid var(--border);
border-radius: var(--radius-md); overflow: hidden; display: flex; flex-direction: column;
height: calc(100vh - 180px); min-height: 400px;
}
.terminal-output {
flex: 1; overflow-y: auto; padding: 16px;
font-family: var(--font-mono); font-size: 13px; line-height: 1.6;
color: #a0d0a0; white-space: pre-wrap; word-break: break-all;
}
/* 输入框行 */
.terminal-input-row {
display: flex; border-top: 1px solid var(--border); background: var(--bg-input);
}
.terminal-prompt {
padding: 10px 12px; color: var(--accent); font-family: var(--font-mono);
font-size: 13px; user-select: none; white-space: nowrap;
}
.terminal-input {
flex: 1; background: transparent; border: none; color: #c0e0c0;
font-family: var(--font-mono); font-size: 13px; padding: 10px 8px; outline: none;
}
/* 命令面板 */
.cmd-panels {
padding: 12px 16px; border-bottom: 1px solid var(--border);
overflow-y: auto; max-height: 260px;
}
.cmd-group {
margin-bottom: 10px;
}
.cmd-group-title {
font-size: 12px; color: var(--text-muted); font-weight: 600;
margin-bottom: 6px; padding-left: 2px;
}
.cmd-group-btns {
display: flex; flex-wrap: wrap; gap: 6px;
}
.cmd-btn {
padding: 6px 14px; background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius-sm); cursor: pointer; transition: all var(--transition);
text-align: left; line-height: 1.4;
}
.cmd-btn:hover {
border-color: var(--accent); background: rgba(0,173,181,0.08);
}
.cmd-btn-name {
display: block; font-family: var(--font-mono); font-size: 12px;
color: var(--text-primary); font-weight: 600;
}
.cmd-btn-desc {
display: block; font-family: var(--font-sans); font-size: 10px;
color: var(--text-muted); margin-top: 2px;
}
/* ============================================================
Datacenter
============================================================ */
.dc-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.dc-tab-bar { display: flex; gap: 4px; }
.dc-tab {
padding: 6px 16px; border-radius: 6px; font-size: 12px; font-family: var(--font-mono);
background: var(--bg-surface); border: 1px solid var(--border);
color: var(--text-secondary); cursor: pointer;
}
.dc-tab:hover { border-color: var(--accent); }
.dc-tab.active { background: var(--accent); color: var(--bg-deep); border-color: var(--accent); }
.dc-search {
padding: 6px 12px; background: var(--bg-input); border: 1px solid var(--border);
border-radius: 6px; font-size: 13px; color: var(--text-primary); width: 220px;
}
.dc-search:focus { outline: none; border-color: var(--accent); }
.dc-count { font-size: 12px; color: var(--text-muted); margin-left: auto; white-space: nowrap; }
.dc-sel-bar {
display: flex; align-items: center; gap: 12px;
background: rgba(0,212,170,0.08); border: 1px solid var(--accent);
border-radius: var(--radius-md); padding: 8px 16px; margin-bottom: 12px;
}
.dc-sel-label { font-size: 13px; font-weight: 600; color: var(--accent); }
/* ============================================================
PLC Debug
============================================================ */
.plc-debug-grid { display: grid; grid-template-columns: 1fr 1.2fr 1fr; gap: 16px; }
.plc-col { min-width: 0; }
.plc-sig-col { border-left: 1px solid var(--border); border-right: 1px solid var(--border); padding: 0 8px; }
.plc-sig-list { max-height: 65vh; overflow-y: auto; }
.plc-led {
width: 28px; height: 28px; border-radius: 50%; background: #3a3a3a;
border: 2px solid #555; cursor: pointer; flex-shrink: 0;
transition: all 0.3s; box-shadow: 0 0 4px rgba(0,0,0,0.4);
}
.plc-led-on { background: #0f0; border-color: #4f4; box-shadow: 0 0 12px rgba(0,255,0,0.6); }
.plc-btn-icon {
width: 32px; height: 32px; border-radius: 6px; background: var(--bg-surface);
border: 1px solid var(--border); cursor: pointer; flex-shrink: 0;
font-size: 16px; color: var(--text-secondary); transition: all 0.2s;
}
.plc-btn-icon:hover { border-color: var(--accent); color: var(--accent); background: rgba(0,212,170,0.1); }
.plc-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,0.04);
}
.plc-item:hover { background: rgba(255,255,255,0.02); }
.plc-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.plc-saddr { font-family: var(--font-mono); font-size: 12px; color: var(--text-primary); word-break: break-all; }
.plc-arrow-row { display: flex; gap: 4px; margin-left: auto; }
.plc-pick-row {
padding: 10px 12px; cursor: pointer; border-bottom: 1px solid rgba(255,255,255,0.05);
transition: background 0.15s;
}
.plc-pick-row:hover { background: rgba(0,212,170,0.06); }
@media (max-width: 768px) { .plc-debug-grid { grid-template-columns: 1fr; } .plc-sig-col { border: none; padding: 0; } }
/* ============================================================
Monitor
============================================================ */
.monitor-stats { display: flex; gap: 16px; margin-bottom: 16px; }
.monitor-stat {
background: var(--bg-surface); padding: 12px 20px; border-radius: var(--radius-md);
border: 1px solid var(--border); font-size: 12px;
}
.monitor-stat-val { font-size: 24px; font-weight: 700; color: var(--accent); }
.monitor-list {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); overflow-y: auto;
max-height: calc(100vh - 220px); font-family: var(--font-mono); font-size: 12px;
}
.monitor-item { padding: 6px 16px; border-bottom: 1px solid rgba(30,48,64,0.4); display: flex; gap: 10px; }
.monitor-dir { width: 20px; flex-shrink: 0; font-weight: 700; }
.monitor-dir.up { color: var(--accent2); }
.monitor-dir.down { color: var(--accent); }
.monitor-ts { color: var(--text-muted); flex-shrink: 0; width: 80px; }
.monitor-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); }
.monitor-msg:hover { color: var(--text-primary); white-space: pre-wrap; }
/* ============================================================
PLC
============================================================ */
/* ---- PLC Debug 三列布局 ---- */
.plc-debug-grid {
display: grid;
grid-template-columns: 1fr 1.2fr 1fr;
gap: 12px;
height: calc(100vh - 200px);
min-height: 500px;
}
.plc-col {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow-y: auto;
padding: 0;
}
.plc-sig-col {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow-y: auto;
padding: 0;
display: flex; flex-direction: column;
}
.plc-col-title {
padding: 12px 14px 8px;
font-size: 13px; font-weight: 700;
color: var(--text-primary);
border-bottom: 1px solid var(--border);
background: var(--bg-surface);
position: sticky; top: 0; z-index: 1;
}
.plc-empty { text-align: center; color: var(--text-muted); padding: 30px 10px; font-size: 12px; }
.plc-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-bottom: 1px solid rgba(30,48,64,0.4);
transition: background 0.15s;
}
.plc-item:hover { background: var(--bg-hover); }
.plc-cb { flex-shrink: 0; accent-color: var(--accent); }
.plc-debug-led {
display: inline-block; width: 18px; height: 18px; border-radius: 50%;
background: #3a3e4a; cursor: pointer; flex-shrink: 0;
transition: all 0.2s;
}
.plc-debug-led-on { background: #2ed573; box-shadow: 0 0 12px rgba(46,213,115,0.6); }
.plc-debug-btn {
display: inline-block; padding: 4px 10px; border-radius: 4px;
background: var(--bg-surface); border: 1px solid var(--border);
color: var(--text-primary); cursor: pointer; flex-shrink: 0;
font-size: 11px; font-weight: 600; text-align: center; min-width: 50px;
transition: all 0.15s;
}
.plc-debug-btn:hover { border-color: var(--accent2); color: var(--accent2); }
.plc-item-info { flex: 1; min-width: 0; font-size: 11px; }
.plc-item-saddr { font-family: var(--font-mono); color: var(--accent); font-size: 11px; }
.plc-item-val { margin-left: 4px; font-weight: 600; font-size: 10px; }
/* ---- PLC Signal List ---- */
.plc-arrow-row { display: flex; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--border); }
.plc-sig-list { flex: 1; overflow-y: auto; }
.plc-sig-check-item {
display: flex; align-items: center; gap: 6px;
padding: 6px 12px; border-bottom: 1px solid rgba(30,48,64,0.3);
cursor: pointer; transition: background 0.15s;
}
.plc-sig-check-item:hover { background: var(--bg-hover); }
.plc-sig-cb { flex-shrink: 0; accent-color: var(--accent2); }
.plc-sig-saddr { flex: 1; font-family: var(--font-mono); font-size: 11px; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plc-sig-desc { font-size: 10px; color: var(--text-muted); max-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plc-sig-val { font-family: var(--font-mono); font-size: 11px; font-weight: 600; color: var(--accent); flex-shrink: 0; }
/* ---- PLC Signal Picker Modal Row ---- */
.plc-sig-row:hover { background: var(--bg-hover); }
/* ---- Mobile: stack PLC debug columns ---- */
@media (max-width: 768px) {
.plc-debug-grid { grid-template-columns: 1fr; height: auto; }
}
/* ---- PLC Config Canvas & Chain Tabs ---- */
.chain-tabs {
display: flex; gap: 4px; padding: 6px 0 0;
flex-wrap: wrap; border-bottom: 1px solid var(--border);
}
.chain-tab {
padding: 6px 14px; font-size: 12px; font-weight: 500;
color: var(--text-secondary); cursor: pointer;
border: 1px solid transparent; border-bottom: none;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
transition: all var(--transition);
font-family: var(--font-mono);
}
.chain-tab:hover { color: var(--text-primary); background: var(--bg-hover); }
.chain-tab.active {
color: var(--accent); background: var(--bg-surface);
border-color: var(--border); font-weight: 600;
}
.plc-canvas-wrap {
display: block; width: 100%; height: 500px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #f0f2f5;
cursor: crosshair;
margin-top: 0;
}
.plc-zoom-bar {
display: flex; align-items: center; gap: 12px;
padding: 6px 10px; background: var(--bg-surface);
border: 1px solid var(--border); border-top: none;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
font-size: 12px; color: var(--text-secondary);
}
.plc-zoom-bar span {
cursor: pointer; padding: 2px 8px; border-radius: 3px;
transition: all var(--transition);
}
.plc-zoom-bar span:hover { color: var(--accent); background: var(--bg-hover); }
#plc-zoom-val { font-family: var(--font-mono); font-weight: 600; color: var(--text-primary); }
/* ============================================================
PLC Config (stub - preserved for backward compat)
============================================================ */
.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;
}
/* ============================================================
Login Page
============================================================ */
.login-wrap {
display: flex; align-items: center; justify-content: center;
height: 100vh; width: 100vw;
background: radial-gradient(ellipse at center, #111d2b 0%, var(--bg-deep) 70%);
}
.login-box {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 40px; width: 360px;
box-shadow: 0 8px 48px rgba(0,0,0,0.6);
}
.login-box h1 { text-align: center; margin-bottom: 28px; font-size: 22px; font-weight: 700; }
.login-box .btn { width: 100%; justify-content: center; margin-top: 20px; padding: 12px; font-size: 14px; }
/* ============================================================
Responsive
============================================================ */
@media (max-width: 768px) {
#nav-sidebar { position: fixed; left: 0; top: 0; bottom: 0; transform: translateX(-100%); }
#nav-sidebar.open { transform: translateX(0); }
#nav-toggle { display: flex; }
#main-content { margin-left: 0 !important; }
#page-container { padding: 16px; }
#page-header { padding: 12px 16px; }
.dash-grid { grid-template-columns: repeat(2, 1fr); }
}

136
config/web/index.html Normal file
View File

@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>RTU Web Manager</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<!-- Mobile Nav Toggle -->
<button id="nav-toggle" onclick="toggleNav()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
</button>
<nav id="nav-sidebar">
<div class="nav-header">
<div class="nav-logo">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
<span id="nav-title">RTU Manager</span>
</div>
<div class="nav-status">
<span class="nav-status-dot disconnected" id="nav-status-dot"></span>
<span id="nav-status-text">Disconnected</span>
</div>
</div>
<div id="nav-links"></div>
<div class="nav-lang">
<select class="lang-select" id="lang-select" onchange="I18n.setLang(this.value)">
<option value="zh">中文</option>
<option value="en">English</option>
</select>
</div>
<div class="nav-footer">
<span id="nav-version">RTU Web v3.0</span>
<span id="nav-time"></span>
</div>
</nav>
<div id="main-content">
<div id="page-header">
<span id="page-title">Dashboard</span>
<span id="page-subtitle"></span>
</div>
<div id="page-container"></div>
</div>
<div class="toast-container" id="toast-container"></div>
<script src="js/i18n.js"></script>
<script src="js/ws-client.js"></script>
<script src="js/signal-store.js"></script>
<script src="js/ui.js"></script>
<script src="js/router.js"></script>
<script src="js/pages/login.js"></script>
<script src="js/pages/dashboard.js"></script>
<script src="js/pages/signals.js"></script>
<script src="js/pages/param.js"></script>
<script src="js/pages/datacenter.js"></script>
<script src="js/pages/plc_debug.js"></script>
<script src="js/pages/plc_config.js"></script>
<script src="js/pages/monitor.js"></script>
<script src="js/pages/terminal.js"></script>
<script>
(function() {
I18n.init();
SignalStore.restore();
document.getElementById('lang-select').value = I18n.current;
document.getElementById('nav-version').textContent = I18n.t('app.version');
document.getElementById('nav-title').textContent = I18n.t('app.title');
buildNav();
WsClient.onMsg(function(data, binary) {
if (binary) {
var text = new TextDecoder().decode(binary);
/* 命令输出走 terminal 的 termOnBinary 处理 */
if (typeof termOnBinary === 'function') { termOnBinary(text); }
MonitorRing.push('↓', '[binary ' + binary.byteLength + 'B]');
return;
}
if (!data || typeof data !== 'object') return;
MonitorRing.push('↓', JSON.stringify(data).substring(0, 200));
if (data.out || data.in || data.yk || data.ao || data.param) {
SignalStore.update(data);
var sigPages = ['#out','#in','#yk','#ao','#param','#datacenter','#plc_debug'];
if (sigPages.indexOf(Router.currentHash) >= 0) Router.dispatch(Router.currentHash);
}
/* ---- 命令列表:渲染到终端面板 ---- */
if (data.type === 'cmd_list' && data.cmds) {
if (typeof renderCmdPanels === 'function') { renderCmdPanels(data.cmds); }
}
if (data.type === 'async_output') {
toast('[' + (data.module||'async') + '] ' + (data.msg||data.data||''), data.success?'success':'error');
}
/* ---- 文件请求响应 ---- */
if (data.type === 'file_content' || data.type === 'file_saved') {
if (typeof PlcConfigPage !== 'undefined' && PlcConfigPage.onData) {
PlcConfigPage.onData(data);
}
}
if (data.type === 'cmd_rsp') {
var out = document.getElementById('term-out');
if (out) { out.textContent += (typeof data.data === 'string' ? data.data : JSON.stringify(data.data)) + '\n'; out.scrollTop = out.scrollHeight; }
}
});
var _orig = WsClient.send.bind(WsClient);
WsClient.send = function(obj) { MonitorRing.push('↑', JSON.stringify(obj).substring(0,200)); _orig(obj); };
WsClient.connect('ws://' + location.hostname + ':8080/ws');
window.addEventListener('hashchange', function() {
Router.dispatch(window.location.hash);
});
Router.dispatch(window.location.hash);
document.addEventListener('click', function(e) {
var link = e.target.closest('#nav-links a');
if (link) { e.preventDefault(); window.location.hash = link.dataset.hash; if (window.innerWidth < 768) toggleNav(); }
});
setInterval(function() {
var el = document.getElementById('nav-time');
if (el) el.textContent = new Date().toLocaleTimeString();
}, 1000);
})();
</script>
</body>
</html>

364
config/web/js/i18n.js Normal file
View File

@ -0,0 +1,364 @@
/**
* @file i18n.js
* @brief RTU Web Manager 多语言国际化框架
*
* @details 预留式设计, 当前支持 zh (中文) / en (English),
* 新增语言只需在 strings 对象中添加新语言键即可
*
* 使用方式:
* t('nav.dashboard') 仪表盘 / Dashboard
* t(['out.signals', count]) 开出信号 (20) / Out Signals (20)
*
* 语言切换:
* I18n.setLang('zh') / I18n.setLang('en')
* 切换后写入 localStorage.rtu_lang 并刷新页面
*/
var I18n = {
current: 'zh',
strings: {
/* ---- 简体中文 ---- */
zh: {
/* App */
'app.title': 'RTU 管理系统',
'app.version': 'RTU Web v3.0',
/* Status */
'status.connected': '已连接',
'status.disconnected': '已断开',
'status.connecting': '连接中…',
/* Navigation */
'nav.section.overview': '概览',
'nav.section.signal_mgmt': '信号管理',
'nav.section.tools': '工具',
'nav.dashboard': '仪表盘',
'nav.out': '开出信号',
'nav.in': '开入信号',
'nav.yk': '遥控信号',
'nav.ao': '参数设置',
'nav.param': '定值管理',
'nav.datacenter': '数据中心',
'nav.plc_debug': 'PLC 调试',
'nav.plc_config': 'PLC 配置',
'nav.monitor': '实时监控',
'nav.terminal': '命令终端',
/* Login */
'login.title': 'RTU 管理系统',
'login.username': '用户名',
'login.password': '密码',
'login.signin': '登录',
'login.error': '用户名或密码错误 (admin/admin)',
'login.success': '登录成功',
/* Dashboard */
'dash.title': '仪表盘',
'dash.quick_actions': '快捷操作',
'dash.connection': '连接信息',
'dash.export_xml': '导出全部 XML',
'dash.import_xml': '从 XML 导入',
'dash.open_terminal': '打开终端',
'dash.open_monitor': '打开监控',
'dash.url': '地址',
'dash.status': '状态',
'dash.out.desc': '开出信号注册',
'dash.in.desc': '开入信号链接',
'dash.yk.desc': '遥控信号管理',
'dash.ao.desc': '模拟输出参数',
'dash.param.desc': '定值区参数',
/* Signals */
'signal.add': '添加',
'signal.delete_sel': '批量删除',
'signal.set': '设置',
'signal.delete': '删除',
'signal.no_data': '暂无信号数据,请先添加',
'signal.confirm_del': '确认删除信号',
'signal.confirm_del_msg': '确定删除信号',
'signal.confirm_batch_del': '确认批量删除',
'signal.confirm_batch_msg': '确定删除选中的',
'signal.add_title': '添加信号',
'signal.saddr': '信号地址',
'signal.desc': '描述',
'signal.type': '数据类型',
'signal.link': '链接目标',
'signal.ctrl': '控制类型',
'signal.ctrl_direct': '直控',
'signal.ctrl_sbo': '选控 (SBO)',
'signal.val': '值',
'signal.action': '操作',
'signal.set_title': '设置数值',
'signal.new_val': '新值',
'signal.zone': '定值区',
/* Datacenter */
'dc.title': '数据中心 — 信号浏览器',
'dc.no_signal': '暂无信号数据',
'dc.saddr': '信号地址',
'dc.desc': '描述',
'dc.type': '类型',
'dc.val': '值',
'dc.action': '操作',
'dc.refresh': '刷 新',
'dc.search_placeholder': '搜索 saddr/desc…',
'dc.sel_count': '已选 {n} 个信号',
'dc.add_to_config': '添加到配置页',
'dc.confirm_add': '确定将 {n} 个信号添加到',
'dc.added': '已发送 {n} 个添加请求',
'dc.no_selected': '请先勾选要添加的信号',
'dc.querying': '正在从 RTU 查询数据中心…',
'dc.refreshed': '数据中心已刷新',
'mon.recv': '接收',
/* Terminal */
'term.title': '命令终端',
'term.placeholder': '输入命令…',
'term.welcome': 'RTU 命令终端 — 输入 help 查看帮助\n\n',
'term.prompt': 'rtu>',
/* PLC */
'plc_debug.title': 'PLC 调试 — 开出信号',
'plc_debug.refresh': '通过 WebSocket 自动刷新',
'plc_debug.led_col': '指示灯',
'plc_debug.sig_col': '开出信号',
'plc_debug.btn_col': '按键',
'plc_debug.unbound': '未绑定',
'plc_debug.no_led': '暂无指示灯 — 点击 "+ LED" 添加',
'plc_debug.no_btn': '暂无按键 — 点击 "+ Button" 添加',
'plc_debug.no_sig': '暂无开出信号 — 请先在 Out 页面注册',
'plc_debug.bind_title': '绑定开出信号',
'plc_debug.toggle': '翻转',
'plc_debug.add_led': '+ LED',
'plc_debug.add_btn': '+ Button',
'plc_debug.del_sel': '删除选中',
'plc_debug.refresh_btn': '刷新',
'plc_debug.to_led': '→ LED',
'plc_debug.to_btn': '→ BTN',
'plc_config.title': 'PLC 逻辑配置',
'plc_config.file': '配置文件: config/PLC/Reclose_logic.xml',
'plc_config.add_input': '+输入',
'plc_config.add_output': '+输出',
'plc_config.load_xml': '加载 XML',
'plc_config.save_xml': '保存 XML',
'plc_config.clear': '清空',
'plc_config.delete': '删除节点',
'plc_config.zoom_in': '放大',
'plc_config.zoom_out': '缩小',
'plc_config.zoom_fit': '适应',
'plc_config.edit_title': '编辑节点',
'plc_config.edit_save': '保存',
'plc_config.help': '点击节点选中 | 拖拽移动 | 点击输出圆点→点击输入圆点连线 | 滚轮缩放 | 拖拽空白区平移',
/* Toast */
'toast.ws_error': 'WebSocket 未连接',
'toast.saddr_required': '信号地址不能为空',
'toast.add_sent': '添加请求已发送',
'toast.del_sent': '已删除',
'toast.batch_del_sent': '个信号已删除',
'toast.set_sent': '设置请求已发送',
'toast.no_selected': '请先选择信号',
'toast.xml_exported': 'XML 已导出',
'toast.no_export': '无信号数据可导出',
'toast.xml_importing': '正在导入信号…',
'toast.xml_parse_error': 'XML 解析失败',
'toast.xml_no_signals': '未找到信号元素',
'toast.confirm': '确认',
'toast.cancel': '取消',
/* Language */
'lang.label': 'Language',
},
/* ---- English ---- */
en: {
/* App */
'app.title': 'RTU Web Manager',
'app.version': 'RTU Web v3.0',
/* Status */
'status.connected': 'Connected',
'status.disconnected': 'Disconnected',
'status.connecting': 'Connecting…',
/* Navigation */
'nav.section.overview': 'Overview',
'nav.section.signal_mgmt': 'Signal Management',
'nav.section.tools': 'Tools',
'nav.dashboard': 'Dashboard',
'nav.out': 'Out Signals',
'nav.in': 'In Signals',
'nav.yk': 'YK Controls',
'nav.ao': 'AO Parameters',
'nav.param': 'Param Settings',
'nav.datacenter': 'Datacenter',
'nav.plc_debug': 'PLC Debug',
'nav.plc_config': 'PLC Config',
'nav.monitor': 'Monitor',
'nav.terminal': 'Terminal',
/* Login */
'login.title': 'RTU Web Manager',
'login.username': 'Username',
'login.password': 'Password',
'login.signin': 'Sign In',
'login.error': 'Invalid credentials (admin/admin)',
'login.success': 'Login successful',
/* Dashboard */
'dash.title': 'Dashboard',
'dash.quick_actions': 'Quick Actions',
'dash.connection': 'Connection Info',
'dash.export_xml': 'Export All to XML',
'dash.import_xml': 'Import from XML',
'dash.open_terminal': 'Open Terminal',
'dash.open_monitor': 'Open Monitor',
'dash.url': 'URL',
'dash.status': 'Status',
'dash.out.desc': 'Output signal registrations',
'dash.in.desc': 'Input signal links',
'dash.yk.desc': 'Remote control signals',
'dash.ao.desc': 'Analog output parameters',
'dash.param.desc': 'Setting zone parameters',
/* Signals */
'signal.add': 'Add',
'signal.delete_sel': 'Delete Selected',
'signal.set': 'Set',
'signal.delete': 'Delete',
'signal.no_data': 'No signals registered. Use Add button above.',
'signal.confirm_del': 'Confirm Delete',
'signal.confirm_del_msg': 'Delete signal',
'signal.confirm_batch_del': 'Confirm Batch Delete',
'signal.confirm_batch_msg': 'Delete',
'signal.add_title': 'Add Signal',
'signal.saddr': 'Signal Address',
'signal.desc': 'Description',
'signal.type': 'Data Type',
'signal.link': 'Link Target',
'signal.ctrl': 'Control Type',
'signal.ctrl_direct': 'Direct',
'signal.ctrl_sbo': 'SBO',
'signal.val': 'Value',
'signal.action': 'Action',
'signal.set_title': 'Set Value',
'signal.new_val': 'New Value',
'signal.zone': 'Zone',
/* Datacenter */
'dc.title': 'Datacenter — Signal Explorer',
'dc.no_signal': 'No signals found',
'dc.saddr': 'SAddr',
'dc.desc': 'Desc',
'dc.type': 'Type',
'dc.val': 'Value',
'dc.action': 'Action',
/* Monitor */
'mon.title': 'WebSocket Traffic Monitor',
'mon.clear': 'Clear',
'mon.pause': 'Pause',
'mon.resume': 'Resume',
'mon.total': 'Total',
'mon.sent': 'Sent',
'mon.recv': 'Recv',
/* Terminal */
'term.title': 'Command Terminal',
'term.placeholder': 'Enter command…',
'term.welcome': 'RTU Command Terminal — Type \'help\' to start.\n\n',
'term.prompt': 'rtu>',
/* PLC */
'plc_debug.title': 'PLC Debug — Out Signals',
'plc_debug.refresh': 'Auto-refresh via WebSocket',
'plc_debug.led_col': 'LED Indicators',
'plc_debug.sig_col': 'Out Signals',
'plc_debug.btn_col': 'Buttons',
'plc_debug.unbound': 'Unbound',
'plc_debug.no_led': 'No LEDs — click "+ LED" to add',
'plc_debug.no_btn': 'No Buttons — click "+ Button" to add',
'plc_debug.no_sig': 'No out signals — register first',
'plc_debug.bind_title': 'Bind Out Signal',
'plc_debug.toggle': 'Toggle',
'plc_debug.add_led': '+ LED',
'plc_debug.add_btn': '+ Button',
'plc_debug.del_sel': 'Delete Selected',
'plc_debug.refresh_btn': 'Refresh',
'plc_debug.to_led': '→ LED',
'plc_debug.to_btn': '→ BTN',
'plc_config.title': 'PLC Logic Configuration',
'plc_config.file': 'Config: config/PLC/Reclose_logic.xml',
'plc_config.add_input': '+Input',
'plc_config.add_output': '+Output',
'plc_config.load_xml': 'Load XML',
'plc_config.save_xml': 'Save XML',
'plc_config.clear': 'Clear',
'plc_config.delete': 'Delete',
'plc_config.zoom_in': 'Zoom In',
'plc_config.zoom_out': 'Zoom Out',
'plc_config.zoom_fit': 'Fit',
'plc_config.edit_title': 'Edit Node',
'plc_config.edit_save': 'Save',
'plc_config.help': 'Click node to select | Drag to move | Click output pin → input pin to wire | Scroll to zoom | Drag empty area to pan',
/* Toast */
'toast.ws_error': 'WebSocket not connected',
'toast.saddr_required': 'Signal address is required',
'toast.add_sent': 'Add request sent',
'toast.del_sent': 'Deleted',
'toast.batch_del_sent': 'signals deleted',
'toast.set_sent': 'Set request sent',
'toast.no_selected': 'No signals selected',
'toast.xml_exported': 'XML exported',
'toast.no_export': 'No signals to export',
'toast.xml_importing': 'Importing signals…',
'toast.xml_parse_error': 'XML parse error',
'toast.xml_no_signals': 'No signal elements found',
'toast.confirm': 'Confirm',
'toast.cancel': 'Cancel',
/* Language */
'lang.label': '语言',
}
},
/* ---- API ---- */
/**
* @brief 获取翻译字符串
* @param {string} key 翻译键
* @return 当前语言的翻译文本
*/
t: function(key) {
var dict = this.strings[this.current];
return (dict && dict[key]) ? dict[key] : (this.strings['en'][key] || key);
},
/**
* @brief 切换语言
* @param {string} lang 语言代码 ('zh' | 'en' | )
*/
setLang: function(lang) {
if (this.strings[lang]) {
this.current = lang;
localStorage.setItem('rtu_lang', lang);
window.location.reload();
}
},
/**
* @brief 初始化 加载用户偏好或自动检测浏览器语言
*/
init: function() {
var saved = localStorage.getItem('rtu_lang');
if (saved && this.strings[saved]) {
this.current = saved;
} else {
var nav = (navigator.language || '').toLowerCase();
this.current = (nav.indexOf('zh') === 0) ? 'zh' : 'en';
}
}
};

View File

@ -0,0 +1,105 @@
/**
* @file dashboard.js
* @brief Dashboard 仪表盘页面 信号卡片 + 快捷操作 + 连接状态
*/
Router.register('#dashboard', function(c) {
var types = [
{ t: 'out', key: 'nav.out', descKey: 'dash.out.desc', color: 'var(--accent)' },
{ t: 'in', key: 'nav.in', descKey: 'dash.in.desc', color: 'var(--accent2)' },
{ t: 'yk', key: 'nav.yk', descKey: 'dash.yk.desc', color: 'var(--warning)' },
{ t: 'ao', key: 'nav.ao', descKey: 'dash.ao.desc', color: 'var(--success)' },
{ t: 'param', key: 'nav.param', descKey: 'dash.param.desc', color: '#a78bfa' },
];
var cards = types.map(function(t) {
var count = SignalStore.get(t.t).length;
return '<div class="dash-card" onclick="window.location.hash=\'#' + t.t + '\'">'
+ '<div class="dash-card-icon">' + Icon.get(t.icon === 'out' ? 'signal' : t.icon === 'in' ? 'link' : t.icon === 'yk' ? 'zap' : t.icon === 'ao' ? 'sliders' : 'settings') + '</div>'
+ '<div class="dash-card-count">' + count + '</div>'
+ '<div class="dash-card-label">' + I18n.t(t.key) + '</div>'
+ '<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">' + I18n.t(t.descKey) + '</div>'
+ '</div>';
}).join('');
c.innerHTML = '<div class="dash-grid">' + cards + '</div>'
+ '<div class="card" style="margin-top:16px;">'
+ '<div class="card-header"><span>' + I18n.t('dash.quick_actions') + '</span></div>'
+ '<div class="btn-group">'
+ '<button class="btn" onclick="DashActions.exportAll()">' + I18n.t('dash.export_xml') + '</button>'
+ '<button class="btn" onclick="DashActions.importAll()">' + I18n.t('dash.import_xml') + '</button>'
+ '<button class="btn btn-sm" onclick="window.location.hash=\'#terminal\'">' + I18n.t('dash.open_terminal') + '</button>'
+ '<button class="btn btn-sm" onclick="window.location.hash=\'#monitor\'">' + I18n.t('dash.open_monitor') + '</button>'
+ '</div></div>'
+ '<div class="card">'
+ '<div class="card-header"><span>' + I18n.t('dash.connection') + '</span></div>'
+ '<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-secondary);">'
+ I18n.t('dash.url') + ': <span style="color:var(--accent);">ws://' + location.hostname + ':8080/ws</span><br>'
+ I18n.t('dash.status') + ': <span id="dash-status" style="color:var(--offline);">' + I18n.t('status.disconnected') + '</span><br>'
+ '<span style="color:var(--text-muted);" id="dash-time"></span></div></div>';
WsClient.onStatus(function(s) {
var el = document.getElementById('dash-status');
if (el)
{
el.textContent = I18n.t('status.' + s) || s;
el.style.color = s === 'connected' ? 'var(--online)' : s === 'connecting' ? 'var(--warning)' : 'var(--offline)';
}
});
});
var DashActions = {
exportAll: function() {
var types = ['out', 'in', 'yk', 'ao', 'param'];
var xml = '<?xml version="1.0" encoding="UTF-8"?>\n<root>\n';
var hasData = false;
types.forEach(function(st) {
var data = SignalStore.get(st);
xml += ' <signals type="' + st + '">\n';
if (data.length > 0) { hasData = true; data.forEach(function(s, i) { xml += ' <signal no="' + i + '" saddr="' + esc(s.saddr) + '"/>\n'; }); }
xml += ' </signals>\n';
});
xml += '</root>';
if (!hasData) { toast(I18n.t('toast.no_export'), 'error'); return; }
var blob = new Blob([xml], { type: 'application/xml' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'rtu_signals_' + Date.now() + '.xml';
a.click();
toast(I18n.t('toast.xml_exported'), 'success');
},
importAll: function() {
var input = document.createElement('input');
input.type = 'file';
input.accept = '.xml';
input.onchange = function(e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(ev) {
var parser = new DOMParser();
var doc = parser.parseFromString(ev.target.result, 'text/xml');
var err = doc.querySelector('parsererror');
if (err) { toast(I18n.t('toast.xml_parse_error') + ': ' + err.textContent, 'error'); return; }
var nodes = doc.querySelectorAll('signals');
if (nodes.length === 0) { toast(I18n.t('toast.xml_no_signals'), 'error'); return; }
var count = 0;
nodes.forEach(function(el) {
var type = el.getAttribute('type');
if (['out', 'in', 'yk', 'ao', 'param'].indexOf(type) === -1) return;
el.querySelectorAll('signal').forEach(function(sig) {
var saddr = sig.getAttribute('saddr');
if (!saddr || !saddr.trim()) return;
WsClient.send({ saddr: saddr.trim(), signal_type: type, curd: 'add', setting_zone: '0', signal_data: '' });
count++;
});
});
toast(I18n.t('toast.xml_importing') + ' (' + count + ')', 'success');
};
reader.readAsText(file);
};
input.click();
}
};

View File

@ -0,0 +1,213 @@
/**
* @file datacenter.js
* @brief RTU_ALL_AI 数据中心 搜索过滤 + 复选框 + 批量添加到配置页
*/
Router.register('#datacenter', function(c) { DcPage.render(c); });
var DcPage = (function() {
var allData = {};
var activeTab = 'out';
var searchText = '';
var selectedSet = {};
var MAX_SIGNALS = 500;
function getVisibleData() {
var data = allData[activeTab] || [];
if (!searchText) return data;
var st = searchText.toLowerCase();
return data.filter(function(x) {
return (x.saddr && x.saddr.toLowerCase().indexOf(st) !== -1)
|| (x.desc && x.desc.toLowerCase().indexOf(st) !== -1);
});
}
function getSelectedCount() {
var c = 0;
for (var k in selectedSet) { if (selectedSet.hasOwnProperty(k)) c++; }
return c;
}
function updateSelBar() {
var b = document.getElementById('dc-sel-bar');
var l = document.getElementById('dc-sel-label');
if (!b || !l) return;
var n = getSelectedCount();
b.style.display = n > 0 ? 'flex' : 'none';
l.textContent = I18n.t('dc.sel_count').replace('{n}', n);
}
function renderTabs() {
var types = ['out', 'in', 'yk', 'ao', 'param'];
var labels = { out: '注册 (out)', in: '链接注册 (in)', yk: '遥控 (yk)', ao: '参数 (ao)', param: '定值 (param)' };
var el = document.getElementById('dc-tab-bar');
if (!el) return;
var h = '';
types.forEach(function(t) {
h += '<button class="dc-tab' + (t === activeTab ? ' active' : '')
+ '" onclick="DcPage.switchTab(\'' + t + '\')">' + labels[t] + '</button>';
});
el.innerHTML = h;
}
function doAddSelected() {
var visible = getVisibleData(), items = [];
visible.forEach(function(x) { if (selectedSet[x.saddr]) items.push(x); });
if (items.length === 0) { toast(I18n.t('dc.no_selected'), 'error'); return; }
var existing = SignalStore.get(activeTab);
if (existing.length + items.length > MAX_SIGNALS) {
toast('MAX ' + MAX_SIGNALS + ' exceeded', 'error'); return;
}
if (!confirm(I18n.t('dc.confirm_add').replace('{n}', items.length) + ' -> ' + activeTab.toUpperCase())) return;
var ok = 0;
items.forEach(function(x) {
WsClient.send({ saddr: x.saddr, signal_type: activeTab, curd: 'add', setting_zone: '0', signal_data: '' });
ok++;
});
toast('Added ' + ok + ' to ' + activeTab.toUpperCase(), 'success');
selectedSet = {};
DcPage._renderTable();
updateSelBar();
}
function renderTable() {
var data = getVisibleData();
var tbody = document.getElementById('dc-tbody');
var cntEl = document.getElementById('dc-count');
if (!tbody) return;
if (cntEl) cntEl.textContent = data.length + ' / ' + (allData[activeTab] || []).length;
var allChk = data.length > 0 && data.every(function(x) { return !!selectedSet[x.saddr]; });
var thEl = document.getElementById('dc-thead');
if (thEl) thEl.innerHTML = '<th style="width:36px"><input type="checkbox" id="dc-cb-all"'
+ (allChk ? ' checked' : '') + ' onchange="DcPage._toggleAll()"></th>'
+ '<th>id</th><th>saddr</th><th>desc</th><th>val</th><th>type</th><th>ctrl</th><th>link</th>';
if (data.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="8">' + I18n.t('dc.no_signal') + '</td></tr>';
return;
}
var h = '';
data.forEach(function(x) {
var chk = !!selectedSet[x.saddr];
h += '<tr><td><input type="checkbox"' + (chk ? ' checked' : '')
+ ' onchange="DcPage._toggleRow(\'' + esc(x.saddr) + '\')"></td>'
+ '<td class="cell-mono">' + fmtVal(x.id) + '</td>'
+ '<td class="cell-mono">' + esc(x.saddr) + '</td>'
+ '<td>' + esc(x.desc || '') + '</td>'
+ '<td class="cell-val">' + fmtVal(x.val) + '</td>'
+ '<td class="cell-mono">' + esc(x.type || '') + '</td>'
+ '<td class="cell-mono">' + (x.ctrl_type == 1 ? 'SBO' : 'Direct') + '</td>'
+ '<td>' + esc(x.link_saddrs || '') + '</td></tr>';
});
tbody.innerHTML = h;
}
function parseDcOutput(text) {
if (!text || text.length < 10) return;
var types = ['out', 'in', 'yk', 'ao', 'param'], currentType = '';
var lines = text.split('\n');
lines.forEach(function(line) {
types.forEach(function(t) {
if (line.indexOf(t + ' signals:') >= 0) currentType = t;
});
if (!currentType) return;
if (line.indexOf('---') >= 0 || line.indexOf('saddr') >= 0 || line.indexOf(' id ') >= 0) return;
var trimmed = line.trim();
if (!trimmed) return;
var parts = trimmed.split(/\s+/);
if (parts.length < 4) return;
var item = {};
if (currentType === 'param') {
item = { id: parseInt(parts[0]), saddr: parts[1], desc: parts[2], type: parts[3] || '', ctrl_type: parseInt(parts[4]) || 0 };
} else if (currentType === 'in') {
item = { id: parseInt(parts[0]), saddr: parts[1], desc: parts[2], link_saddrs: parts[3] || '', type: parts[4] || '', val: parts[5] || '' };
} else {
item = { id: parseInt(parts[0]), saddr: parts[1], desc: parts[2], type: parts[3] || '', val: parts[4] || '' };
}
allData[currentType] = allData[currentType] || [];
allData[currentType].push(item);
});
DcPage._renderTable();
toast(I18n.t('dc.refreshed'), 'success');
}
function refreshAll() {
allData = { out: [], in: [], yk: [], ao: [], param: [] };
toast(I18n.t('dc.querying'), 'info');
var buf = '', timer = null, done = false;
var handler = function(data, binary) {
if (binary) {
buf += new TextDecoder().decode(binary);
clearTimeout(timer);
timer = setTimeout(function() {
if (done) return; done = true;
WsClient.msgCbs = WsClient.msgCbs.filter(function(cb) { return cb !== handler; });
parseDcOutput(buf);
}, 600);
}
};
WsClient.msgCbs.unshift(handler);
WsClient.send({ type: 'cmd', data: 'datacenter all' });
/* 兜底超时, 但只在 600ms 定时器未触发时生效 */
setTimeout(function() {
if (buf && !done) {
done = true;
WsClient.msgCbs = WsClient.msgCbs.filter(function(cb) { return cb !== handler; });
parseDcOutput(buf);
}
}, 6000);
}
return {
render: function(container) {
allData = {}; activeTab = 'out'; searchText = ''; selectedSet = {};
container.innerHTML = '<div class="card">'
+ '<div class="card-header"><span>' + I18n.t('dc.title') + '</span>'
+ '<div class="btn-group">'
+ '<button class="btn btn-primary btn-sm" onclick="DcPage.refreshAll()">' + I18n.t('dc.refresh') + '</button>'
+ '</div></div>'
+ '<div class="dc-toolbar">'
+ '<div class="dc-tab-bar" id="dc-tab-bar"></div>'
+ '<input class="form-input dc-search" id="dc-search" placeholder="' + I18n.t('dc.search_placeholder') + '" oninput="DcPage._search()">'
+ '<span class="dc-count" id="dc-count">0</span>'
+ '</div>'
+ '<div class="dc-sel-bar" id="dc-sel-bar" style="display:none">'
+ '<span class="dc-sel-label" id="dc-sel-label"></span>'
+ '<button class="btn btn-primary btn-sm" onclick="DcPage._addSelected()">' + I18n.t('dc.add_to_config') + '</button>'
+ '</div>'
+ '<div class="table-wrap"><table class="signal-table">'
+ '<thead id="dc-thead"></thead>'
+ '<tbody id="dc-tbody"></tbody>'
+ '</table></div></div>';
renderTabs();
renderTable();
},
refreshAll: refreshAll,
switchTab: function(t) {
activeTab = t; searchText = ''; selectedSet = {};
var inp = document.getElementById('dc-search'); if (inp) inp.value = '';
renderTabs(); DcPage._renderTable(); updateSelBar();
},
_renderTable: renderTable,
_search: function() {
var inp = document.getElementById('dc-search');
searchText = inp ? inp.value : '';
renderTable();
},
_toggleAll: function() {
var cb = document.getElementById('dc-cb-all');
var allChk = cb ? cb.checked : false;
var v = getVisibleData();
if (allChk) { v.forEach(function(x) { selectedSet[x.saddr] = true; }); }
else { v.forEach(function(x) { delete selectedSet[x.saddr]; }); }
renderTable(); updateSelBar();
},
_toggleRow: function(saddr) {
if (selectedSet[saddr]) { delete selectedSet[saddr]; }
else { selectedSet[saddr] = true; }
renderTable(); updateSelBar();
},
_addSelected: doAddSelected
};
})();

View File

@ -0,0 +1,36 @@
/**
* @file login.js
* @brief Login 登录页面
*/
Router.register('#login', function(c) {
c.innerHTML = '<div class="login-wrap"><div class="login-box">'
+ '<h1>' + I18n.t('login.title') + '</h1>'
+ '<div class="form-group"><label class="form-label">' + I18n.t('login.username') + '</label><input class="form-input" id="login-user" placeholder="admin" autofocus></div>'
+ '<div class="form-group"><label class="form-label">' + I18n.t('login.password') + '</label><input class="form-input" id="login-pass" type="password" placeholder="\u2022\u2022\u2022\u2022\u2022\u2022"></div>'
+ '<button class="btn btn-primary" onclick="doLogin()">' + I18n.t('login.signin') + '</button>'
+ '</div></div>';
});
function doLogin()
{
var u = document.getElementById('login-user').value.trim();
var p = document.getElementById('login-pass').value.trim();
if (u === 'admin' && p === 'admin')
{
localStorage.setItem('rtu_logged_in', '1');
window.location.hash = '#dashboard';
}
else
{
toast(I18n.t('login.error'), 'error');
}
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && Router.currentHash === '#login')
{
doLogin();
}
});

View File

@ -0,0 +1,62 @@
/**
* @file monitor.js
* @brief 实时监控页面 WebSocket 流量环形缓冲区
*/
Router.register('#monitor', function(c) {
c.innerHTML = '<div class="card">'
+ '<div class="card-header"><span>' + I18n.t('mon.title') + '</span>'
+ '<div class="btn-group">'
+ '<button class="btn btn-sm" onclick="MonitorRing.clear()">' + I18n.t('mon.clear') + '</button>'
+ '<button class="btn btn-sm" id="mon-pause-btn" onclick="MonitorRing.togglePause()">' + I18n.t('mon.pause') + '</button>'
+ '</div></div>'
+ '<div class="monitor-stats" id="mon-stats"></div>'
+ '<div class="monitor-list" id="mon-list"></div></div>';
MonitorRing.render();
});
var MonitorRing = {
ring: [], max: 500, paused: false,
push: function(dir, msg) {
if (this.paused) return;
var ts = new Date().toLocaleTimeString();
this.ring.push({ dir: dir, ts: ts, msg: typeof msg === 'string' ? msg : JSON.stringify(msg).substring(0, 200) });
if (this.ring.length > this.max) { this.ring.shift(); }
this.render();
},
clear: function() {
this.ring = [];
this.render();
},
togglePause: function() {
this.paused = !this.paused;
var btn = document.getElementById('mon-pause-btn');
if (btn) { btn.textContent = this.paused ? I18n.t('mon.resume') : I18n.t('mon.pause'); }
},
render: function() {
var stats = document.getElementById('mon-stats');
var list = document.getElementById('mon-list');
if (!stats || !list) return;
var up = 0, down = 0;
this.ring.forEach(function(r) { r.dir === '\u2191' ? up++ : down++; });
stats.innerHTML = '<div class="monitor-stat"><div class="monitor-stat-val">' + (up + down) + '</div>' + I18n.t('mon.total') + '</div>'
+ '<div class="monitor-stat"><div class="monitor-stat-val" style="color:var(--accent2)">' + up + '</div>' + I18n.t('mon.sent') + ' \u2191</div>'
+ '<div class="monitor-stat"><div class="monitor-stat-val" style="color:var(--accent)">' + down + '</div>' + I18n.t('mon.recv') + ' \u2193</div>';
list.innerHTML = this.ring.map(function(r) {
return '<div class="monitor-item">'
+ '<span class="monitor-dir ' + (r.dir === '\u2191' ? 'up' : 'down') + '">' + r.dir + '</span>'
+ '<span class="monitor-ts">' + r.ts + '</span>'
+ '<span class="monitor-msg">' + r.msg + '</span></div>';
}).reverse().join('');
list.scrollTop = list.scrollHeight;
}
};

View File

@ -0,0 +1,58 @@
/**
* @file param.js
* @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>';
}
else
{
signals.forEach(function(s) {
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>'
+ '</div>';
});
rows += '<tr>'
+ '<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>'
+ '</tr>';
});
}
c.innerHTML = '<div class="card">'
+ '<div class="card-header"><span>PARAM \u2014 ' + 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>'
+ '</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'));
}
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,215 @@
/**
* @file plc_debug.js
* @brief PLC 调试页 指示灯/按键绑定 out 信号 + 增量刷新
*/
var PlcDebugLeds = [];
var PlcDebugButtons = [];
Router.register('#plc_debug', function(c) { PlcDebugPage.render(c); });
var PlcDebugPage = (function() {
var outSignals = [];
var ledChecked = {}, btnChecked = {}, sigChecked = {};
function getOut() {
var out = SignalStore.get('out');
return (Array.isArray(out) && out.length) ? out : outSignals;
}
function showPicker(cb) {
var sigs = getOut();
if (!sigs.length) { toast(I18n.t('plc_debug.no_sig'), 'error'); return; }
var rows = '';
sigs.forEach(function(s) {
rows += '<div class="plc-pick-row" data-saddr="' + esc(s.saddr) + '" data-desc="' + esc(s.desc || '') + '">'
+ '<strong>' + esc(s.saddr) + '</strong>'
+ (s.desc ? ' <span style="color:var(--text-muted)">' + esc(s.desc) + '</span>' : '')
+ '<span style="float:right;color:var(--accent);font-weight:700">' + fmtVal(s.val) + '</span></div>';
});
var ov = document.createElement('div');
ov.className = 'modal-overlay';
ov.innerHTML = '<div class="modal-box" style="max-width:550px"><h3>' + I18n.t('plc_debug.bind_title') + '</h3>'
+ '<div style="max-height:300px;overflow-y:auto;margin:12px 0">' + rows + '</div>'
+ '<button class="btn btn-sm" id="plc-pick-cancel">' + I18n.t('toast.cancel') + '</button></div>';
document.body.appendChild(ov);
document.getElementById('plc-pick-cancel').onclick = function() { ov.remove(); };
ov.addEventListener('click', function(e) { if (e.target === ov) ov.remove(); });
ov.querySelectorAll('.plc-pick-row').forEach(function(r) {
r.onclick = function() {
var sa = this.getAttribute('data-saddr'), de = this.getAttribute('data-desc');
ov.remove();
if (confirm(I18n.t('plc_debug.confirm_bind') + ' ' + sa + ' ?')) cb(sa, de);
};
});
}
function findSig(sa) {
for (var i = 0; i < outSignals.length; i++) if (outSignals[i].saddr === sa) return outSignals[i];
return null;
}
function refreshData() {
var o = getOut(); if (Array.isArray(o)) outSignals = o;
PlcDebugLeds.forEach(function(l) { var f = findSig(l.saddr); l.val = f ? f.val : 0; });
PlcDebugButtons.forEach(function(b) { var f = findSig(b.saddr); b.val = f ? f.val : 0; });
}
function incrRefresh() {
refreshData();
PlcDebugLeds.forEach(function(l, i) {
var on = parseFloat(l.val) > 0;
var ve = document.getElementById('led-val-' + i), ie = document.getElementById('led-icon-' + i);
if (ve) { ve.textContent = '(' + l.val + ')'; ve.style.color = on ? 'var(--accent)' : 'var(--text-muted)'; }
if (ie) ie.className = 'plc-led' + (on ? ' plc-led-on' : '');
});
PlcDebugButtons.forEach(function(b, j) {
var ve = document.getElementById('btn-val-' + j);
if (ve) { ve.textContent = '(' + b.val + ')'; ve.style.color = parseFloat(b.val) > 0 ? 'var(--accent)' : 'var(--text-muted)'; }
});
}
function countChecked(obj) { var n = 0; for (var k in obj) if (obj.hasOwnProperty(k)) n++; return n; }
function delChecked() {
var todel = [];
for (var k in ledChecked) if (ledChecked.hasOwnProperty(k)) todel.push({ type: 'led', idx: parseInt(k) });
for (var k in btnChecked) if (btnChecked.hasOwnProperty(k)) todel.push({ type: 'btn', idx: parseInt(k) });
if (!todel.length) { toast(I18n.t('toast.no_selected'), 'error'); return; }
if (!confirm(I18n.t('plc_debug.confirm_del') + ' ' + todel.length + ' ?')) return;
todel.sort(function(a, b) { return b.idx - a.idx; }).forEach(function(t) {
if (t.type === 'led') PlcDebugLeds.splice(t.idx, 1);
else PlcDebugButtons.splice(t.idx, 1);
});
ledChecked = {}; btnChecked = {};
renderAll();
}
function renderAll() {
var c = document.getElementById('plc-content');
if (!c) return;
refreshData();
/* 指示灯列 */
var ledHtml = '';
if (!PlcDebugLeds.length) {
ledHtml = '<div style="color:var(--text-muted);padding:20px;text-align:center">' + I18n.t('plc_debug.no_led') + '</div>';
} else {
PlcDebugLeds.forEach(function(l, i) {
var on = parseFloat(l.val) > 0, chk = !!ledChecked[i];
ledHtml += '<div class="plc-item">'
+ '<div class="plc-led' + (on ? ' plc-led-on' : '') + '" id="led-icon-' + i + '" onclick="PlcDebugPage._bindLed(' + i + ')" title="' + I18n.t('plc_debug.click_to_bind') + '"></div>'
+ '<div class="plc-info">'
+ (l.saddr ? '<span class="plc-saddr">' + esc(l.saddr) + ' <span id="led-val-' + i + '" style="color:' + (on ? 'var(--accent)' : 'var(--text-muted)') + '">(' + fmtVal(l.val) + ')</span></span>'
+ (l.desc ? '<span style="color:var(--text-muted);font-size:11px">' + esc(l.desc) + '</span>' : '')
: '<span style="color:var(--text-muted)">' + I18n.t('plc_debug.unbound') + '</span>')
+ '</div>'
+ '<input type="checkbox" ' + (chk ? 'checked' : '') + ' onchange="PlcDebugPage._toggleLed(' + i + ',this.checked)">'
+ '</div>';
});
}
/* 按键列 */
var btnHtml = '';
if (!PlcDebugButtons.length) {
btnHtml = '<div style="color:var(--text-muted);padding:20px;text-align:center">' + I18n.t('plc_debug.no_btn') + '</div>';
} else {
PlcDebugButtons.forEach(function(b, j) {
var chk = !!btnChecked[j];
btnHtml += '<div class="plc-item">'
+ '<button class="plc-btn-icon" id="btn-icon-' + j + '" onclick="PlcDebugPage._toggleBtn(' + j + ')" title="' + I18n.t('plc_debug.toggle') + '">🔘</button>'
+ '<div class="plc-info">'
+ (b.saddr ? '<span class="plc-saddr">' + esc(b.saddr) + ' <span id="btn-val-' + j + '" style="color:var(--text-muted)">(' + fmtVal(b.val) + ')</span></span>'
+ (b.desc ? '<span style="color:var(--text-muted);font-size:11px">' + esc(b.desc) + '</span>' : '')
: '<span style="color:var(--text-muted)">' + I18n.t('plc_debug.unbound') + '</span>')
+ '</div>'
+ '<input type="checkbox" ' + (chk ? 'checked' : '') + ' onchange="PlcDebugPage._toggleBtnChk(' + j + ',this.checked)">'
+ '</div>';
});
}
/* 信号列 */
var o = getOut();
var sigHtml = '';
if (!o.length) {
sigHtml = '<div style="color:var(--text-muted);padding:20px;text-align:center">' + I18n.t('plc_debug.no_sig') + '</div>';
} else {
o.forEach(function(s, i) {
var chk = !!sigChecked[i];
sigHtml += '<div class="plc-item">'
+ '<input type="checkbox" ' + (chk ? 'checked' : '') + ' onchange="PlcDebugPage._toggleSig(' + i + ',this.checked)">'
+ '<span class="plc-saddr">' + esc(s.saddr) + '</span>'
+ '<span style="color:var(--text-muted);font-size:11px">' + esc(s.desc || '') + '</span>'
+ '<span style="margin-left:auto;color:var(--accent);font-weight:700;font-family:var(--font-mono)">' + fmtVal(s.val) + '</span>'
+ '</div>';
});
}
c.innerHTML = '<div class="plc-debug-grid">'
+ '<div class="plc-col"><div class="card-header" style="margin-bottom:8px"><span>💡 ' + I18n.t('plc_debug.led_col') + '</span>'
+ '<div class="btn-group">'
+ '<button class="btn btn-sm" onclick="PlcDebugPage._addLed()">' + I18n.t('plc_debug.add_led') + '</button>'
+ '</div></div><div class="plc-sig-list">' + ledHtml + '</div></div>'
+ '<div class="plc-col plc-sig-col"><div class="card-header" style="margin-bottom:8px"><span>📋 ' + I18n.t('plc_debug.sig_col') + '</span>'
+ '<div class="plc-arrow-row">'
+ '<button class="btn btn-sm" onclick="PlcDebugPage._moveSel(\'led\')" title="' + I18n.t('plc_debug.to_led') + '">← LED</button>'
+ '<button class="btn btn-sm" onclick="PlcDebugPage._moveSel(\'btn\')" title="' + I18n.t('plc_debug.to_btn') + '">→ BTN</button>'
+ '</div></div><div class="plc-sig-list">' + sigHtml + '</div></div>'
+ '<div class="plc-col"><div class="card-header" style="margin-bottom:8px"><span>🔘 ' + I18n.t('plc_debug.btn_col') + '</span>'
+ '<div class="btn-group">'
+ '<button class="btn btn-sm" onclick="PlcDebugPage._addBtn()">' + I18n.t('plc_debug.add_btn') + '</button>'
+ '</div></div><div class="plc-sig-list">' + btnHtml + '</div></div>'
+ '</div>';
updateDelBtn();
}
function updateDelBtn() {
var el = document.getElementById('plc-del-btn');
if (!el) return;
var n = countChecked(ledChecked) + countChecked(btnChecked);
el.textContent = I18n.t('plc_debug.del_sel') + (n ? ' (' + n + ')' : '');
el.disabled = !n;
}
return {
render: function(c) {
ledChecked = {}; btnChecked = {}; sigChecked = {};
refreshData();
c.innerHTML = '<div class="card">'
+ '<div class="card-header"><span>' + I18n.t('plc_debug.title') + '</span>'
+ '<div class="btn-group">'
+ '<button class="btn btn-sm" onclick="PlcDebugPage._refresh()">🔄 ' + I18n.t('plc_debug.refresh_btn') + '</button>'
+ '<button class="btn btn-danger btn-sm" id="plc-del-btn" disabled onclick="PlcDebugPage._delChecked()">🗑 ' + I18n.t('plc_debug.del_sel') + '</button>'
+ '</div></div>'
+ '<div id="plc-content"></div></div>';
renderAll();
},
_addLed: function() { PlcDebugLeds.push({ saddr: '', desc: '', val: 0 }); renderAll(); },
_addBtn: function() { PlcDebugButtons.push({ saddr: '', desc: '', val: 0 }); renderAll(); },
_bindLed: function(i) { showPicker(function(sa, de) { PlcDebugLeds[i].saddr = sa; PlcDebugLeds[i].desc = de; var f = findSig(sa); if (f) PlcDebugLeds[i].val = f.val; renderAll(); }); },
_toggleBtn: function(j) {
var b = PlcDebugButtons[j];
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();
},
_toggleLed: function(i, v) { ledChecked[i] = v; updateDelBtn(); },
_toggleBtnChk: function(j, v) { btnChecked[j] = v; updateDelBtn(); },
_toggleSig: function(i, v) { sigChecked[i] = v; },
_moveSel: function(target) {
var picked = [];
for (var k in sigChecked) if (sigChecked.hasOwnProperty(k)) picked.push(parseInt(k));
if (!picked.length) { toast(I18n.t('toast.no_selected'), 'error'); return; }
picked.sort(function(a, b) { return a - b; }).forEach(function(i) {
var s = outSignals[i];
if (!s) return;
if (target === 'led') PlcDebugLeds.push({ saddr: s.saddr, desc: s.desc || '', val: s.val || 0 });
else PlcDebugButtons.push({ saddr: s.saddr, desc: s.desc || '', val: s.val || 0 });
});
sigChecked = {}; renderAll();
},
_refresh: function() { refreshData(); incrRefresh(); },
_delChecked: delChecked,
};
})();

View File

@ -0,0 +1,68 @@
/**
* @file signals.js
* @brief 信号管理页面 out / in / yk / ao (统一渲染)
*/
/* ---- Out ---- */
Router.register('#out', function(c) {
c.innerHTML = renderSignalPage('out',
['<input type="checkbox" style="pointer-events:none;opacity:0">', I18n.t('signal.saddr'), I18n.t('signal.desc'), I18n.t('signal.type')],
function(s) { return td(esc(s.type || '?'), 'cell-mono'); },
function(s) {
return '<div class="btn-group">'
+ '<button class="btn btn-sm" onclick="SignalActions.set(\'out\',\'' + esc(s.saddr) + '\')">' + Icon.get('edit') + ' ' + I18n.t('signal.set') + '</button>'
+ '<button class="btn btn-danger btn-sm" onclick="SignalActions.del(\'out\',\'' + esc(s.saddr) + '\')">' + Icon.get('trash') + '</button>'
+ '</div>';
}
);
[].slice.call(document.querySelectorAll('#tbody-out tr')).forEach(function(tr) {
var saddr = (tr.querySelector('.cell-mono') || {}).textContent || '';
tr.dataset.saddr = saddr;
tr.querySelector('td:first-child').innerHTML = '<input type="checkbox">';
});
});
/* ---- In ---- */
Router.register('#in', function(c) {
c.innerHTML = renderSignalPage('in',
['<input type="checkbox" style="pointer-events:none;opacity:0">', I18n.t('signal.saddr'), I18n.t('signal.desc'), I18n.t('signal.link'), I18n.t('signal.type')],
function(s) { return td(esc(s.link_saddr || '\u2014'), 'cell-mono') + td(esc(s.type || '?'), 'cell-mono'); },
function(s) {
return '<button class="btn btn-danger btn-sm" onclick="SignalActions.del(\'in\',\'' + esc(s.saddr) + '\')">' + Icon.get('trash') + '</button>';
}
);
[].slice.call(document.querySelectorAll('#tbody-in tr')).forEach(function(tr) {
var saddr = (tr.querySelector('.cell-mono') || {}).textContent || '';
tr.dataset.saddr = saddr;
tr.querySelector('td:first-child').innerHTML = '<input type="checkbox">';
});
});
/* ---- YK ---- */
Router.register('#yk', function(c) {
c.innerHTML = renderSignalPage('yk',
[I18n.t('signal.saddr'), I18n.t('signal.desc'), I18n.t('signal.type'), I18n.t('signal.ctrl')],
function(s) { return td(esc(s.type || '?'), 'cell-mono') + td(s.ctrl_type == 1 ? I18n.t('signal.ctrl_sbo') : I18n.t('signal.ctrl_direct'), 'cell-mono'); },
function(s) {
return '<div class="btn-group">'
+ '<button class="btn btn-sm" onclick="SignalActions.set(\'yk\',\'' + esc(s.saddr) + '\')">' + Icon.get('edit') + ' ' + I18n.t('signal.set') + '</button>'
+ '<button class="btn btn-danger btn-sm" onclick="SignalActions.del(\'yk\',\'' + esc(s.saddr) + '\')">' + Icon.get('trash') + '</button>'
+ '</div>';
}
);
});
/* ---- AO ---- */
Router.register('#ao', function(c) {
c.innerHTML = renderSignalPage('ao',
[I18n.t('signal.saddr'), I18n.t('signal.desc'), I18n.t('signal.type'), I18n.t('signal.ctrl')],
function(s) { return td(esc(s.type || '?'), 'cell-mono') + td(s.ctrl_type == 1 ? I18n.t('signal.ctrl_sbo') : I18n.t('signal.ctrl_direct'), 'cell-mono'); },
function(s) {
return '<div class="btn-group">'
+ '<button class="btn btn-sm" onclick="SignalActions.set(\'ao\',\'' + esc(s.saddr) + '\')">' + Icon.get('edit') + ' ' + I18n.t('signal.set') + '</button>'
+ '<button class="btn btn-danger btn-sm" onclick="SignalActions.del(\'ao\',\'' + esc(s.saddr) + '\')">' + Icon.get('trash') + '</button>'
+ '</div>';
}
);
});

View File

@ -0,0 +1,155 @@
/**
* @file terminal.js
* @brief 命令终端页面 按钮面板 + 输入框
* @details 连接后自动获取命令列表按模块分组生成按钮
* 点击按钮将命令名填充到输入框用户可继续输入子命令参数
* 回车执行输出显示在下方窗口
*/
var TermCommands = [];
Router.register('#terminal', function(c) {
c.innerHTML =
'<div class="card">' +
' <div class="card-header"><span>' + I18n.t('term.title') + '</span></div>' +
' <div class="terminal-wrap">' +
' <div class="cmd-panels" id="cmd-panels">' +
' <div style="padding:16px;color:#888;">' + (I18n.t('term.loading') || '正在加载命令列表...') + '</div>' +
' </div>' +
' <div class="terminal-output" id="term-out">' + I18n.t('term.welcome') + '</div>' +
' <div class="terminal-input-row">' +
' <span class="terminal-prompt">RTU&gt;</span>' +
' <input class="terminal-input" id="term-input" placeholder="点击上方按钮填充命令,或直接输入..." ' +
' autocomplete="off" onkeydown="handleTermKey(event)">' +
' </div>' +
' </div>' +
'</div>';
if (TermCommands.length > 0)
{
renderCmdPanels(TermCommands);
}
else
{
WsClient.send({ type: 'get_cmds' });
}
});
/* ---- 命令分组 ---- */
function groupCommands(cmds)
{
var groups = {};
for (var i = 0; i < cmds.length; i++)
{
var mod = 'other';
var name = cmds[i].name;
if (name.indexOf('datacenter') === 0) mod = 'datacenter';
else if (name.indexOf('iec') === 0) mod = 'iec';
else if (name.indexOf('plc') === 0) mod = 'plc';
else if (name.indexOf('self_ptl') === 0) mod = 'self_ptl';
else if (name.indexOf('modbus_m') === 0) mod = 'modbus_m';
else if (name === 'help' || name === 'version') mod = 'system';
if (!groups[mod]) groups[mod] = [];
groups[mod].push(cmds[i]);
}
return groups;
}
var groupMeta = {
'datacenter': { icon: '&#x1F4CA;', label: '数据中心' },
'iec': { icon: '&#x26A1;', label: 'IEC-104' },
'plc': { icon: '&#x1F39B;', label: 'PLC 逻辑' },
'self_ptl': { icon: '&#x1F517;', label: '自协议' },
'modbus_m': { icon: '&#x1F4E1;', label: 'Modbus 主站' },
'system': { icon: '&#x2699;', label: '系统' },
'other': { icon: '&#x1F4E6;', label: '其他' },
};
/* ---- 渲染命令面板 ---- */
function renderCmdPanels(cmds)
{
TermCommands = cmds;
var panels = document.getElementById('cmd-panels');
if (!panels) return;
var groups = groupCommands(cmds);
var html = '';
for (var mod in groups)
{
var meta = groupMeta[mod] || { icon: '', label: mod };
html += '<div class="cmd-group">';
html += '<div class="cmd-group-title">' + meta.icon + ' ' + meta.label + '</div>';
html += '<div class="cmd-group-btns">';
for (var j = 0; j < groups[mod].length; j++)
{
var cmd = groups[mod][j];
html += '<button class="cmd-btn" title="' + esc(cmd.desc || '') + '" ' +
'onclick="fillTermCmd(\'' + esc(cmd.name) + '\')">' +
'<span class="cmd-btn-name">' + esc(cmd.name) + '</span>' +
'<span class="cmd-btn-desc">' + esc(cmd.desc || '') + '</span>' +
'</button>';
}
html += '</div></div>';
}
panels.innerHTML = html;
}
/* ---- 填充命令到输入框 ---- */
function fillTermCmd(cmd)
{
var input = document.getElementById('term-input');
if (!input) return;
input.value = cmd + ' ';
input.focus();
}
/* ---- 回车执行 ---- */
function handleTermKey(e)
{
if (e.key !== 'Enter') return;
var input = document.getElementById('term-input');
var out = document.getElementById('term-out');
if (!input || !out) return;
var cmd = input.value.trim();
if (!cmd) return;
/* 本地清屏 */
if (cmd === 'clear')
{
out.textContent = '';
input.value = '';
return;
}
/* 显示已执行命令 */
out.textContent += '\n> ' + cmd + '\n';
out.scrollTop = out.scrollHeight;
/* 发送到后端 */
WsClient.send({ type: 'cmd', data: cmd });
input.value = '';
}
/* ---- WebSocket 二进制消息追加到输出窗口 ---- */
function termOnBinary(text)
{
var out = document.getElementById('term-out');
if (!out) return;
out.textContent += text;
out.scrollTop = out.scrollHeight;
}

112
config/web/js/router.js Normal file
View File

@ -0,0 +1,112 @@
/**
* @file router.js
* @brief Hash-based SPA 路由器 页面注册 + 导航 + 调度
*/
var Router = {
pages: {},
currentHash: '',
/**
* @brief 注册页面渲染函数
* @param {string} hash '#dashboard'
* @param {function} render 渲染回调 (container)
*/
register: function(hash, render) {
this.pages[hash] = { render: render };
},
/**
* @brief 调度到指定 hash
*/
dispatch: function(hash) {
if (!hash) { hash = window.location.hash || '#dashboard'; }
/* 登录守卫 */
if (hash !== '#login' && !localStorage.getItem('rtu_logged_in'))
{
window.location.hash = '#login';
return;
}
if (hash === '#login' && localStorage.getItem('rtu_logged_in'))
{
window.location.hash = '#dashboard';
return;
}
if (hash === this.currentHash) return;
this.currentHash = hash;
updateNavActive(hash);
var page = this.pages[hash];
if (!page) { window.location.hash = '#dashboard'; return; }
var titleMap = {
'#dashboard': I18n.t('dash.title'),
'#out': I18n.t('nav.out'),
'#in': I18n.t('nav.in'),
'#yk': I18n.t('nav.yk'),
'#ao': I18n.t('nav.ao'),
'#param': I18n.t('nav.param'),
'#datacenter': I18n.t('nav.datacenter'),
'#plc_debug': I18n.t('nav.plc_debug'),
'#plc_config': I18n.t('nav.plc_config'),
'#monitor': I18n.t('nav.monitor'),
'#terminal': I18n.t('nav.terminal'),
'#login': I18n.t('login.title')
};
document.getElementById('page-title').textContent = titleMap[hash] || '';
document.getElementById('page-subtitle').textContent = '';
var container = document.getElementById('page-container');
container.innerHTML = '';
page.render(container);
}
};
/* ---- 侧栏导航 ---- */
var NAV = [
{ h: '#dashboard', key: 'nav.dashboard', icon: 'dashboard' },
{ h: '#out', key: 'nav.out', icon: 'signal', section: 'signal_mgmt' },
{ h: '#in', key: 'nav.in', icon: 'link' },
{ h: '#yk', key: 'nav.yk', icon: 'zap' },
{ h: '#ao', key: 'nav.ao', icon: 'sliders' },
{ h: '#param', key: 'nav.param', icon: 'settings' },
{ h: '#datacenter', key: 'nav.datacenter', icon: 'database', section: 'tools' },
{ h: '#plc_debug', key: 'nav.plc_debug', icon: 'bug' },
{ h: '#plc_config', key: 'nav.plc_config', icon: 'cpu' },
{ h: '#monitor', key: 'nav.monitor', icon: 'activity' },
{ h: '#terminal', key: 'nav.terminal', icon: 'terminal' },
];
function buildNav()
{
var html = '';
var lastSection = null;
NAV.forEach(function(item) {
if (item.section && item.section !== lastSection)
{
var secKey = 'nav.section.' + item.section;
html += '<div class="nav-section-title">' + I18n.t(secKey) + '</div>';
lastSection = item.section;
}
html += '<a href="' + item.h + '" data-hash="' + item.h + '">' + Icon.get(item.icon, 'nav-icon') + I18n.t(item.key) + '</a>';
});
document.getElementById('nav-links').innerHTML = html;
}
function updateNavActive(hash)
{
document.querySelectorAll('#nav-links a').forEach(function(a) {
a.classList.toggle('active', a.dataset.hash === hash);
});
}
function toggleNav()
{
document.getElementById('nav-sidebar').classList.toggle('open');
}

View File

@ -0,0 +1,61 @@
/**
* @file signal-store.js
* @brief 信号数据缓存 接收 WS 推送并更新内存 + localStorage 持久化
*/
var SignalStore = {
data: { out: [], in: [], yk: [], ao: [], param: [] },
/**
* @brief 合并 WS 推送数据到缓存, 并持久化到 localStorage
* @param {object} push {out:[...], in:[...], ...}
*/
update: function(push) {
var self = this;
['out', 'in', 'yk', 'ao', 'param'].forEach(function(type) {
if (push[type])
{
var map = {};
self.data[type].forEach(function(s) { map[s.saddr] = s; });
push[type].forEach(function(s) { map[s.saddr] = s; });
self.data[type] = Object.keys(map).map(function(k) { return map[k]; });
}
});
this._save();
},
get: function(type) {
return this.data[type] || [];
},
addFromPush: function(type, item) {
this.data[type].push(item);
this._save();
},
remove: function(type, saddr) {
this.data[type] = this.data[type].filter(function(s) { return s.saddr !== saddr; });
this._save();
},
/** @brief 从 localStorage 恢复缓存 */
restore: function() {
try {
var saved = localStorage.getItem('rtu_signals');
if (saved) {
var parsed = JSON.parse(saved);
if (parsed && parsed.out) { this.data = parsed; }
}
} catch (e) { /* ignore corrupt data */ }
},
/** @brief 持久化到 localStorage (debounced) */
_save: function() {
var self = this;
if (this._timer) clearTimeout(this._timer);
this._timer = setTimeout(function() {
try { localStorage.setItem('rtu_signals', JSON.stringify(self.data)); } catch (e) {}
}, 500);
}
};

220
config/web/js/ui.js Normal file
View File

@ -0,0 +1,220 @@
/**
* @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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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, headers, extraCols, actionsForRow) {
var signals = SignalStore.get(type);
var rows = '';
if (signals.length === 0)
{
rows = '<tr class="empty-row"><td colspan="' + headers.length + '">' + I18n.t('signal.no_data') + '</td></tr>';
}
else
{
signals.forEach(function(s) {
var xtra = extraCols ? extraCols(s) : '';
var acts = actionsForRow ? actionsForRow(s) : '';
rows += '<tr>'
+ '<td class="cell-mono">' + esc(s.saddr) + '</td>'
+ '<td class="cell-desc">' + esc(s.desc || '') + '</td>'
+ xtra
+ '<td class="cell-val">' + fmtVal(s.val) + '</td>'
+ '<td>' + acts + '</td>'
+ '</tr>';
});
}
return '<div class="card">'
+ '<div class="card-header"><span>' + type.toUpperCase() + ' \u2014 ' + 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>'
+ '</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>';
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')
{
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();
});
},
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);
Router.dispatch(Router.currentHash);
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 saddr = cb.closest('tr').dataset.saddr;
WsClient.send({ saddr: saddr, signal_type: type, curd: 'del', setting_zone: '0' });
});
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'));
}
};

103
config/web/js/ws-client.js Normal file
View File

@ -0,0 +1,103 @@
/**
* @file ws-client.js
* @brief WebSocket 客户端封装 自动重连 + 事件分发
*/
var WsClient = {
ws: null, url: '', status: 'disconnected', timer: null,
msgCbs: [], statusCbs: [],
onMsg: function(cb) { this.msgCbs.push(cb); },
onStatus: function(cb) { this.statusCbs.push(cb); },
connect: function(url) {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING))
{
return;
}
this.url = url;
this._setStatus('connecting');
try
{
this.ws = new WebSocket(url);
this.ws.binaryType = 'arraybuffer';
}
catch (e)
{
this._setStatus('disconnected');
this._schedule();
return;
}
this.ws.onopen = function() {
var self = WsClient;
self._setStatus('connected');
if (self.timer) { clearTimeout(self.timer); self.timer = null; }
self.send({ type: 'get_cmds' });
};
this.ws.onclose = function() {
var self = WsClient;
self._setStatus('disconnected');
self.ws = null;
self._schedule();
};
this.ws.onerror = function() {};
this.ws.onmessage = function(e) {
var self = WsClient;
var raw = e.data;
if (raw instanceof ArrayBuffer)
{
self.msgCbs.forEach(function(cb) { try { cb(null, raw); } catch(ex) {} });
return;
}
var data = null;
try { data = JSON.parse(raw); } catch(ex) {}
self.msgCbs.forEach(function(cb) { try { cb(data || raw, null); } catch(ex) {} });
};
},
send: function(obj) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
{
if (typeof toast === 'function') { toast(I18n.t('toast.ws_error'), 'error'); }
return;
}
var raw = (typeof obj === 'object') ? JSON.stringify(obj) : String(obj);
this.ws.send(raw);
},
_setStatus: function(s) {
if (this.status === s) return;
this.status = s;
var dot = document.getElementById('nav-status-dot');
var txt = document.getElementById('nav-status-text');
if (dot) { dot.className = 'nav-status-dot ' + s; }
if (txt)
{
var map = { connected: I18n.t('status.connected'), disconnected: I18n.t('status.disconnected'), connecting: I18n.t('status.connecting') };
txt.textContent = map[s] || s;
}
this.statusCbs.forEach(function(cb) { cb(s); });
},
_schedule: function() {
var self = this;
if (self.timer) return;
self.timer = setTimeout(function() {
self.timer = null;
if (self.status === 'disconnected') { self.connect(self.url); }
}, 3000);
}
};

View File

@ -27,6 +27,7 @@
static struct mg_mgr s_ws_mgr;
static std::vector<mg_connection *> g_ws_conns;
static pthread_mutex_t g_ws_conns_mutex = PTHREAD_MUTEX_INITIALIZER;
static std::string g_web_root; /**< 静态文件根目录 */
/* ---- 前向声明 ---- */
void ws_recv(struct mg_connection *c, const char *p_rx, uint16_t rx_len);
@ -145,8 +146,10 @@ static void ws_event_handler(mg_connection *c, int ev, void *ev_data)
}
else
{
/* HTTP 静态文件: 默认返回 404 */
mg_http_reply(c, 404, "Content-Type: text/plain\r\n", "Not Found\n");
/* HTTP 静态文件服务 — 从 web/ 目录提供 */
struct mg_http_serve_opts opts = {};
opts.root_dir = g_web_root.c_str();
mg_http_serve_dir(c, hm, &opts);
}
}
else if (ev == MG_EV_WS_MSG)
@ -366,10 +369,23 @@ int app_web_server_init1(void *arg)
{
(void)arg;
/* 静态文件根目录: <exe_dir>/web/ */
char proc_dir[512] = {0};
if (0 == func_proc_self_dir(proc_dir, sizeof(proc_dir)))
{
g_web_root = std::string(proc_dir) + "/config/web";
}
else
{
g_web_root = "web"; /* fallback: 相对路径 */
}
mg_log_set(MG_LL_ERROR);
mg_mgr_init(&s_ws_mgr);
mg_http_listen(&s_ws_mgr, WS_LISTEN_ON, ws_event_handler, NULL);
LOG_I("web_server listening on %s, ws=%s", WS_LISTEN_ON, WS_UPGRADE_URI);
MY_LOG_I("web_server listening on %s, ws=%s, root=%s",
WS_LISTEN_ON, WS_UPGRADE_URI, g_web_root.c_str());
return 0;
}
@ -384,7 +400,7 @@ void *app_web_server(void *arg)
{
if (!arg)
{
LOG_E("app_web_server arg null");
MY_LOG_E("app_web_server arg null");
return NULL;
}
@ -403,7 +419,7 @@ void *app_web_server(void *arg)
if (event & EV_STOP)
{
LOG_I("web_server stopping");
MY_LOG_I("web_server stopping");
break;
}
@ -424,12 +440,14 @@ void *app_web_server(void *arg)
{
cnt_1s++;
p_app->run_cnt++;
LOG_I("web_server run_cnt=%u", p_app->run_cnt);
}
}
mg_mgr_free(&s_ws_mgr);
LOG_I("web_server stopped, total 1s cycles=%u", cnt_1s);
MY_LOG_I("web_server stopped, total 1s cycles=%u", cnt_1s);
return NULL;
}

View File

@ -517,6 +517,125 @@ void ws_recv(struct mg_connection *c, const char *p_rx, uint16_t rx_len)
std::string out = ws_cmd_exec(jd->valuestring);
ws_send_binary(c->id, out.data(), out.size());
/* datacenter 命令特殊处理:导出 XML + 返回信号元数据 */
if (0 == strncmp(jd->valuestring, "datacenter", 10))
{
const char *sub = jd->valuestring + 10;
while (*sub == ' ' || *sub == '\t')
{
sub++;
}
/* 导出 out 信号 XMLPLC Config 页面依赖) */
if (0 == strcmp(sub, "out") || 0 == strncmp(sub, "out ", 4)
|| 0 == strcmp(sub, "all") || 0 == strncmp(sub, "all ", 4))
{
char resolved[512];
char proc_dir[256] = {0};
if (0 == func_proc_self_dir(proc_dir, sizeof(proc_dir)))
{
snprintf(resolved, sizeof(resolved),
"%sconfig/SYSTEM/datacenter_out.xml", proc_dir);
dc_save_out_signals_xml(resolved);
LOG_I("datacenter_out.xml saved to %s", resolved);
}
}
/* 构建信号元数据 JSON */
const char *dc_types[5];
int dc_type_count = 0;
if (0 == strcmp(sub, "all") || 0 == strncmp(sub, "all ", 4))
{
dc_types[dc_type_count++] = "out";
dc_types[dc_type_count++] = "in";
dc_types[dc_type_count++] = "yk";
dc_types[dc_type_count++] = "ao";
dc_types[dc_type_count++] = "param";
}
else if (0 == strcmp(sub, "out") || 0 == strncmp(sub, "out ", 4))
{
dc_types[dc_type_count++] = "out";
}
else if (0 == strcmp(sub, "in") || 0 == strncmp(sub, "in ", 3))
{
dc_types[dc_type_count++] = "in";
}
else if (0 == strcmp(sub, "yk") || 0 == strncmp(sub, "yk ", 3))
{
dc_types[dc_type_count++] = "yk";
}
else if (0 == strcmp(sub, "ao") || 0 == strncmp(sub, "ao ", 3))
{
dc_types[dc_type_count++] = "ao";
}
else if (0 == strcmp(sub, "param") || 0 == strncmp(sub, "param ", 6))
{
dc_types[dc_type_count++] = "param";
}
else
{
/* 默认返回全部 */
dc_types[dc_type_count++] = "out";
dc_types[dc_type_count++] = "in";
dc_types[dc_type_count++] = "yk";
dc_types[dc_type_count++] = "ao";
dc_types[dc_type_count++] = "param";
}
/* 构建 dc_data JSON */
cJSON *dc_resp = cJSON_CreateObject();
cJSON_AddStringToObject(dc_resp, "type", "dc_data");
for (int si = 0; si < dc_type_count; si++)
{
cJSON *arr = cJSON_CreateArray();
uint32_t count = dc_get_signal_count(dc_types[si]);
for (uint32_t j = 0; j < count; j++)
{
char saddr[128] = {0};
char desc[256] = {0};
char dts[32] = {0};
uint8_t ctrl_type = 0;
char link_str[256] = {0};
if (0 != dc_get_signal_info_by_id(dc_types[si], j,
saddr, sizeof(saddr),
desc, sizeof(desc),
dts, sizeof(dts),
&ctrl_type,
link_str, sizeof(link_str)))
{
continue;
}
cJSON *item = cJSON_CreateObject();
cJSON_AddNumberToObject(item, "id", j);
cJSON_AddStringToObject(item, "saddr", saddr);
cJSON_AddStringToObject(item, "desc", desc);
cJSON_AddStringToObject(item, "type", dts);
cJSON_AddNumberToObject(item, "ctrl_type", ctrl_type);
cJSON_AddStringToObject(item, "link_saddrs", link_str);
cJSON_AddItemToArray(arr, item);
}
cJSON_AddItemToObject(dc_resp, dc_types[si], arr);
}
char *dc_tx = cJSON_PrintUnformatted(dc_resp);
if (dc_tx)
{
ws_send_binary(c->id, dc_tx, strlen(dc_tx));
free(dc_tx);
}
cJSON_Delete(dc_resp);
}
}
cJSON_Delete(root);
@ -557,6 +676,131 @@ void ws_recv(struct mg_connection *c, const char *p_rx, uint16_t rx_len)
return;
}
/* ---- type=get_file: 读取配置文件 ---- */
if (0 == strcmp(jt->valuestring, "get_file"))
{
cJSON *jpath = cJSON_GetObjectItem(root, "path");
cJSON *resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "type", "file_content");
if (jpath && cJSON_IsString(jpath) && jpath->valuestring)
{
char resolved[512] = {0};
if (jpath->valuestring[0] == '/')
{
strncpy(resolved, jpath->valuestring, sizeof(resolved) - 1);
}
else
{
char proc_dir[256] = {0};
if (0 == func_proc_self_dir(proc_dir, sizeof(proc_dir)))
{
snprintf(resolved, sizeof(resolved), "%s%s", proc_dir, jpath->valuestring);
}
else
{
strncpy(resolved, jpath->valuestring, sizeof(resolved) - 1);
}
}
cJSON_AddStringToObject(resp, "path", jpath->valuestring);
FILE *fp = fopen(resolved, "rb");
if (fp)
{
fseek(fp, 0, SEEK_END);
long fsize = ftell(fp);
fseek(fp, 0, SEEK_SET);
if (fsize > 0 && fsize < 1024 * 1024)
{
char *buf = (char *)malloc((size_t)fsize + 1);
if (buf)
{
size_t n = fread(buf, 1, (size_t)fsize, fp);
buf[n] = '\0';
cJSON_AddStringToObject(resp, "content", buf);
free(buf);
}
}
fclose(fp);
}
}
char *p_tx = cJSON_PrintUnformatted(resp);
if (p_tx)
{
ws_send_one(c->id, p_tx, strlen(p_tx));
free(p_tx);
}
cJSON_Delete(resp);
cJSON_Delete(root);
return;
}
/* ---- type=set_file: 保存配置文件 ---- */
if (0 == strcmp(jt->valuestring, "set_file"))
{
cJSON *jpath = cJSON_GetObjectItem(root, "path");
cJSON *jcontent = cJSON_GetObjectItem(root, "content");
cJSON *resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "type", "file_saved");
if (jpath && cJSON_IsString(jpath) && jcontent && cJSON_IsString(jcontent))
{
char resolved[512] = {0};
if (jpath->valuestring[0] == '/')
{
strncpy(resolved, jpath->valuestring, sizeof(resolved) - 1);
}
else
{
char proc_dir[256] = {0};
if (0 == func_proc_self_dir(proc_dir, sizeof(proc_dir)))
{
snprintf(resolved, sizeof(resolved), "%s%s", proc_dir, jpath->valuestring);
}
else
{
strncpy(resolved, jpath->valuestring, sizeof(resolved) - 1);
}
}
FILE *fp = fopen(resolved, "wb");
if (fp)
{
fwrite(jcontent->valuestring, 1, strlen(jcontent->valuestring), fp);
fclose(fp);
cJSON_AddBoolToObject(resp, "success", 1);
cJSON_AddStringToObject(resp, "path", jpath->valuestring);
}
}
char *p_tx = cJSON_PrintUnformatted(resp);
if (p_tx)
{
ws_send_one(c->id, p_tx, strlen(p_tx));
free(p_tx);
}
cJSON_Delete(resp);
cJSON_Delete(root);
return;
}
}
/* ---- 信号 CRUD 命令 ---- */