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:
parent
47d9c6273d
commit
06fc257696
|
|
@ -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); }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
})();
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
};
|
||||
})();
|
||||
|
|
@ -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>';
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
@ -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></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: '📊', label: '数据中心' },
|
||||
'iec': { icon: '⚡', label: 'IEC-104' },
|
||||
'plc': { icon: '🎛', label: 'PLC 逻辑' },
|
||||
'self_ptl': { icon: '🔗', label: '自协议' },
|
||||
'modbus_m': { icon: '📡', label: 'Modbus 主站' },
|
||||
'system': { icon: '⚙', label: '系统' },
|
||||
'other': { icon: '📦', 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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function fmtVal(v) {
|
||||
return (v == null || v === '') ? '\u2014' : esc(String(v));
|
||||
}
|
||||
|
||||
function td(s, cls) {
|
||||
return '<td class="' + (cls || '') + '">' + s + '</td>';
|
||||
}
|
||||
|
||||
function th(s) {
|
||||
return '<th>' + s + '</th>';
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 渲染信号管理页面通用模板
|
||||
*/
|
||||
function renderSignalPage(type, 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'));
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 信号 XML(PLC 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 命令 ---- */
|
||||
|
|
|
|||
Loading…
Reference in New Issue