diff --git a/config/web/css/style.css b/config/web/css/style.css
new file mode 100644
index 0000000..884c662
--- /dev/null
+++ b/config/web/css/style.css
@@ -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); }
+}
diff --git a/config/web/index.html b/config/web/index.html
new file mode 100644
index 0000000..155aaca
--- /dev/null
+++ b/config/web/index.html
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+RTU Web Manager
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/web/js/i18n.js b/config/web/js/i18n.js
new file mode 100644
index 0000000..20be471
--- /dev/null
+++ b/config/web/js/i18n.js
@@ -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';
+ }
+ }
+};
diff --git a/config/web/js/pages/dashboard.js b/config/web/js/pages/dashboard.js
new file mode 100644
index 0000000..e7a68fd
--- /dev/null
+++ b/config/web/js/pages/dashboard.js
@@ -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 ''
+ + '
' + Icon.get(t.icon === 'out' ? 'signal' : t.icon === 'in' ? 'link' : t.icon === 'yk' ? 'zap' : t.icon === 'ao' ? 'sliders' : 'settings') + '
'
+ + '
' + count + '
'
+ + '
' + I18n.t(t.key) + '
'
+ + '
' + I18n.t(t.descKey) + '
'
+ + '
';
+ }).join('');
+
+ c.innerHTML = '' + cards + '
'
+ + ''
+ + ''
+ + '
'
+ + ''
+ + ''
+ + ''
+ + ''
+ + '
'
+ + ''
+ + ''
+ + '
'
+ + I18n.t('dash.url') + ': ws://' + location.hostname + ':8080/ws
'
+ + I18n.t('dash.status') + ': ' + I18n.t('status.disconnected') + '
'
+ + '
';
+
+ 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 = '\n\n';
+ var hasData = false;
+ types.forEach(function(st) {
+ var data = SignalStore.get(st);
+ xml += ' \n';
+ if (data.length > 0) { hasData = true; data.forEach(function(s, i) { xml += ' \n'; }); }
+ xml += ' \n';
+ });
+ xml += '';
+ 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();
+ }
+};
diff --git a/config/web/js/pages/datacenter.js b/config/web/js/pages/datacenter.js
new file mode 100644
index 0000000..2a48f75
--- /dev/null
+++ b/config/web/js/pages/datacenter.js
@@ -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 += '';
+ });
+ 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 = ' | '
+ + 'id | saddr | desc | val | type | ctrl | link | ';
+
+ if (data.length === 0) {
+ tbody.innerHTML = '| ' + I18n.t('dc.no_signal') + ' |
';
+ return;
+ }
+
+ var h = '';
+ data.forEach(function(x) {
+ var chk = !!selectedSet[x.saddr];
+ h += ' | '
+ + '' + fmtVal(x.id) + ' | '
+ + '' + esc(x.saddr) + ' | '
+ + '' + esc(x.desc || '') + ' | '
+ + '' + fmtVal(x.val) + ' | '
+ + '' + esc(x.type || '') + ' | '
+ + '' + (x.ctrl_type == 1 ? 'SBO' : 'Direct') + ' | '
+ + '' + esc(x.link_saddrs || '') + ' |
';
+ });
+ 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 = ''
+ + ''
+ + '
'
+ + '
'
+ + ''
+ + ''
+ + '
'
+ + '
';
+ 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
+ };
+})();
diff --git a/config/web/js/pages/login.js b/config/web/js/pages/login.js
new file mode 100644
index 0000000..960465d
--- /dev/null
+++ b/config/web/js/pages/login.js
@@ -0,0 +1,36 @@
+/**
+ * @file login.js
+ * @brief Login 登录页面
+ */
+
+
+Router.register('#login', function(c) {
+ c.innerHTML = ''
+ + '
' + I18n.t('login.title') + '
'
+ + '
'
+ + '
'
+ + '
'
+ + '
';
+});
+
+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();
+ }
+});
diff --git a/config/web/js/pages/monitor.js b/config/web/js/pages/monitor.js
new file mode 100644
index 0000000..c77d802
--- /dev/null
+++ b/config/web/js/pages/monitor.js
@@ -0,0 +1,62 @@
+/**
+ * @file monitor.js
+ * @brief 实时监控页面 — WebSocket 流量环形缓冲区
+ */
+
+
+Router.register('#monitor', function(c) {
+ c.innerHTML = '';
+ 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 = '' + (up + down) + '
' + I18n.t('mon.total') + '
'
+ + '' + up + '
' + I18n.t('mon.sent') + ' \u2191
'
+ + '' + down + '
' + I18n.t('mon.recv') + ' \u2193
';
+
+ list.innerHTML = this.ring.map(function(r) {
+ return ''
+ + '' + r.dir + ''
+ + '' + r.ts + ''
+ + '' + r.msg + '
';
+ }).reverse().join('');
+
+ list.scrollTop = list.scrollHeight;
+ }
+};
diff --git a/config/web/js/pages/param.js b/config/web/js/pages/param.js
new file mode 100644
index 0000000..a9baa69
--- /dev/null
+++ b/config/web/js/pages/param.js
@@ -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 = '| ' + I18n.t('signal.no_data') + ' |
';
+ }
+ else
+ {
+ signals.forEach(function(s) {
+ var zones = s.setting_zone_list || [];
+ var zoneHtml = '';
+ zones.forEach(function(z) {
+ zoneHtml += ''
+ + '#' + esc(z.id) + ''
+ + '' + esc(z.val) + ''
+ + ''
+ + '
';
+ });
+ rows += ''
+ + '| ' + esc(s.saddr) + ' | '
+ + '' + esc(s.desc || '') + ' | '
+ + '' + esc(s.type || '?') + ' | '
+ + '' + (zoneHtml || '\u2014') + ' | '
+ + ' | '
+ + '
';
+ });
+ }
+
+ c.innerHTML = ''
+ + ''
+ + '
'
+ + '| ' + I18n.t('signal.saddr') + ' | ' + I18n.t('signal.desc') + ' | ' + I18n.t('signal.type') + ' | ' + I18n.t('signal.zone') + ' | ' + I18n.t('signal.action') + ' |
'
+ + '' + rows + ''
+ + '
';
+});
+
+var ParamActions = {
+ set: function(saddr, zoneId) {
+ showModal(I18n.t('signal.set_title') + ' \u2014 ' + esc(saddr) + ' ' + I18n.t('signal.zone') + ' ' + zoneId,
+ '',
+ 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'));
+ }
+};
diff --git a/config/web/js/pages/plc_config.js b/config/web/js/pages/plc_config.js
new file mode 100644
index 0000000..5c129e7
--- /dev/null
+++ b/config/web/js/pages/plc_config.js
@@ -0,0 +1,2529 @@
+/**
+ * @file plc_config.js
+ * @brief PLC 逻辑配置页 — 信号列表 + 逻辑图查看器 + 编辑器
+ * @details Canvas 可视化 + XML 解析/生成, 11 种门类型
+ */
+/* ========================================================================
+ * PLC 逻辑配置页面 — 信号管理 + 逻辑图查看 + 新建配置编辑器
+ * ======================================================================== */
+var PlcConfigPage = (function() {
+ "use strict";
+
+ /* ---- Canvas roundRect polyfill (兼容旧浏览器) ---- */
+ if (!CanvasRenderingContext2D.prototype.roundRect) {
+ CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) {
+ if (typeof r === 'number') r = { tl: r, tr: r, br: r, bl: r };
+ else if (Array.isArray(r)) r = { tl: r[0]||0, tr: r[1]||r[0]||0, br: r[2]||r[0]||0, bl: r[3]||r[0]||0 };
+ this.beginPath();
+ this.moveTo(x + r.tl, y);
+ this.lineTo(x + w - r.tr, y);
+ this.quadraticCurveTo(x + w, y, x + w, y + r.tr);
+ this.lineTo(x + w, y + h - r.br);
+ this.quadraticCurveTo(x + w, y + h, x + w - r.br, y + h);
+ this.lineTo(x + r.bl, y + h);
+ this.quadraticCurveTo(x, y + h, x, y + h - r.bl);
+ this.lineTo(x, y + r.tl);
+ this.quadraticCurveTo(x, y, x + r.tl, y);
+ this.closePath();
+ };
+ }
+
+ /* ========== 全局状态 ========== */
+ var activeTab = 'signals'; // signals | viewer | editor
+ var dcOutSignals = []; // [{no, saddr, desc}] 从 datacenter_out.xml
+ var logicGraphs = []; // 已解析的逻辑图数组
+ var onlineCfgId = '';
+ var loadedFiles = {}; // {path: content} 记录已加载的文件
+
+ /* ======== 编辑器状态 ======== */
+ var editNodes = []; // [{type, id, x, y, saddr, desc, no, inPins, _lx, _ly}]
+ var editLinks = []; // [{srcIdx, dstIdx, dstPin}] — dstPin 目标节点输入引脚序号
+ var editKey = ''; // 逻辑图 key
+ var editName = ''; // 逻辑图名称
+ var editOnlineCfgId = ''; // 当前 online_cfgId
+ var editSelectedNode = -1; // 当前选中节点索引,-1=无选中
+ var editSelectedLink = -1; // 当前选中连线索引,-1=无选中
+ var editWiring = null; // {fromNodeIdx, fromSide, fromPin} 当前连线起点
+ var editDragNode = null; // 拖拽中的节点索引
+ var editDragOffX = 0, editDragOffY = 0;
+ var editDragCanvas = null; // 画布拖拽状态
+ var editPanX = 0, editPanY = 0; // 画布偏移
+ var editScale = 1.0; // 缩放
+ var editNextGateIdx = {}; // {type: nextIdx} 每种门类型独立序号从0开始
+ var editLoadedFromFile = false; // 是否从文件加载
+ var editLastClickTime = 0; // 上次 click 时间戳(双击检测)
+ var editLastClickNode = -1; // 上次 click 的节点索引
+
+ /* ========== 查看器状态 ========== */
+ var viewPanX = 0, viewPanY = 0;
+ var viewScale = 1.0;
+ var viewDrag = null; // {startX, startY, panX, panY}
+ var viewAutoFit = true; // 首次 / resize 后自动适配缩放
+
+ /* ========== 节点类型常量 ========== */
+ var NT_OUTPUT = 0, NT_INPUT = 1, NT_OR = 2, NT_AND = 3, NT_NOT = 4;
+ var NT_62P = 5, NT_62D = 6, NT_RISING = 7, NT_SR = 8, NT_RS = 9, NT_FALLING = 10;
+
+ var NODE_INFO = {
+ 0: { name: '输出点', cls: 'node-out', icon: '📤', color: '#4CAF50', inPins: 1, outPins: 0, inMax: 1, outMax: 0 },
+ 1: { name: '输入点', cls: 'node-in', icon: '📥', color: '#2196F3', inPins: 0, outPins: 1, inMax: 0, outMax: 99 },
+ 2: { name: '或门 OR', cls: 'node-or', icon: '🔶', color: '#FF9800', inPins: 2, outPins: 1, inMax: 10, outMax: 10 },
+ 3: { name: '与门 AND', cls: 'node-and', icon: '🔷', color: '#9C27B0', inPins: 2, outPins: 1, inMax: 10, outMax: 10 },
+ 4: { name: '非门 NOT', cls: 'node-not', icon: '🔺', color: '#f44336', inPins: 1, outPins: 1, inMax: 1, outMax: 10 },
+ 5: { name: '62p延时', cls: 'node-62p', icon: '⏱️', color: '#00BCD4', inPins: 1, outPins: 1, inMax: 1, outMax: 10 },
+ 6: { name: '62d延时', cls: 'node-62d', icon: '⏱️', color: '#009688', inPins: 1, outPins: 1, inMax: 1, outMax: 10 },
+ 7: { name: '上升沿', cls: 'node-re', icon: '⬆️', color: '#E91E63', inPins: 1, outPins: 1, inMax: 1, outMax: 10 },
+ 8: { name: 'SR触发器', cls: 'node-sr', icon: '🔒', color: '#795548', inPins: 2, outPins: 1, inMax: 2, outMax: 10 },
+ 9: { name: 'RS触发器', cls: 'node-rs', icon: '🔒', color: '#607D8B', inPins: 2, outPins: 1, inMax: 2, outMax: 10 },
+ 10: { name: '下降沿', cls: 'node-fe', icon: '⬇️', color: '#FF5722', inPins: 1, outPins: 1, inMax: 1, outMax: 10 }
+ };
+
+ /* ========== 工具函数 ========== */
+ function fmtVal(v) { return (v === null || v === undefined) ? '' : String(v); }
+ function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); }
+
+ /* ---- 通过节点 ID 查找信号信息 (datacenter out 中 no 即 ID) ---- */
+ function findSignalByNo(signalNo) {
+ for (var i = 0; i < dcOutSignals.length; i++) {
+ if (dcOutSignals[i].no === signalNo) return dcOutSignals[i];
+ }
+ return null;
+ }
+
+ /* ---- 生成节点显示标签 (十进制ID, 含saddr/desc) ---- */
+ function getNodeDisplayLabel(node) {
+ var info = NODE_INFO[node.type] || {};
+ if (node.type === NT_INPUT || node.type === NT_OUTPUT) {
+ var prefix = (node.type === NT_INPUT) ? '输入' : '输出';
+ var decId = node.id; // id 在 parse 时已转为十进制
+ var sig = findSignalByNo(decId);
+ if (sig) {
+ return prefix + ' #' + decId + ' ("' + sig.saddr + '", "' + sig.desc + '")';
+ }
+ return prefix + ' #' + decId;
+ }
+ // 逻辑门:显示组件序号
+ var gateIdx = node.id & 0xFF;
+ return info.icon + ' ' + info.name + ' #' + gateIdx;
+ }
+
+ /* ---- 生成编辑器小标签 (简洁版,用于编辑器节点) ---- */
+ function getEditorNodeLabel(node) {
+ var info = NODE_INFO[node.type] || {};
+ if (node.type === NT_INPUT || node.type === NT_OUTPUT) {
+ var prefix = (node.type === NT_INPUT) ? '输入点' : '输出点';
+ var decId = node.id;
+ var sig = node.saddr ? node : findSignalByNo(decId);
+ if (sig && sig.saddr) {
+ return info.icon + ' ' + prefix + ' #' + decId + '\n' + sig.saddr + '\n' + (sig.desc || '');
+ }
+ return info.icon + ' ' + prefix + ' #' + decId;
+ }
+ var gateIdx = node.id & 0xFF;
+ return info.icon + ' ' + info.name + ' #' + gateIdx;
+ }
+
+ function showCfgToast(msg, type) { // wraps global toast
+ type = type || 'info';
+ var t = document.getElementById('plc-cfg-toast');
+ if (!t) {
+ t = document.createElement('div');
+ t.id = 'plc-cfg-toast';
+ t.style.cssText = 'position:fixed;top:80px;left:50%;transform:translateX(-50%);padding:10px 24px;border-radius:6px;z-index:99999;font-size:14px;box-shadow:0 4px 12px rgba(0,0,0,0.3);transition:opacity 0.3s;pointer-events:none;color:#fff';
+ document.body.appendChild(t);
+ }
+ t.style.background = (type === 'err') ? '#f44336' : (type === 'ok') ? '#4CAF50' : '#333';
+ t.textContent = msg; t.style.opacity = '1';
+ clearTimeout(t._timer);
+ t._timer = setTimeout(function() { t.style.opacity = '0'; }, 2000);
+ }
+
+ /* ========== WebSocket 文件请求 ========== */
+ function requestFile(path, callback) {
+ if (loadedFiles[path]) {
+ callback(loadedFiles[path]);
+ return;
+ }
+ if (!PlcConfigPage._pending) PlcConfigPage._pending = {};
+ PlcConfigPage._pending[path] = callback;
+ var sent = WsClient.send({ type: 'get_file', path: path });
+ if (!sent) {
+ if (!PlcConfigPage._retryList) PlcConfigPage._retryList = [];
+ PlcConfigPage._retryList.push(path);
+ }
+ }
+
+ /* ========== 主渲染 ========== */
+ function renderTabs() {
+ var tabs = [
+ { id: 'signals', label: '📋 信号列表', title: 'datacenter_out.xml — 已注册 Out 信号' },
+ { id: 'viewer', label: '👁️ 逻辑图查看', title: 'Reclose_logic.xml 可视化' },
+ { id: 'editor', label: '✏️ 新建配置', title: '新建/编辑 PLC 逻辑配置' }
+ ];
+ var html = '';
+ for (var i = 0; i < tabs.length; i++) {
+ var cls = (tabs[i].id === activeTab) ? 'plc-cfg-tab active' : 'plc-cfg-tab';
+ html += '';
+ }
+ html += '
';
+ return html;
+ }
+
+ function render(container) {
+ container.innerHTML = ''
+ + ''
+ + renderTabs()
+ + '';
+
+ // Tab 切换事件
+ var tabEls = container.querySelectorAll('.plc-cfg-tab');
+ for (var i = 0; i < tabEls.length; i++) {
+ tabEls[i].onclick = function() {
+ activeTab = this.getAttribute('data-tab');
+ render(container);
+ };
+ }
+
+ // 渲染当前 Tab
+ var content = document.getElementById('plc-cfg-content');
+ if (!content) return;
+
+ switch (activeTab) {
+ case 'signals': renderSignalsTab(content); break;
+ case 'viewer': renderViewerTab(content); break;
+ case 'editor': renderEditorTab(content); break;
+ }
+ }
+
+ /* ================================================================
+ * Tab 1: 信号列表 — 解析 datacenter_out.xml
+ * ================================================================ */
+ function renderSignalsTab(container) {
+ container.innerHTML = ''
+ + ''
+ + ''
+ + '| 序号 (no) | 短地址 (saddr) | 描述 (desc) |
'
+ + ''
+ + '
';
+
+ document.getElementById('plc-sig-search').oninput = function() { refreshSigTable(); };
+ document.getElementById('plc-sig-reload').onclick = function() {
+ loadedFiles['config/SYSTEM/datacenter_out.xml'] = null;
+ dcOutSignals = [];
+ loadDcOutSignals(function() { refreshSigTable(); });
+ };
+
+ if (dcOutSignals.length === 0) {
+ loadDcOutSignals(function() { refreshSigTable(); });
+ } else {
+ refreshSigTable();
+ }
+ }
+
+ function loadDcOutSignals(cb) {
+ requestFile('config/SYSTEM/datacenter_out.xml', function(content) {
+ if (!content) { showToast('加载 datacenter_out.xml 失败,请先在终端执行 datacenter out', 'err'); return; }
+ parseDcOutXml(content);
+ if (cb) cb();
+ });
+ }
+
+ function parseDcOutXml(xmlStr) {
+ dcOutSignals = [];
+ try {
+ var parser = new DOMParser();
+ var doc = parser.parseFromString(xmlStr, 'text/xml');
+ var outNodes = doc.documentElement.getElementsByTagName('Out');
+ if (outNodes.length > 0) {
+ var signals = outNodes[0].getElementsByTagName('Signal');
+ for (var i = 0; i < signals.length; i++) {
+ dcOutSignals.push({
+ no: parseInt(signals[i].getAttribute('no')) || i,
+ saddr: signals[i].getAttribute('saddr') || '',
+ desc: signals[i].getAttribute('desc') || ''
+ });
+ }
+ }
+ } catch(e) {
+ showToast('解析 datacenter_out.xml 失败: ' + e.message, 'err');
+ }
+ }
+
+ function refreshSigTable() {
+ var search = (document.getElementById('plc-sig-search').value || '').toLowerCase();
+ var data = dcOutSignals;
+
+ // 过滤
+ var filtered = data;
+ if (search) {
+ filtered = data.filter(function(s) {
+ return s.saddr.toLowerCase().indexOf(search) !== -1
+ || s.desc.toLowerCase().indexOf(search) !== -1
+ || String(s.no).indexOf(search) !== -1;
+ });
+ }
+
+ document.getElementById('plc-sig-count').textContent = '共 ' + filtered.length + ' / ' + data.length + ' 条';
+
+ var html = '';
+ for (var i = 0; i < filtered.length; i++) {
+ var s = filtered[i];
+ html += ''
+ + '| ' + s.no + ' | '
+ + '' + escHtml(s.saddr) + ' | '
+ + '' + escHtml(s.desc) + ' | '
+ + '
';
+ }
+ document.getElementById('plc-sig-tbody').innerHTML = html;
+ }
+
+ /* ================================================================
+ * Tab 2: 逻辑图查看器 — Canvas 可视化 Reclose_logic.txt
+ * ================================================================ */
+ function renderViewerTab(container) {
+ container.innerHTML = ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + '
'
+ + ''
+ + ''
+ + '
'
+ + '';
+
+ // 图例
+ var legendHtml = '图例: ';
+ var types = [1,0,3,2,4,5,6,7,8,9,10];
+ for (var i = 0; i < types.length; i++) {
+ var info = NODE_INFO[types[i]];
+ legendHtml += '' + info.icon + ' ' + info.name + '';
+ }
+ document.getElementById('plc-view-legend').innerHTML = legendHtml;
+
+ // 绑定事件
+ document.getElementById('plc-view-zoomin').onclick = function() { viewScale = Math.min(3, viewScale * 1.2); drawViewer(); };
+ document.getElementById('plc-view-zoomout').onclick = function() { viewScale = Math.max(0.2, viewScale / 1.2); drawViewer(); };
+ document.getElementById('plc-view-reset').onclick = function() { viewAutoFit = true; drawViewer(); };
+ document.getElementById('plc-view-reload').onclick = function() {
+ loadedFiles['config/PLC/Reclose_logic.xml'] = null;
+ loadedFiles['config/PLC/Reclose_logic.xml.bak'] = null;
+ logicGraphs = [];
+ loadLogicFileAll(function() { refreshGraphSelect(); });
+ };
+ document.getElementById('plc-view-graph').onchange = function() { viewAutoFit = true; drawViewer(); };
+
+ // 画布交互
+ setupViewerCanvas();
+
+ if (logicGraphs.length === 0) {
+ loadLogicFileAll(function() { refreshGraphSelect(); });
+ } else {
+ refreshGraphSelect();
+ }
+ }
+
+ function loadLogicFileAll(cb) {
+ var loadCount = 0;
+ var loadedFiles2 = []; // 记录成功加载的文件名
+ var failedFiles = []; // 记录失败的文件名
+ function tryCb() {
+ loadCount++;
+ if (loadCount >= 2 && cb) {
+ if (failedFiles.length > 0) {
+ var msg = loadedFiles2.length > 0
+ ? '已加载: ' + loadedFiles2.join(', ') + ' | 缺失: ' + failedFiles.join(', ')
+ : '配置文件均缺失: ' + failedFiles.join(', ') + ',请先在编辑器中生成配置';
+ showToast(msg, loadedFiles2.length > 0 ? 'info' : 'err');
+ }
+ cb();
+ }
+ }
+ requestFile('config/PLC/Reclose_logic.xml', function(content) {
+ if (!content) { failedFiles.push('Reclose_logic.xml'); tryCb(); return; }
+ parseLogicFile(content, 'Reclose_logic.xml');
+ loadedFiles2.push('Reclose_logic.xml');
+ tryCb();
+ });
+ requestFile('config/PLC/Reclose_logic.xml.bak', function(content) {
+ if (!content) { tryCb(); return; }
+ parseLogicFile(content, 'Reclose_logic.xml.bak');
+ loadedFiles2.push('Reclose_logic.xml.bak');
+ tryCb();
+ });
+ }
+
+
+ function loadLogicFile(cb) {
+ requestFile('config/PLC/Reclose_logic.xml', function(content) {
+ if (!content) { showToast('加载 Reclose_logic.xml 失败', 'err'); return; }
+ parseLogicFile(content, 'Reclose_logic.xml');
+ if (cb) cb();
+ });
+ }
+ function parseLogicFile(content, sourceName) {
+ logicGraphs = logicGraphs.filter(function(g) { return g._source !== sourceName; });
+ onlineCfgId = onlineCfgId || '';
+ parseLogicXml(content, sourceName);
+ }
+
+ // XML → 扁平 graph,与 C++ parse_xml_comb 完全一致
+ function parseLogicXml(xmlStr, sourceName) {
+ var parser = new DOMParser();
+ var doc = parser.parseFromString(xmlStr, 'text/xml');
+ var root = doc.documentElement;
+ if (!root || root.nodeName !== 'PLCConfig') {
+ showToast('XML 根元素不是 PLCConfig', 'err'); return;
+ }
+ if (sourceName.indexOf('.bak') < 0) {
+ onlineCfgId = root.getAttribute('checksum') || '';
+ }
+ var thisKey = root.getAttribute('key') || sourceName;
+ var thisName = root.getAttribute('name') || '';
+ var g = { key: thisKey, name: thisName, nodes: [], links: [] };
+ var GTM = {'INPUT':1,'OUTPUT':0,'OR':2,'AND':3,'NOT':4,'T62P':5,'T62D':6,'RISING':7,'SR':8,'RS':9,'FALLING':10};
+
+ var sigNodeMap = {};
+ function ensureSig(ioType, no, x, y) {
+ var k = ioType + ':' + no;
+ if (sigNodeMap[k] !== undefined) {
+ if (x !== undefined) { g.nodes[sigNodeMap[k]].x = x; g.nodes[sigNodeMap[k]].y = y; }
+ return sigNodeMap[k];
+ }
+ g.nodes.push({ type:ioType, id:no, x:x||0, y:y||0 });
+ sigNodeMap[k] = g.nodes.length - 1;
+ return g.nodes.length - 1;
+ }
+ function ensureGate(type, id, x, y, inPins, delayMs) {
+ for (var i = 0; i < g.nodes.length; i++)
+ if (g.nodes[i].type === type && (g.nodes[i].id & 0xFF) === (id & 0xFF)) return i;
+ var nd = { type:type, id:id, x:x||0, y:y||0 };
+ if (inPins) nd.inPins = inPins;
+ if (delayMs) nd.delayMs = delayMs;
+ g.nodes.push(nd);
+ return g.nodes.length - 1;
+ }
+
+ function parseComb(combEl) {
+ var ge = combEl.firstElementChild;
+ while (ge && ge.nodeName !== 'Gate') ge = ge.nextElementSibling;
+ if (!ge) return -1;
+ var t = GTM[ge.getAttribute('type')] || 0;
+ var gid = parseInt(ge.getAttribute('id'));
+ var gi = ensureGate(t, gid,
+ parseInt(ge.getAttribute('x')), parseInt(ge.getAttribute('y')),
+ ge.getAttribute('inPins') ? parseInt(ge.getAttribute('inPins')) : null,
+ ge.getAttribute('delayMs') ? parseInt(ge.getAttribute('delayMs')) : null);
+
+ // 读取 Comb id
+ var combId = combEl.getAttribute('id');
+ if (combId) g.nodes[gi].combId = parseInt(combId);
+
+ // 读取 SR/RS 的 R/S 属性
+ var rAttr = ge.getAttribute('R');
+ var sAttr = ge.getAttribute('S');
+ if (rAttr) g.nodes[gi].rsR = rAttr;
+ if (sAttr) g.nodes[gi].rsS = sAttr;
+
+ var c = combEl.firstElementChild;
+ while (c) {
+ if (c.nodeName === 'Comb') {
+ var upi = parseComb(c);
+ if (upi >= 0) g.links.push({ src_type:g.nodes[upi].type, src_id:g.nodes[upi].id, dst_type:t, dst_id:gid });
+ }
+ c = c.nextElementSibling;
+ }
+
+ var ie = combEl.firstElementChild;
+ while (ie && ie.nodeName !== 'Inputs') ie = ie.nextElementSibling;
+ if (ie) {
+ var s = ie.firstElementChild;
+ while (s) {
+ if (s.nodeName === 'Signal') {
+ var no = parseInt(s.getAttribute('no'));
+ ensureSig(NT_INPUT, no, parseInt(s.getAttribute('x')), parseInt(s.getAttribute('y')));
+ g.links.push({ src_type:NT_INPUT, src_id:no, dst_type:t, dst_id:gid });
+ }
+ s = s.nextElementSibling;
+ }
+ }
+
+ var oe = combEl.firstElementChild;
+ while (oe && oe.nodeName !== 'Outputs') oe = oe.nextElementSibling;
+ if (oe) {
+ var s = oe.firstElementChild;
+ while (s) {
+ if (s.nodeName === 'Signal') {
+ var no = parseInt(s.getAttribute('no'));
+ ensureSig(NT_OUTPUT, no, parseInt(s.getAttribute('x')), parseInt(s.getAttribute('y')));
+ g.links.push({ src_type:t, src_id:gid, dst_type:NT_OUTPUT, dst_id:no });
+ }
+ s = s.nextElementSibling;
+ }
+ }
+ return gi;
+ }
+
+ var chains = root.getElementsByTagName('Chain');
+ for (var ci = 0; ci < chains.length; ci++) {
+ var chain = chains[ci];
+ var c = chain.firstElementChild;
+ while (c) {
+ if (c.nodeName === 'Inputs') {
+ var s = c.firstElementChild;
+ while (s) {
+ if (s.nodeName === 'Signal')
+ ensureSig(NT_INPUT, parseInt(s.getAttribute('no')),
+ parseInt(s.getAttribute('x')), parseInt(s.getAttribute('y')));
+ s = s.nextElementSibling;
+ }
+ }
+ if (c.nodeName === 'Outputs') {
+ var s = c.firstElementChild;
+ while (s) {
+ if (s.nodeName === 'Signal')
+ ensureSig(NT_OUTPUT, parseInt(s.getAttribute('no')),
+ parseInt(s.getAttribute('x')), parseInt(s.getAttribute('y')));
+ s = s.nextElementSibling;
+ }
+ }
+ if (c.nodeName === 'Comb') parseComb(c);
+ if (c.nodeName === 'Gate') {
+ var gt = GTM[c.getAttribute('type')] || 0;
+ var giChain = ensureGate(gt, parseInt(c.getAttribute('id')),
+ parseInt(c.getAttribute('x')), parseInt(c.getAttribute('y')));
+ var rAttrC = c.getAttribute('R');
+ var sAttrC = c.getAttribute('S');
+ var dmsC = c.getAttribute('delayMs');
+ if (rAttrC) g.nodes[giChain].rsR = rAttrC;
+ if (sAttrC) g.nodes[giChain].rsS = sAttrC;
+ if (dmsC) g.nodes[giChain].delayMs = parseInt(dmsC);
+ }
+ c = c.nextElementSibling;
+ }
+ }
+
+ // Comb链: Comb Gate → 链级 Gate (一个Comb一根线) + 链级 Gate → Outputs
+ // 简单链: Inputs → Gate → Outputs
+ for (var ci2 = 0; ci2 < chains.length; ci2++) {
+ var chain2 = chains[ci2];
+ var hasComb = false;
+ var c3 = chain2.firstElementChild;
+ while (c3) { if (c3.nodeName === 'Comb') { hasComb = true; break; } c3 = c3.nextElementSibling; }
+ var ge2 = null, ie2 = null, oe2 = null;
+ c3 = chain2.firstElementChild;
+ while (c3) {
+ if (c3.nodeName === 'Gate') ge2 = c3;
+ if (c3.nodeName === 'Inputs') ie2 = c3;
+ if (c3.nodeName === 'Outputs') oe2 = c3;
+ c3 = c3.nextElementSibling;
+ }
+ if (hasComb && ge2) {
+ // 链级 Comb → 取其 Gate → 链级 Gate
+ c3 = chain2.firstElementChild;
+ while (c3) {
+ if (c3.nodeName === 'Comb') {
+ var cg = c3.firstElementChild;
+ while (cg && cg.nodeName !== 'Gate') cg = cg.nextElementSibling;
+ if (cg) {
+ var ct = GTM[cg.getAttribute('type')] || 0;
+ var ci = parseInt(cg.getAttribute('id'));
+ g.links.push({ src_type:ct, src_id:ci, dst_type:GTM[ge2.getAttribute('type')] || 0, dst_id:parseInt(ge2.getAttribute('id')) });
+ }
+ }
+ c3 = c3.nextElementSibling;
+ }
+ // 链级 Inputs(如 RS/SR 直接连 Input 信号)→ 链级 Gate
+ if (ie2) {
+ var s = ie2.firstElementChild;
+ while (s) { if (s.nodeName === 'Signal') g.links.push({ src_type:NT_INPUT, src_id:parseInt(s.getAttribute('no')), dst_type:GTM[ge2.getAttribute('type')] || 0, dst_id:parseInt(ge2.getAttribute('id')) }); s = s.nextElementSibling; }
+ }
+ // RS/SR 的 R/S 属性引用的 Input 信号(无 块时也需创建链接)
+ if (ge2) {
+ var gtCS = GTM[ge2.getAttribute('type')] || 0;
+ if (gtCS === NT_SR || gtCS === NT_RS) {
+ var sAttrCS = ge2.getAttribute('S');
+ var rAttrCS = ge2.getAttribute('R');
+ if (sAttrCS && sAttrCS.indexOf('Input#') === 0) {
+ var sNo = parseInt(sAttrCS.substring(6));
+ ensureSig(NT_INPUT, sNo, 0, 0);
+ g.links.push({ src_type:NT_INPUT, src_id:sNo, dst_type:gtCS, dst_id:parseInt(ge2.getAttribute('id')) });
+ }
+ if (rAttrCS && rAttrCS.indexOf('Input#') === 0) {
+ var rNo = parseInt(rAttrCS.substring(6));
+ ensureSig(NT_INPUT, rNo, 0, 0);
+ g.links.push({ src_type:NT_INPUT, src_id:rNo, dst_type:gtCS, dst_id:parseInt(ge2.getAttribute('id')) });
+ }
+ }
+ }
+ if (oe2) {
+ var s = oe2.firstElementChild;
+ while (s) { if (s.nodeName === 'Signal') g.links.push({ src_type:GTM[ge2.getAttribute('type')] || 0, src_id:parseInt(ge2.getAttribute('id')), dst_type:NT_OUTPUT, dst_id:parseInt(s.getAttribute('no')) }); s = s.nextElementSibling; }
+ }
+ } else if (!hasComb && ge2) {
+ var gt = GTM[ge2.getAttribute('type')] || 0;
+ var gid = parseInt(ge2.getAttribute('id'));
+ if (ie2) {
+ var s = ie2.firstElementChild;
+ while (s) { if (s.nodeName === 'Signal') g.links.push({ src_type:NT_INPUT, src_id:parseInt(s.getAttribute('no')), dst_type:gt, dst_id:gid }); s = s.nextElementSibling; }
+ }
+ if (oe2) {
+ var s = oe2.firstElementChild;
+ while (s) { if (s.nodeName === 'Signal') g.links.push({ src_type:gt, src_id:gid, dst_type:NT_OUTPUT, dst_id:parseInt(s.getAttribute('no')) }); s = s.nextElementSibling; }
+ }
+ }
+ }
+
+ // 最终遍:为 SR/RS 节点修复 dstPin
+ for (var ni = 0; ni < g.nodes.length; ni++) {
+ var nd = g.nodes[ni];
+ if (nd.type !== NT_SR && nd.type !== NT_RS) continue;
+ var sSrc = nd.rsS || '';
+ var rSrc = nd.rsR || '';
+ if (!sSrc && !rSrc) continue;
+ // 遍历所有到该节点的链路
+ for (var lj = 0; lj < g.links.length; lj++) {
+ var lk = g.links[lj];
+ if (lk.dst_type !== nd.type || (lk.dst_id & 0xFF) !== (nd.id & 0xFF)) continue;
+ var srcDesc = '';
+ var sN = null;
+ for (var sn = 0; sn < g.nodes.length; sn++) {
+ if (g.nodes[sn].type === lk.src_type &&
+ (g.nodes[sn].type <= 1 ? g.nodes[sn].id === lk.src_id : (g.nodes[sn].id & 0xFF) === (lk.src_id & 0xFF))) {
+ sN = g.nodes[sn]; break;
+ }
+ }
+ if (!sN) continue;
+ if (sN.type === NT_INPUT) srcDesc = 'Input#' + sN.id;
+ else srcDesc = 'Comb#' + (sN.combId !== undefined ? sN.combId : (sN.id & 0xFF));
+ if (nd.type === NT_SR) {
+ if (sSrc && srcDesc === sSrc) lk.dstPin = 0;
+ else if (rSrc && srcDesc === rSrc) lk.dstPin = 1;
+ } else {
+ if (sSrc && srcDesc === sSrc) lk.dstPin = 1;
+ else if (rSrc && srcDesc === rSrc) lk.dstPin = 0;
+ }
+ }
+ }
+
+ g._source = sourceName;
+ logicGraphs.push(g);
+ }
+
+ function refreshGraphSelect() {
+ var sel = document.getElementById('plc-view-graph');
+ if (!sel) return;
+ sel.innerHTML = '';
+ for (var i = 0; i < logicGraphs.length; i++) {
+ var g = logicGraphs[i];
+ var bakTag = (g._source && g._source.indexOf(".bak") >= 0) ? " [备份]" : "";
+ var label = g.key + " — " + (g.name || "(未命名)") + bakTag + " (" + g.nodes.length + "节点 " + g.links.length + "连线)";
+ sel.innerHTML += '';
+ }
+ if (logicGraphs.length > 0) {
+ sel.value = '0';
+ setTimeout(function() { drawViewer(); }, 100);
+ }
+ }
+
+ /* ---- 查看器 Canvas ---- */
+ function setupViewerCanvas() {
+ var wrap = document.getElementById('plc-view-canvas-wrap');
+ var canvas = document.getElementById('plc-view-canvas');
+ if (!wrap || !canvas) return;
+
+ function resize() {
+ canvas.width = wrap.clientWidth;
+ canvas.height = wrap.clientHeight - 4;
+ viewAutoFit = true;
+ drawViewer();
+ }
+ resize();
+ window.addEventListener('resize', function() { resize(); });
+
+ // 鼠标事件
+ canvas.onmousedown = function(e) {
+ viewDrag = { startX: e.clientX, startY: e.clientY, panX: viewPanX, panY: viewPanY };
+ };
+ canvas.onmousemove = function(e) {
+ if (!viewDrag) return;
+ viewPanX = viewDrag.panX + (e.clientX - viewDrag.startX);
+ viewPanY = viewDrag.panY + (e.clientY - viewDrag.startY);
+ drawViewer();
+ };
+ canvas.onmouseup = function() { viewDrag = null; };
+ canvas.onmouseleave = function() { viewDrag = null; };
+ canvas.onwheel = function(e) {
+ e.preventDefault();
+ var delta = e.deltaY > 0 ? 0.9 : 1.1;
+ var newScale = Math.max(0.2, Math.min(3, viewScale * delta));
+ // 以鼠标位置为中心缩放
+ var rect = canvas.getBoundingClientRect();
+ var mx = e.clientX - rect.left;
+ var my = e.clientY - rect.top;
+ viewPanX = mx - (mx - viewPanX) * (newScale / viewScale);
+ viewPanY = my - (my - viewPanY) * (newScale / viewScale);
+ viewScale = newScale;
+ drawViewer();
+ };
+ }
+
+ function drawViewer() {
+ var canvas = document.getElementById('plc-view-canvas');
+ if (!canvas) return;
+ var ctx = canvas.getContext('2d');
+ var W = canvas.width, H = canvas.height;
+
+ ctx.clearRect(0, 0, W, H);
+ ctx.fillStyle = '#1a1a2e';
+ ctx.fillRect(0, 0, W, H);
+
+ var sel = document.getElementById('plc-view-graph');
+ if (!sel || sel.value === '') {
+ ctx.fillStyle = '#888';
+ ctx.font = '16px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('请选择一个逻辑图', W/2, H/2);
+ return;
+ }
+
+ var g = logicGraphs[parseInt(sel.value)];
+ if (!g) return;
+
+ // 使用文件中保存的 x,y 坐标(与编辑器布局一致)
+ var viewNodes = [];
+ var bMinX = Infinity, bMaxX = -Infinity, bMinY = Infinity, bMaxY = -Infinity;
+ for (var vi = 0; vi < g.nodes.length; vi++) {
+ var vn = g.nodes[vi];
+ var vx = vn.x, vy = vn.y;
+ if (vx < bMinX) bMinX = vx; if (vx > bMaxX) bMaxX = vx;
+ if (vy < bMinY) bMinY = vy; if (vy > bMaxY) bMaxY = vy;
+ var vsig = findSignalByNo(vn.id);
+ viewNodes.push({
+ type: vn.type, id: vn.id, _lx: vx, _ly: vy,
+ saddr: (vsig && vsig.saddr) || '', desc: (vsig && vsig.desc) || '', no: vn.id
+ });
+ }
+
+ // 首次绘制 / resize 后自动适配缩放
+ if (viewAutoFit) {
+ viewAutoFit = false;
+ var cw2 = bMaxX - bMinX, ch2 = bMaxY - bMinY;
+ if (cw2 <= 0) cw2 = 800; if (ch2 <= 0) ch2 = 600;
+ var pad = 80;
+ viewScale = Math.min((W - pad * 2) / cw2, (H - pad * 2) / ch2, 1.2);
+ viewPanX = W / 2 - (bMinX + cw2 / 2) * viewScale;
+ viewPanY = H / 2 - (bMinY + ch2 / 2) * viewScale;
+ }
+
+ // 链路 —— 修复:首个命中而非最后命中(避免同名门 ID 歧义)
+ var viewLinks = [];
+ for (var vlj = 0; vlj < g.links.length; vlj++) {
+ var vl = g.links[vlj];
+ var vls = -1, vld = -1;
+ for (var vlk = 0; vlk < g.nodes.length; vlk++) {
+ var vln = g.nodes[vlk];
+ if (vls < 0 && vln.type === vl.src_type && (vln.type <= 1 ? vln.id === vl.src_id : (vln.id & 0xFF) === (vl.src_id & 0xFF))) vls = vlk;
+ if (vld < 0 && vln.type === vl.dst_type && (vln.type <= 1 ? vln.id === vl.dst_id : (vln.id & 0xFF) === (vl.dst_id & 0xFF))) vld = vlk;
+ }
+ if (vls >= 0 && vld >= 0) viewLinks.push({ srcIdx: vls, dstIdx: vld, dstPin: vl.dstPin || 0 });
+ }
+
+ // 网格
+ ctx.strokeStyle = 'rgba(255,255,255,0.04)';
+ ctx.lineWidth = 1;
+ var gridSize = 50;
+ var startGx = Math.floor(-viewPanX / viewScale / gridSize) * gridSize;
+ var startGy = Math.floor(-viewPanY / viewScale / gridSize) * gridSize;
+
+ ctx.save();
+ ctx.translate(viewPanX, viewPanY);
+ ctx.scale(viewScale, viewScale);
+
+ for (var gx = startGx - gridSize; gx < startGx + W/viewScale + gridSize; gx += gridSize) {
+ ctx.beginPath(); ctx.moveTo(gx, startGy - gridSize); ctx.lineTo(gx, startGy + H/viewScale + gridSize); ctx.stroke();
+ }
+ for (var gy = startGy - gridSize; gy < startGy + H/viewScale + gridSize; gy += gridSize) {
+ ctx.beginPath(); ctx.moveTo(startGx - gridSize, gy); ctx.lineTo(startGx + W/viewScale + gridSize, gy); ctx.stroke();
+ }
+
+ drawGraphContent(ctx, viewNodes, viewLinks, -1, -1, null);
+
+ ctx.restore();
+ }
+
+ function calcLayout(g) {
+ // 自动布局:每条链路 (输入→运算→输出) 构成一条横向数据流链
+ // 节点类型常量复用的全局 NODE_INFO
+ var layout = {};
+
+ // 1. 构建邻接关系,按连线确定连通分量(每条链)
+ var adjOut = {}; for (var i = 0; i < g.nodes.length; i++) adjOut[i] = [];
+ var adjIn = {}; for (var i = 0; i < g.nodes.length; i++) adjIn[i] = [];
+ for (var j = 0; j < g.links.length; j++) {
+ var l = g.links[j];
+ var si = -1, di = -1;
+ for (var k = 0; k < g.nodes.length; k++) {
+ var n = g.nodes[k];
+ if (si < 0 && n.type === l.src_type && (n.type <= 1 ? n.id === l.src_id : (n.id & 0xFF) === (l.src_id & 0xFF))) si = k;
+ if (di < 0 && n.type === l.dst_type && (n.type <= 1 ? n.id === l.dst_id : (n.id & 0xFF) === (l.dst_id & 0xFF))) di = k;
+ }
+ if (si >= 0 && di >= 0) { adjOut[si].push(di); adjIn[di].push(si); }
+ }
+
+ // BFS 找连通分量
+ var visited = {};
+ var chains = [];
+ for (var idx = 0; idx < g.nodes.length; idx++) {
+ if (visited[idx]) continue;
+ var chain = [];
+ var queue = [idx];
+ visited[idx] = true;
+ while (queue.length > 0) {
+ var cur = queue.shift();
+ chain.push(cur);
+ var nbrs = adjOut[cur].concat(adjIn[cur]);
+ for (var nb = 0; nb < nbrs.length; nb++) {
+ if (!visited[nbrs[nb]]) { visited[nbrs[nb]] = true; queue.push(nbrs[nb]); }
+ }
+ }
+ chains.push(chain);
+ }
+
+ // 2. 计算每条链的实际竖向高度(含节点+间距)
+ var NODE_IO_H = 80; // 输入/输出节点高度
+ var NODE_GATE_H = 66; // 逻辑门节点高度
+ var ITEM_GAP = 28; // 同列节点纵向间距
+
+ function calcChainPixels(chain) {
+ var cin = 0, cg = 0, cout = 0;
+ for (var i = 0; i < chain.length; i++) {
+ var nd = g.nodes[chain[i]];
+ if (nd.type === NT_INPUT) cin++;
+ else if (nd.type === NT_OUTPUT) cout++;
+ else cg++;
+ }
+ // 每列独立高度
+ var hIn = cin > 0 ? cin * NODE_IO_H + (cin - 1) * ITEM_GAP : 0;
+ var hGate = cg > 0 ? cg * NODE_GATE_H + (cg - 1) * ITEM_GAP : 0;
+ var hOut = cout > 0 ? cout * NODE_IO_H + (cout - 1) * ITEM_GAP : 0;
+ return Math.max(hIn, hGate, hOut, 60); // 至少 60px 避免空链过扁
+ }
+
+ var CHAIN_GAP = 80; // 链间纵向间距
+
+ // 累计 Y 偏移
+ var chainHeights = [];
+ var totalH = 0;
+ for (var ci = 0; ci < chains.length; ci++) {
+ var h = calcChainPixels(chains[ci]);
+ chainHeights.push(h);
+ totalH += h;
+ }
+ totalH += (chains.length - 1) * CHAIN_GAP;
+
+ // 使链条在 Y 轴居中:startY = -(totalH / 2) + (firstHalfHeight/2)
+ var startY = -totalH / 2;
+ var curY = startY;
+
+ // 3. 列布局 (输入 | 运算 | 输出)
+ var COL_INPUT = -420;
+ var COL_GATE = 0;
+ var COL_OUTPUT = 420;
+
+ for (var ci2 = 0; ci2 < chains.length; ci2++) {
+ var chain = chains[ci2];
+ var ch = chainHeights[ci2];
+ var cy = curY + ch / 2; // 链中心 Y
+
+ // 分类节点
+ var cInputs = [], cGates = [], cOutputs = [];
+ for (var n = 0; n < chain.length; n++) {
+ var nd = g.nodes[chain[n]];
+ if (nd.type === NT_INPUT) cInputs.push(chain[n]);
+ else if (nd.type === NT_OUTPUT) cOutputs.push(chain[n]);
+ else cGates.push(chain[n]);
+ }
+
+ // 计算每列起始 Y(使该列节点在链高度内居中)
+ function colTop(count, itemH) {
+ if (count === 0) return cy;
+ var colH = count * itemH + (count - 1) * ITEM_GAP;
+ return cy - colH / 2 + itemH / 2;
+ }
+
+ var topIn = colTop(cInputs.length, NODE_IO_H);
+ var topGate = colTop(cGates.length, NODE_GATE_H);
+ var topOut = colTop(cOutputs.length, NODE_IO_H);
+
+ for (var a = 0; a < cInputs.length; a++)
+ layout[cInputs[a]] = { x: COL_INPUT, y: topIn + a * (NODE_IO_H + ITEM_GAP) };
+ for (var b = 0; b < cGates.length; b++)
+ layout[cGates[b]] = { x: COL_GATE, y: topGate + b * (NODE_GATE_H + ITEM_GAP) };
+ for (var c = 0; c < cOutputs.length; c++)
+ layout[cOutputs[c]] = { x: COL_OUTPUT, y: topOut + c * (NODE_IO_H + ITEM_GAP) };
+
+ curY += ch + CHAIN_GAP;
+ }
+
+ // 暴露边界信息供 canvas 自适应
+ layout._bounds = {
+ minX: COL_INPUT - 130, maxX: COL_OUTPUT + 130,
+ minY: startY - 40, maxY: startY + totalH + 40
+ };
+ return layout;
+ }
+
+ /* ---- 通用绘制函数(编辑器 + 查看器共用) ---- */
+ function drawGraphContent(ctx, nodes, links, selectedIdx, selectedLinkIdx, wiring) {
+ // 节点渲染尺寸常量
+ var IO_W = 250, IO_H = 80; // 输入/输出
+ var GATE_W = 170, GATE_H = 66; // 逻辑门
+ var CORNER_R = 12;
+ var SHADOW_OFF = 3;
+ var LEFT_BAR_W = 8;
+
+ // ---- 连线层 ----
+ for (var i = 0; i < links.length; i++) {
+ var l = links[i];
+ if (l.srcIdx >= nodes.length || l.dstIdx >= nodes.length) continue;
+ var sn = nodes[l.srcIdx], dn = nodes[l.dstIdx];
+ var srcIsIO = (sn.type === NT_OUTPUT || sn.type === NT_INPUT);
+ var dstIsIO = (dn.type === NT_OUTPUT || dn.type === NT_INPUT);
+ var dstIsSRRS = (dn.type === NT_SR || dn.type === NT_RS);
+ var sW = srcIsIO ? IO_W : GATE_W, dW = dstIsIO ? IO_W : GATE_W;
+ var dH = dstIsIO ? IO_H : (dstIsSRRS ? 86 : GATE_H);
+ var x1 = sn._lx + sW/2;
+ var y1 = sn._ly;
+ var dstInP = getNodeInPins(dn);
+ var effPinIdx = (l.dstPin !== undefined) ? l.dstPin : 0;
+ var y2 = dn._ly - dH/2 + dH * (effPinIdx + 1) / (dstIsSRRS ? 3 : (dstInP + 1));
+ var x2 = dn._lx - dW/2;
+ var isActive = wiring && wiring.fromIdx === l.srcIdx;
+ var isLinkSelected = (selectedLinkIdx === i);
+
+ var midX = (x1 + x2) / 2;
+ var lineAlpha = (isActive || isLinkSelected) ? 1 : 0.6;
+ var lineW = (isActive || isLinkSelected) ? 3 : 2;
+ var lineColor = isLinkSelected ? '#FF5722' : (isActive ? '#FFC107' : 'rgba(180,200,220,' + lineAlpha + ')');
+
+ // 发光效果 (阴影线)
+ ctx.beginPath();
+ ctx.strokeStyle = isLinkSelected ? 'rgba(255,87,34,0.3)' : 'rgba(255,200,0,' + (lineAlpha * 0.2) + ')';
+ ctx.lineWidth = lineW + 4;
+ ctx.moveTo(x1, y1);
+ ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2);
+ ctx.stroke();
+
+ // 主线
+ ctx.beginPath();
+ ctx.strokeStyle = lineColor;
+ ctx.lineWidth = lineW;
+ ctx.moveTo(x1, y1);
+ ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2);
+ ctx.stroke();
+
+ // 箭头 (指向目标)
+ ctx.fillStyle = lineColor;
+ ctx.beginPath();
+ ctx.moveTo(x2, y2);
+ ctx.lineTo(x2 - 7, y2 - 3.5);
+ ctx.lineTo(x2 - 7, y2 + 3.5);
+ ctx.closePath();
+ ctx.fill();
+
+ // 选中连线删除按钮标记(×)
+ if (isLinkSelected) {
+ var labelX = (x1 + x2) / 2, labelY = (y1 + y2) / 2 - 18;
+ ctx.fillStyle = '#FF5722';
+ ctx.font = 'bold 14px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('✕ 右键删除', labelX, labelY);
+ }
+ }
+
+ // 临时连线(仅编辑器)
+ if (wiring && wiring.fromIdx < nodes.length) {
+ var fn = nodes[wiring.fromIdx];
+ var fnIsIO = (fn.type === NT_OUTPUT || fn.type === NT_INPUT);
+ var fW = fnIsIO ? IO_W : GATE_W;
+ var fx = (wiring.fromSide === 'out') ? fn._lx + fW/2 : fn._lx - fW/2;
+ var fy = fn._ly;
+ ctx.beginPath();
+ ctx.strokeStyle = '#FFD700';
+ ctx.lineWidth = 2;
+ ctx.setLineDash([6, 4]);
+ ctx.moveTo(fx, fy);
+ ctx.lineTo(fx + (wiring.fromSide === 'out' ? 100 : -100), fy);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ // ---- 节点层 ----
+ for (var j = 0; j < nodes.length; j++) {
+ var n = nodes[j];
+ var info = NODE_INFO[n.type] || { name: '?', color: '#555', icon: '?' };
+ var isIO = (n.type === NT_OUTPUT || n.type === NT_INPUT);
+ var inP = getNodeInPins(n);
+ var nw = isIO ? IO_W : GATE_W;
+ var nh = isIO ? IO_H : (GATE_H + Math.max(0, inP - 2) * 14);
+ var x = n._lx, y = n._ly;
+ var halfW = nw/2, halfH = nh/2;
+ var isWiringSrc = wiring && wiring.fromIdx === j;
+ var isSelected = (selectedIdx === j);
+
+ // 发光描边宽度
+ var glowW = (isSelected || isWiringSrc) ? 3 : 1;
+
+ // --- 阴影 ---
+ ctx.fillStyle = 'rgba(0,0,0,0.5)';
+ ctx.beginPath();
+ ctx.roundRect(x - halfW + SHADOW_OFF, y - halfH + SHADOW_OFF, nw, nh, CORNER_R);
+ ctx.fill();
+
+ // --- 主体渐变 ---
+ var grad = ctx.createLinearGradient(x - halfW, y - halfH, x - halfW, y + halfH);
+ if (isSelected) {
+ grad.addColorStop(0, '#3b3b6b');
+ grad.addColorStop(1, '#282848');
+ } else if (isWiringSrc) {
+ grad.addColorStop(0, '#3c3c50');
+ grad.addColorStop(1, '#252538');
+ } else {
+ grad.addColorStop(0, '#2e2e4a');
+ grad.addColorStop(1, '#1e1e36');
+ }
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.roundRect(x - halfW, y - halfH, nw, nh, CORNER_R);
+ ctx.fill();
+
+ // --- 描边 ---
+ ctx.strokeStyle = isSelected ? '#FFD700' : (isWiringSrc ? '#FFC107' : info.color);
+ ctx.lineWidth = glowW;
+ ctx.beginPath();
+ ctx.roundRect(x - halfW, y - halfH, nw, nh, CORNER_R);
+ ctx.stroke();
+
+ // --- 左侧色条 ---
+ ctx.fillStyle = info.color;
+ ctx.beginPath();
+ ctx.roundRect(x - halfW, y - halfH, LEFT_BAR_W, nh, [CORNER_R, 0, 0, CORNER_R]);
+ ctx.fill();
+
+ // --- 顶部类型标签 ---
+ if (info.name) {
+ var labelBgX = x - halfW + LEFT_BAR_W;
+ var labelBgW = info.name.length * 7.5 + 18;
+ var labelBgY = y - halfH + 2;
+ var labelBgH = 16;
+ ctx.fillStyle = info.color;
+ ctx.globalAlpha = 0.25;
+ ctx.fillRect(labelBgX, labelBgY, Math.min(labelBgW, nw - LEFT_BAR_W - 4), labelBgH);
+ ctx.globalAlpha = 1;
+
+ ctx.fillStyle = isIO ? '#fff' : info.color;
+ ctx.font = '9px -apple-system, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillText(info.name, labelBgX + 4, labelBgY + 2.5);
+ }
+
+ // --- 文本内容 ---
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ if (isIO) {
+ var decId = n.id;
+ var sig = n.saddr ? n : findSignalByNo(decId);
+ var prefix = (n.type === NT_INPUT) ? '📥' : '📤';
+ var roleLabel = (n.type === NT_INPUT) ? '输入点' : '输出点';
+
+ // ID 行
+ ctx.fillStyle = '#e0e0e0';
+ ctx.font = 'bold 12px -apple-system, sans-serif';
+ ctx.fillText(prefix + ' ' + roleLabel + ' #' + decId, x, y - 18);
+
+ // saddr 行
+ ctx.fillStyle = '#aaa';
+ ctx.font = '10px monospace';
+ var saddrText = (sig && sig.saddr) ? sig.saddr : '—';
+ ctx.fillText(saddrText, x, y);
+
+ // desc 行
+ var descText = (sig && sig.desc) ? sig.desc : '';
+ if (descText) {
+ ctx.fillStyle = '#888';
+ ctx.font = '9px -apple-system, sans-serif';
+ var truncated = descText.length > 18 ? descText.substring(0, 16) + '…' : descText;
+ ctx.fillText(truncated, x, y + 14);
+ }
+ } else {
+ // 逻辑门
+ var gateIdx = n.id & 0xFF;
+ var realIn = 0, realOut = 0;
+ for (var li = 0; li < links.length; li++) {
+ if (links[li].dstIdx === j) realIn++;
+ if (links[li].srcIdx === j) realOut++;
+ }
+
+ // 中部:名称 + 序号
+ ctx.fillStyle = '#e8e8e8';
+ ctx.font = 'bold 11px -apple-system, sans-serif';
+ var titleText = info.icon + ' #' + gateIdx;
+ if (n.type === NT_62P || n.type === NT_62D) {
+ titleText += ' (' + (n.delayMs || 1000) + 'ms)';
+ }
+ ctx.fillText(titleText, x, y - 6);
+
+ // 连接数 / 延时信息
+ ctx.fillStyle = '#999';
+ ctx.font = '9px monospace';
+ if (n.type === NT_SR || n.type === NT_RS) {
+ ctx.fillText(realIn + '入 ' + realOut + '出', x, y + 14);
+ } else {
+ ctx.fillText(realIn + '入 ' + realOut + '出', x, y + 14);
+ }
+ }
+
+ // --- 输入引脚 ---
+ if (inP > 0) {
+ var isSRRS = (n.type === NT_SR || n.type === NT_RS);
+ var renderPins = isSRRS ? 2 : (isIO ? inP : 1);
+ for (var p = 0; p < renderPins; p++) {
+ var py = y - nh/2 + nh * (p + 1) / (isSRRS ? 3 : (inP + 1));
+ // 外圈
+ ctx.fillStyle = '#2e2e4a';
+ ctx.beginPath();
+ ctx.arc(x - halfW, py, 7, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = isSRRS ? (p === 0 ? '#FFC107' : '#FF5722') : '#4CAF50';
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ // 内芯
+ ctx.fillStyle = isSRRS ? (p === 0 ? '#FFC107' : '#FF5722') : '#4CAF50';
+ ctx.beginPath();
+ ctx.arc(x - halfW, py, 3.5, 0, Math.PI * 2);
+ ctx.fill();
+ // SR/RS 引脚标签
+ if (isSRRS) {
+ ctx.fillStyle = '#fff';
+ ctx.font = 'bold 10px monospace';
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+ var srl = (n.type === NT_SR) ? ['S', 'R'] : ['R', 'S'];
+ ctx.fillText(srl[p], x - halfW - 12, py);
+ }
+ }
+ }
+
+ // --- 输出引脚 ---
+ if (info.outPins > 0) {
+ // 外圈
+ ctx.fillStyle = '#2e2e4a';
+ ctx.beginPath();
+ ctx.arc(x + halfW, y, 7, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = '#FF9800';
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ // 内芯
+ ctx.fillStyle = '#FF9800';
+ ctx.beginPath();
+ ctx.arc(x + halfW, y, 3.5, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+ }
+
+ function renderEditorTab(container) {
+ editNodes = [];
+ editLinks = [];
+ editKey = '';
+ editName = '';
+ editOnlineCfgId = '';
+ editSelectedNode = -1;
+ editSelectedLink = -1;
+ editWiring = null;
+ editDragNode = null;
+ editPanX = 0; editPanY = 0;
+ editScale = 1.0;
+ editLoadedFromFile = false;
+ editNextGateIdx = {};
+
+ container.innerHTML = ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + '
'
+ + ''
+ + '
'
+ + ''
+ + '
'
+ + '
'
+ + ''
+ + '💡 双击模块可修改配置 | 单击选中/引脚连线 | 右键删除连线 | 滚轮缩放 | 按住空白拖动画布'
+ + '
';
+
+ // 工具栏事件
+ document.getElementById('plc-edit-key').oninput = function() { editKey = this.value; };
+ document.getElementById('plc-edit-name').oninput = function() { editName = this.value; };
+ document.getElementById('plc-edit-add-in').onclick = function() { showAddInputModal(); };
+ document.getElementById('plc-edit-add-out').onclick = function() { showAddOutputModal(); };
+ document.getElementById('plc-edit-add-gate').onclick = function() { showAddGateModal(); };
+ document.getElementById('plc-edit-zoomin').onclick = function() { editScale = Math.min(3, editScale * 1.2); drawEditor(); };
+ document.getElementById('plc-edit-zoomout').onclick = function() { editScale = Math.max(0.2, editScale / 1.2); drawEditor(); };
+ document.getElementById('plc-edit-del').onclick = function() { deleteSelectedNode(); };
+ document.getElementById('plc-edit-modify').onclick = function() { modifySelectedNode(); };
+ document.getElementById('plc-edit-generate').onclick = function() { generateAndSaveConfig(); };
+
+ setupEditorCanvas();
+ drawEditor();
+
+ // 加载当前 Reclose_logic.xml 为编辑起点
+ function initEditor() {
+ loadLogicFile(function() {
+ if (logicGraphs.length > 0) {
+ // 优先使用非 bak 文件的最新配置
+ var g = null;
+ for (var gi = 0; gi < logicGraphs.length; gi++) {
+ if (!logicGraphs[gi]._source || logicGraphs[gi]._source.indexOf('.bak') < 0) {
+ g = logicGraphs[gi]; break;
+ }
+ }
+ if (!g && logicGraphs.length > 0) g = logicGraphs[0];
+ if (g) {
+ editKey = g.key;
+ editName = g.name || '';
+ editOnlineCfgId = onlineCfgId;
+ // 复制节点到编辑器
+ for (var i = 0; i < g.nodes.length; i++) {
+ var n = g.nodes[i];
+ var newNode = {
+ type: n.type, id: n.id, x: n.x, y: n.y,
+ _lx: n.x, _ly: n.y,
+ saddr: '', desc: '', no: n.id
+ };
+ // 查找信号信息
+ var sig = findSignalByNo(n.id);
+ if (sig) { newNode.saddr = sig.saddr; newNode.desc = sig.desc; }
+ // 逻辑门 inPins — 优先使用文件中保存的配置
+ if (n.type !== NT_INPUT && n.type !== NT_OUTPUT) {
+ newNode.inPins = (n.inPins !== undefined) ? n.inPins : ((NODE_INFO[n.type] || {}).inPins || 2);
+ }
+ // 延时字段
+ if (n.delayMs !== undefined) newNode.delayMs = n.delayMs;
+ // Comb id 和 RS 信息
+ if (n.combId !== undefined) newNode.combId = n.combId;
+ if (n.rsR) newNode.rsR = n.rsR;
+ if (n.rsS) newNode.rsS = n.rsS;
+ editNodes.push(newNode);
+ }
+ // 复制链路(按节点在新数组中的索引)
+ for (var j = 0; j < g.links.length; j++) {
+ var l = g.links[j];
+ var si = findEditNodeIndex(l.src_type, l.src_id);
+ var di = findEditNodeIndex(l.dst_type, l.dst_id);
+ if (si >= 0 && di >= 0) {
+ var el = { srcIdx: si, dstIdx: di };
+ if (l.dstPin !== undefined) el.dstPin = l.dstPin;
+ editLinks.push(el);
+ }
+ }
+ editLoadedFromFile = true;
+
+ // 按类型统计最大序号,后续新增时每种类型独立递增
+ var maxByType = {};
+ for (var di = 0; di < editNodes.length; di++) {
+ var dn = editNodes[di];
+ if (dn.type === NT_INPUT || dn.type === NT_OUTPUT) continue;
+ var idx = dn.id & 0xFF;
+ if (idx >= (maxByType[dn.type] || -1)) maxByType[dn.type] = idx;
+ }
+ for (var t in maxByType) {
+ editNextGateIdx[t] = maxByType[t] + 1;
+ }
+
+ // 消除已有的重复 type+id 并基于每类型独立序号分配新 ID
+ var seenIds = {};
+ for (var di = 0; di < editNodes.length; di++) {
+ var dn = editNodes[di];
+ if (dn.type === NT_INPUT || dn.type === NT_OUTPUT) continue;
+ var key2 = dn.type + ':' + (dn.id & 0xFF);
+ if (seenIds[key2]) {
+ var next = editNextGateIdx[dn.type] || 0;
+ editNodes[di].id = (dn.id & ~0xFF) | next;
+ editNextGateIdx[dn.type] = next + 1;
+ } else {
+ seenIds[key2] = true;
+ }
+ }
+
+ // 更新输入框
+ var keyEl = document.getElementById('plc-edit-key');
+ var nameEl = document.getElementById('plc-edit-name');
+ if (keyEl) keyEl.value = editKey;
+ if (nameEl) nameEl.value = editName;
+ } else {
+ editKey = String(Date.now());
+ editName = '';
+ var keyEl = document.getElementById('plc-edit-key');
+ if (keyEl) keyEl.value = editKey;
+ }
+ drawEditor();
+ }
+ });
+ }
+
+ if (dcOutSignals.length === 0) {
+ loadDcOutSignals(function() { initEditor(); });
+ } else {
+ initEditor();
+ }
+ }
+
+ function findEditNodeIndex(type, id) {
+ for (var i = 0; i < editNodes.length; i++) {
+ var n = editNodes[i];
+ if (n.type === type) {
+ if (type <= 1 && n.id === id) return i;
+ if (type > 1 && (n.id & 0xFF) === (id & 0xFF)) return i;
+ }
+ }
+ return -1;
+ }
+
+ function deleteSelectedNode() {
+ if (editSelectedNode < 0 || editSelectedNode >= editNodes.length) {
+ showToast('请先在画布上双击或单击选中一个模块', 'err');
+ return;
+ }
+ var node = editNodes[editSelectedNode];
+ var idx = editSelectedNode;
+ var label = getNodeDisplayLabel(node);
+ var html = ''
+ + '
🗑 确认删除
'
+ + '
确定删除 ' + escHtml(label) + ' 及其所有连线?
'
+ + '
'
+ + ''
+ + ''
+ + '
';
+ var div = document.createElement('div');
+ div.innerHTML = html;
+ document.body.appendChild(div.firstElementChild);
+ document.getElementById('plc-modal-cancel').onclick = closeModal;
+ document.getElementById('plc-modal-ok').onclick = function() {
+ closeModal();
+ var delType = node.type;
+ editLinks = editLinks.filter(function(l) { return l.srcIdx !== idx && l.dstIdx !== idx; });
+ for (var i = 0; i < editLinks.length; i++) {
+ if (editLinks[i].srcIdx > idx) editLinks[i].srcIdx--;
+ if (editLinks[i].dstIdx > idx) editLinks[i].dstIdx--;
+ }
+ editNodes.splice(idx, 1);
+ editSelectedNode = -1;
+
+ // 压缩同类型门序号:删除 #1 后 #2→#1, #3→#2 …
+ if (delType !== NT_INPUT && delType !== NT_OUTPUT) {
+ var sameType = [];
+ for (var s = 0; s < editNodes.length; s++) {
+ if (editNodes[s].type === delType) sameType.push(s);
+ }
+ sameType.sort(function(a, b) { return (editNodes[a].id & 0xFF) - (editNodes[b].id & 0xFF); });
+ for (var a = 0; a < sameType.length; a++) {
+ editNodes[sameType[a]].id = (editNodes[sameType[a]].id & ~0xFF) | a;
+ }
+ editNextGateIdx[delType] = sameType.length;
+ }
+
+ drawEditor();
+ showToast('模块已删除', 'ok');
+ };
+ }
+
+ function modifySelectedNode() {
+ if (editSelectedNode < 0 || editSelectedNode >= editNodes.length) {
+ showToast('请先在画布上双击或单击选中一个模块', 'err');
+ return;
+ }
+ var idx = editSelectedNode;
+ var node = editNodes[idx];
+ if (node.type === NT_INPUT || node.type === NT_OUTPUT) {
+ showRebindSignalModal(idx);
+ } else if (node.type === NT_AND || node.type === NT_OR || node.type === NT_NOT) {
+ showGateConfigModal(idx);
+ } else if (node.type === NT_62P || node.type === NT_62D) {
+ showDelayEditModal(idx);
+ } else if (node.type === NT_SR || node.type === NT_RS) {
+ showRSInfoModal(idx);
+ } else {
+ showGateConfigModal(idx);
+ }
+ }
+
+ // 节点双击 — 根据类型弹出编辑框
+ function onNodeDoubleClicked(idx) {
+ var node = editNodes[idx];
+ editSelectedNode = idx;
+ drawEditor();
+ if (node.type === NT_INPUT || node.type === NT_OUTPUT) {
+ showRebindSignalModal(idx);
+ } else if (node.type === NT_62P || node.type === NT_62D) {
+ showDelayEditModal(idx);
+ } else if (node.type === NT_SR || node.type === NT_RS) {
+ showRSInfoModal(idx);
+ } else {
+ showGateConfigModal(idx);
+ }
+ }
+
+ /** 延时模块双击:弹出延时编辑框 */
+ function showDelayEditModal(nodeIdx) {
+ var node = editNodes[nodeIdx];
+ var info = NODE_INFO[node.type] || {};
+ var curMs = node.delayMs || 1000;
+ var html = ''
+ + '
⏱️ ' + info.name + ' — 修改延时
'
+ + '
当前延时: ' + curMs + ' ms
'
+ + '
'
+ + '
'
+ + ''
+ + ''
+ + '
';
+ var div = document.createElement('div');
+ div.innerHTML = html;
+ document.body.appendChild(div.firstElementChild);
+
+ document.getElementById('plc-modal-cancel').onclick = closeModal;
+ document.getElementById('plc-modal-ok').onclick = function() {
+ var val = parseInt(document.getElementById('plc-delay-input').value);
+ if (isNaN(val) || val < 0) { showToast('请输入有效的延时毫秒数', 'err'); return; }
+ editNodes[nodeIdx].delayMs = val;
+ closeModal();
+ drawEditor();
+ showToast('延时已更新: ' + val + ' ms', 'ok');
+ };
+ document.getElementById('plc-delay-input').onkeydown = function(e) {
+ if (e.key === 'Enter') document.getElementById('plc-modal-ok').click();
+ };
+ setTimeout(function() {
+ var inp = document.getElementById('plc-delay-input');
+ if (inp) { inp.focus(); inp.select(); }
+ }, 100);
+ }
+
+ /** SR/RS 双击:显示 R/S 连接信息 */
+ function showRSInfoModal(nodeIdx) {
+ var node = editNodes[nodeIdx];
+ var info = NODE_INFO[node.type] || {};
+ var sSrc = '', rSrc = '';
+ for (var li = 0; li < editLinks.length; li++) {
+ var l = editLinks[li];
+ if (l.dstIdx === nodeIdx) {
+ var srcNode = editNodes[l.srcIdx];
+ var desc = getEditorNodeLabel(srcNode).replace(/\n/g, ' ');
+ if (node.type === NT_SR) {
+ if (l.dstPin === 0) sSrc = desc; else rSrc = desc;
+ } else {
+ if (l.dstPin === 1) sSrc = desc; else rSrc = desc;
+ }
+ }
+ }
+ var html = ''
+ + '
🔒 ' + info.name + ' — 连接信息
'
+ + '
S (置位): ' + (sSrc || '未连接') + '
'
+ + '
R (复位): ' + (rSrc || '未连接') + '
'
+ + '
💡 拖动输出引脚到目标节点的 S/R 引脚来连线
'
+ + '
'
+ + '
';
+ var div = document.createElement('div');
+ div.innerHTML = html;
+ document.body.appendChild(div.firstElementChild);
+ document.getElementById('plc-modal-cancel').onclick = closeModal;
+ }
+
+ // 重新绑定 I/O 节点信号
+ function showRebindSignalModal(nodeIdx) {
+ var node = editNodes[nodeIdx];
+ var signals = dcOutSignals;
+ if (signals.length === 0) { showToast('信号列表为空,请先加载', 'err'); return; }
+
+ var title = (node.type === NT_INPUT) ? '📥 重新绑定输入信号' : '📤 重新绑定输出信号';
+ var html = ''
+ + '
' + title + '
'
+ + '
当前: ' + escHtml(getNodeDisplayLabel(node)) + '
'
+ + '
'
+ + '
';
+ for (var j = 0; j < signals.length; j++) {
+ var s = signals[j];
+ var isCurrent = (node.no === s.no);
+ html += '
'
+ + '#' + s.no + ' ' + escHtml(s.saddr) + ''
+ + ' ' + escHtml(s.desc) + ''
+ + (isCurrent ? '✓ 当前' : '')
+ + '
';
+ }
+ html += '
';
+
+ showModalCustom(html, function(no, saddr, desc) {
+ var sig = findSignalByNo(no);
+ editNodes[nodeIdx].no = no;
+ editNodes[nodeIdx].id = no;
+ editNodes[nodeIdx].saddr = saddr;
+ editNodes[nodeIdx].desc = desc;
+ drawEditor();
+ showToast('已绑定: ' + saddr, 'ok');
+ return true;
+ });
+ }
+
+ // 配置门电路引脚数
+ function showGateConfigModal(nodeIdx) {
+ var node = editNodes[nodeIdx];
+ var curPins = getNodeInPins(node);
+ var info = NODE_INFO[node.type] || {};
+ var maxPins = info.inMax || 99;
+ var minPins = 1;
+ if (node.type === NT_AND || node.type === NT_OR) minPins = 1;
+ else if (node.type === NT_NOT) minPins = 1;
+ else minPins = 1;
+
+ var html = ''
+ + '
' + info.icon + ' ' + info.name + ' 配置
'
+ + '
当前输入节点数: ' + curPins + '
'
+ + '
'
+ + '
'
+ + '
'
+ + ''
+ + ''
+ + '
';
+
+ var div = document.createElement('div');
+ div.innerHTML = html;
+ document.body.appendChild(div.firstElementChild);
+
+ document.getElementById('plc-modal-cancel').onclick = closeModal;
+ document.getElementById('plc-modal-save').onclick = function() {
+ var v = parseInt(document.getElementById('plc-gate-pins').value);
+ if (isNaN(v) || v < minPins) { showToast('最少 ' + minPins + ' 个输入引脚', 'err'); return; }
+ if (v > maxPins) { showToast('最多 ' + maxPins + ' 个输入引脚', 'err'); return; }
+ editNodes[nodeIdx].inPins = v;
+ closeModal();
+ drawEditor();
+ showToast(info.name + ' 输入引脚已设为 ' + v, 'ok');
+ };
+ }
+
+ /* ---- 编辑器 Canvas ---- */
+ function setupEditorCanvas() {
+ var wrap = document.getElementById('plc-edit-canvas-wrap');
+ var canvas = document.getElementById('plc-edit-canvas');
+ if (!wrap || !canvas) return;
+
+ function resize() {
+ canvas.width = wrap.clientWidth;
+ canvas.height = Math.max(500, wrap.clientHeight - 4);
+ // 居中画布
+ editScale = 0.7;
+ editPanX = canvas.width / 2;
+ editPanY = canvas.height / 2;
+ drawEditor();
+ }
+ setTimeout(resize, 50);
+ window.addEventListener('resize', function() { resize(); });
+
+ canvas.onmousedown = function(e) {
+ var rect = canvas.getBoundingClientRect();
+ var mx = (e.clientX - rect.left - editPanX) / editScale;
+ var my = (e.clientY - rect.top - editPanY) / editScale;
+
+ // 检查是否点击了引脚 → 连线操作
+ var pinHit = hitTestPin(mx, my);
+ if (pinHit) {
+ if (editWiring) {
+ // 完成连线
+ if (editWiring.fromIdx !== pinHit.nodeIdx) {
+ if (editWiring.fromSide === 'out' && pinHit.side === 'in') {
+ var srcInfo = NODE_INFO[editNodes[editWiring.fromIdx].type] || {};
+ var outMax = srcInfo.outMax || 99;
+ var outCount = 0;
+ for (var lc = 0; lc < editLinks.length; lc++) {
+ if (editLinks[lc].srcIdx === editWiring.fromIdx) outCount++;
+ }
+ if (outCount >= outMax) {
+ showToast('该模块输出已达上限 (' + outMax + ')', 'err');
+ editWiring = null;
+ editSelectedNode = -1;
+ drawEditor();
+ return;
+ }
+ var link = { srcIdx: editWiring.fromIdx, dstIdx: pinHit.nodeIdx, dstPin: pinHit.pinIdx };
+ // SR/RS 每个引脚只能接一根线
+ var dstNode = editNodes[pinHit.nodeIdx];
+ if (dstNode.type === NT_SR || dstNode.type === NT_RS) {
+ for (var lc2 = 0; lc2 < editLinks.length; lc2++) {
+ if (editLinks[lc2].dstIdx === pinHit.nodeIdx && editLinks[lc2].dstPin === pinHit.pinIdx) {
+ var pinLabel = (dstNode.type === NT_SR) ? ['S','R'] : ['R','S'];
+ showToast('引脚 ' + pinLabel[pinHit.pinIdx] + ' 已有连线,每个引脚只能接一根线', 'err');
+ editWiring = null;
+ editSelectedNode = -1;
+ drawEditor();
+ return;
+ }
+ }
+ }
+ editLinks.push(link);
+ showToast('连线已添加', 'ok');
+ } else if (editWiring.fromSide === 'in' && pinHit.side === 'out') {
+ editLinks.push({ srcIdx: pinHit.nodeIdx, dstIdx: editWiring.fromIdx, dstPin: 0 });
+ showToast('连线已添加', 'ok');
+ } else {
+ showToast('不能这样连线', 'err');
+ }
+ }
+ editWiring = null;
+ editSelectedNode = -1;
+ editSelectedLink = -1;
+ drawEditor();
+ return;
+ } else {
+ editWiring = { fromIdx: pinHit.nodeIdx, fromSide: pinHit.side, fromPin: pinHit.pinIdx };
+ editSelectedNode = -1;
+ editSelectedLink = -1;
+ drawEditor();
+ return;
+ }
+ }
+
+ // 检查是否点击了连线 → 选中高亮(左键选择,右键删除)
+ var linkHit = hitTestLink(mx, my);
+ if (linkHit >= 0) {
+ editSelectedLink = linkHit;
+ editSelectedNode = -1;
+ editDragNode = null;
+ if (editWiring) { editWiring = null; }
+ drawEditor();
+ return;
+ }
+
+ // 检查是否点击了节点 → 拖拽或选中
+ var nodeHit = hitTestNode(mx, my);
+ if (nodeHit >= 0) {
+ editSelectedNode = nodeHit;
+ editSelectedLink = -1;
+ editDragNode = nodeHit;
+ editDragOffX = mx - editNodes[nodeHit]._lx;
+ editDragOffY = my - editNodes[nodeHit]._ly;
+ drawEditor();
+ return;
+ }
+
+ // 空白处取消选中和连线,并开始画布拖拽
+ editSelectedNode = -1;
+ editSelectedLink = -1;
+ if (editWiring) { editWiring = null; }
+ editDragCanvas = { startX: e.clientX, startY: e.clientY, panX: editPanX, panY: editPanY };
+ canvas.style.cursor = 'grabbing';
+ drawEditor();
+ };
+
+ canvas.onmousemove = function(e) {
+ var rect = canvas.getBoundingClientRect();
+ var mx = (e.clientX - rect.left - editPanX) / editScale;
+ var my = (e.clientY - rect.top - editPanY) / editScale;
+
+ if (editDragCanvas) {
+ editPanX = editDragCanvas.panX + (e.clientX - editDragCanvas.startX);
+ editPanY = editDragCanvas.panY + (e.clientY - editDragCanvas.startY);
+ drawEditor();
+ return;
+ }
+
+ if (editDragNode !== null) {
+ editNodes[editDragNode]._lx = mx - editDragOffX;
+ editNodes[editDragNode]._ly = my - editDragOffY;
+ drawEditor();
+ return;
+ }
+
+ if (editWiring && editWiring.fromIdx < editNodes.length) {
+ drawEditor();
+ }
+
+ var pin = hitTestPin(mx, my);
+ canvas.style.cursor = pin ? 'crosshair' : (hitTestNode(mx, my) >= 0 ? 'grab' : 'default');
+ };
+
+ canvas.onmouseup = function(e) {
+ if (editDragCanvas) {
+ editDragCanvas = null;
+ canvas.style.cursor = 'default';
+ return;
+ }
+ if (editDragNode !== null) {
+ var node = editNodes[editDragNode];
+ var nodeIdx = editDragNode;
+ var dx = Math.abs((e.clientX - canvas.getBoundingClientRect().left - editPanX) / editScale - node._lx - editDragOffX);
+ var dy = Math.abs((e.clientY - canvas.getBoundingClientRect().top - editPanY) / editScale - node._ly - editDragOffY);
+ // 小于 3px 视为点击(非拖拽)
+ if (dx < 3 && dy < 3) {
+ // 双击检测:同节点 400ms 内连续点击
+ var now = Date.now();
+ if (nodeIdx === editLastClickNode && (now - editLastClickTime) < 400) {
+ onNodeDoubleClicked(nodeIdx);
+ editLastClickTime = 0;
+ editLastClickNode = -1;
+ } else {
+ editLastClickTime = now;
+ editLastClickNode = nodeIdx;
+ }
+ }
+ }
+ editDragNode = null;
+ };
+ canvas.onmouseleave = function() { editDragNode = null; editDragCanvas = null; canvas.style.cursor = 'default'; };
+
+ canvas.onwheel = function(e) {
+ e.preventDefault();
+ var delta = e.deltaY > 0 ? 0.9 : 1.1;
+ var rect = canvas.getBoundingClientRect();
+ var mx = e.clientX - rect.left;
+ var my = e.clientY - rect.top;
+ var ns = Math.max(0.2, Math.min(3, editScale * delta));
+ editPanX = mx - (mx - editPanX) * (ns / editScale);
+ editPanY = my - (my - editPanY) * (ns / editScale);
+ editScale = ns;
+ drawEditor();
+ };
+
+ // 右键 —— 删除选中的连线 / 节点
+ canvas.oncontextmenu = function(e) {
+ e.preventDefault();
+ if (editSelectedLink >= 0) {
+ editLinks.splice(editSelectedLink, 1);
+ editSelectedLink = -1;
+ editSelectedNode = -1;
+ showToast('连线已删除', 'ok');
+ drawEditor();
+ return;
+ }
+ // 如果选中了节点也可以用右键删除
+ if (editSelectedNode >= 0) {
+ deleteSelectedNode();
+ return;
+ }
+ };
+
+ canvas.tabIndex = 0;
+ canvas.onkeydown = function(e) {
+ if (e.key === 'Escape') { editWiring = null; editSelectedNode = -1; editSelectedLink = -1; drawEditor(); }
+ if (e.key === 'Delete' && editSelectedNode >= 0) { deleteSelectedNode(); }
+ if (e.key === 'Delete' && editSelectedLink >= 0) { editLinks.splice(editSelectedLink, 1); editSelectedLink = -1; showToast('连线已删除', 'ok'); drawEditor(); }
+ };
+ }
+
+ /* ---- 获取节点的实际 inPins(优先使用节点自身的配置) ---- */
+ function getNodeInPins(node) {
+ if (node.inPins !== undefined && node.inPins > 0) return node.inPins;
+ var info = NODE_INFO[node.type] || {};
+ return info.inPins || 0;
+ }
+
+ function getNodeSize(node) {
+ var isIO = (node.type === NT_OUTPUT || node.type === NT_INPUT);
+ var isSRRS = (node.type === NT_SR || node.type === NT_RS);
+ var inP = getNodeInPins(node);
+ var h = isIO ? 80 : (isSRRS ? 86 : (66 + Math.max(0, inP - 2) * 14));
+ var w = isIO ? 250 : 170;
+ return { w: w, h: h };
+ }
+
+ function hitTestNode(mx, my) {
+ for (var i = editNodes.length - 1; i >= 0; i--) {
+ var n = editNodes[i];
+ var sz = getNodeSize(n);
+ var x = n._lx, y = n._ly;
+ if (mx > x - sz.w/2 && mx < x + sz.w/2 && my > y - sz.h/2 && my < y + sz.h/2) return i;
+ }
+ return -1;
+ }
+
+ function hitTestPin(mx, my) {
+ var pinR = 8;
+ for (var i = editNodes.length - 1; i >= 0; i--) {
+ var n = editNodes[i];
+ var sz = getNodeSize(n);
+ var info = NODE_INFO[n.type] || NODE_INFO[0];
+ var x = n._lx, y = n._ly;
+ var isIO = (n.type === NT_INPUT || n.type === NT_OUTPUT);
+ var isSRRS = (n.type === NT_SR || n.type === NT_RS);
+ var inP = getNodeInPins(n);
+ // 输入引脚(左侧)
+ if (info.inPins > 0) {
+ var testPins = isSRRS ? 2 : (isIO ? inP : 1);
+ for (var p = 0; p < testPins; p++) {
+ var py = y - sz.h/2 + sz.h * (p + 1) / (isSRRS ? 3 : (inP + 1));
+ var dx = mx - (x - sz.w/2), dy = my - py;
+ if (Math.sqrt(dx*dx + dy*dy) <= pinR) return { nodeIdx: i, side: 'in', pinIdx: p };
+ }
+ }
+ // 输出引脚(右侧)
+ if (info.outPins > 0) {
+ var dx = mx - (x + sz.w/2), dy = my - y;
+ if (Math.sqrt(dx*dx + dy*dy) <= pinR) return { nodeIdx: i, side: 'out', pinIdx: 0 };
+ }
+ }
+ return null;
+ }
+
+ function hitTestLink(mx, my) {
+ for (var i = 0; i < editLinks.length; i++) {
+ var l = editLinks[i];
+ if (l.srcIdx >= editNodes.length || l.dstIdx >= editNodes.length) continue;
+ var sn = editNodes[l.srcIdx], dn = editNodes[l.dstIdx];
+ var szSrc = getNodeSize(sn), szDst = getNodeSize(dn);
+ var x1 = sn._lx + szSrc.w/2, y1 = sn._ly;
+ var dstIsSRRS = (dn.type === NT_SR || dn.type === NT_RS);
+ var gateInPins = getNodeInPins(dn);
+ var dstPinIdx2 = (l.dstPin !== undefined) ? l.dstPin : 0;
+ var y2 = dn._ly - szDst.h/2 + szDst.h * (dstPinIdx2 + 1) / (dstIsSRRS ? 3 : (gateInPins + 1));
+ var x2 = dn._lx - szDst.w/2;
+ var t = ((mx - x1)*(x2 - x1) + (my - y1)*(y2 - y1)) / ((x2 - x1)*(x2 - x1) + (y2 - y1)*(y2 - y1));
+ t = Math.max(0, Math.min(1, t));
+ var px = x1 + t * (x2 - x1), py = y1 + t * (y2 - y1);
+ if (Math.sqrt((mx - px)*(mx - px) + (my - py)*(my - py)) < 10) return i;
+ }
+ return -1;
+ }
+
+ function getNodeLabel(n) {
+ var info = NODE_INFO[n.type] || {};
+ var label = (info.name || '?');
+ if (n.saddr) label += ' ' + n.saddr;
+ else if (n.type <= 1) label += ' #' + n.id;
+ else label += ' #' + (n.id & 0xFF);
+ return label;
+ }
+
+ /* ---- 编辑器绘制 ---- */
+ function drawEditor() {
+ var canvas = document.getElementById('plc-edit-canvas');
+ if (!canvas) return;
+ var ctx = canvas.getContext('2d');
+ var W = canvas.width, H = canvas.height;
+
+ ctx.clearRect(0, 0, W, H);
+ ctx.fillStyle = '#1a1a2e';
+ ctx.fillRect(0, 0, W, H);
+
+ ctx.save();
+ ctx.translate(editPanX, editPanY);
+ ctx.scale(editScale, editScale);
+
+ // 网格
+ ctx.strokeStyle = 'rgba(255,255,255,0.05)';
+ ctx.lineWidth = 1;
+ var gridSize = 40;
+ var startX = Math.floor(-editPanX / editScale / gridSize) * gridSize;
+ var startY = Math.floor(-editPanY / editScale / gridSize) * gridSize;
+ for (var gx = startX - gridSize; gx < startX + W/editScale + gridSize; gx += gridSize) {
+ ctx.beginPath(); ctx.moveTo(gx, startY - gridSize); ctx.lineTo(gx, startY + H/editScale + gridSize); ctx.stroke();
+ }
+ for (var gy = startY - gridSize; gy < startY + H/editScale + gridSize; gy += gridSize) {
+ ctx.beginPath(); ctx.moveTo(startX - gridSize, gy); ctx.lineTo(startX + W/editScale + gridSize, gy); ctx.stroke();
+ }
+
+ // 区域标签
+ ctx.fillStyle = 'rgba(255,255,255,0.08)';
+ ctx.font = '28px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('📥 输入信号区', -250, -200);
+ ctx.fillText('🧮 逻辑运算区', 0, -200);
+ ctx.fillText('📤 输出信号区', 250, -200);
+
+ // 共用绘制
+ drawGraphContent(ctx, editNodes, editLinks, editSelectedNode, editSelectedLink, editWiring);
+
+ ctx.restore();
+
+ // 更新信息栏
+ var infoEl = document.getElementById('plc-edit-info');
+ if (infoEl) {
+ var selInfo = '';
+ if (editSelectedNode >= 0 && editSelectedNode < editNodes.length) {
+ selInfo = ' | 🔍 选中: ' + getNodeDisplayLabel(editNodes[editSelectedNode]);
+ }
+ infoEl.innerHTML = '节点: ' + editNodes.length + ' | 连线: ' + editLinks.length
+ + ' | Key: ' + escHtml(editKey) + ' | 名称: ' + escHtml(editName || '(未设置)')
+ + (editWiring ? ' | 🔗 连线中... (点击目标引脚完成,ESC取消)' : '')
+ + selInfo
+ + ' | 右键删除连线 | 滚轮缩放 | Delete键删除选中';
+ }
+ }
+
+ /* ---- 添加输入/输出/运算弹窗 ---- */
+ function showAddInputModal() {
+ if (dcOutSignals.length === 0) { showToast('请先加载信号', 'err'); return; }
+
+ var signals = dcOutSignals;
+ var html = ''
+ + '
📥 绑定输入信号
'
+ + '
'
+ + '
';
+ for (var j = 0; j < signals.length; j++) {
+ var s = signals[j];
+ html += '
'
+ + '#' + s.no + ' ' + escHtml(s.saddr) + ''
+ + ' ' + escHtml(s.desc) + '
';
+ }
+ html += '
';
+
+ showModalCustom(html, function(no, saddr, desc) {
+ editNodes.push({
+ type: NT_INPUT, id: no, x: 0, y: 0,
+ _lx: -260, _ly: editNodes.filter(function(n){return n.type===NT_INPUT;}).length * 110,
+ saddr: saddr, desc: desc, no: no
+ });
+ drawEditor();
+ showToast('已添加输入: ' + saddr, 'ok');
+ return true;
+ });
+ }
+
+ function showAddOutputModal() {
+ if (dcOutSignals.length === 0) { showToast('请先加载信号', 'err'); return; }
+
+ var signals = dcOutSignals;
+ var html = ''
+ + '
📤 绑定输出信号
'
+ + '
'
+ + '
';
+ for (var j = 0; j < signals.length; j++) {
+ var s = signals[j];
+ html += '
'
+ + '#' + s.no + ' ' + escHtml(s.saddr) + ''
+ + ' ' + escHtml(s.desc) + '
';
+ }
+ html += '
';
+
+ showModalCustom(html, function(no, saddr, desc) {
+ editNodes.push({
+ type: NT_OUTPUT, id: no, x: 0, y: 0,
+ _lx: 260, _ly: editNodes.filter(function(n){return n.type===NT_OUTPUT;}).length * 110,
+ saddr: saddr, desc: desc, no: no
+ });
+ drawEditor();
+ showToast('已添加输出: ' + saddr, 'ok');
+ return true;
+ });
+ }
+
+ function showAddGateModal() {
+ var gates = [
+ { type: NT_AND, name: '与门 (AND)', desc: '2输入1输出,所有输入为1时输出1', icon: '🔷' },
+ { type: NT_OR, name: '或门 (OR)', desc: '2输入1输出,任一输入为1时输出1', icon: '🔶' },
+ { type: NT_NOT, name: '非门 (NOT)', desc: '1输入1输出,输入取反', icon: '🔺' },
+ { type: NT_62P, name: '62p 延时', desc: '输入1延时输出1,输入0立即输出0', icon: '⏱️' },
+ { type: NT_62D, name: '62d 延时', desc: '输入1立即输出1,输入0延时输出0', icon: '⏱️' },
+ { type: NT_RISING, name: '上升沿', desc: '0→1跳变时输出脉冲', icon: '⬆️' },
+ { type: NT_FALLING, name: '下降沿', desc: '1→0跳变时输出脉冲', icon: '⬇️' },
+ { type: NT_SR, name: 'SR触发器', desc: 'S置位优先,S=1置位 R=1复位', icon: '🔒' },
+ { type: NT_RS, name: 'RS触发器', desc: 'R复位优先,R=1复位 S=1置位', icon: '🔒' }
+ ];
+
+ var html = ''
+ + '
🧮 选择运算模块
'
+ + '
';
+ for (var i = 0; i < gates.length; i++) {
+ var g = gates[i];
+ html += '
'
+ + '' + g.icon + ' ' + g.name + ''
+ + '
' + g.desc + ''
+ + '
';
+ }
+ html += '
';
+
+ // 自定义 modal 处理
+ var div = document.createElement('div');
+ div.innerHTML = html;
+ document.body.appendChild(div.firstElementChild);
+
+ document.getElementById('plc-modal-cancel').onclick = closeModal;
+
+ var rows = document.querySelectorAll('#plc-modal .plc-modal-row');
+ for (var j = 0; j < rows.length; j++) {
+ rows[j].onclick = function(e) {
+ e.stopPropagation();
+ var gateType = parseInt(this.getAttribute('data-gate-type'));
+ var info = NODE_INFO[gateType];
+ var nextIdx = editNextGateIdx[gateType] || 0;
+
+ // 延时模块:弹出自定义延时输入框
+ if (gateType === NT_62P || gateType === NT_62D) {
+ closeModal();
+ showDelayInputModal(gateType, nextIdx, info);
+ return;
+ }
+
+ // 非延时模块:直接添加
+ addGateNode(gateType, nextIdx, 0, info);
+ editNextGateIdx[gateType] = nextIdx + 1;
+ closeModal();
+ drawEditor();
+ };
+ }
+ }
+
+ /** 弹出延时毫秒输入框 */
+ function showDelayInputModal(gateType, nextIdx, info) {
+ var html = ''
+ + '
⏱️ ' + info.name + ' — 延时配置
'
+ + '
请输入延时时间(毫秒)
'
+ + '
'
+ + '
'
+ + ''
+ + ''
+ + '
';
+ var div = document.createElement('div');
+ div.innerHTML = html;
+ document.body.appendChild(div.firstElementChild);
+
+ document.getElementById('plc-modal-cancel').onclick = closeModal;
+ document.getElementById('plc-modal-ok').onclick = function() {
+ var val = parseInt(document.getElementById('plc-delay-input').value);
+ if (isNaN(val) || val < 0) { showToast('请输入有效的延时毫秒数', 'err'); return; }
+ closeModal();
+ addGateNode(gateType, nextIdx, val, info);
+ editNextGateIdx[gateType] = nextIdx + 1;
+ drawEditor();
+ };
+ // 回车确认
+ document.getElementById('plc-delay-input').onkeydown = function(e) {
+ if (e.key === 'Enter') document.getElementById('plc-modal-ok').click();
+ };
+ setTimeout(function() {
+ var inp = document.getElementById('plc-delay-input');
+ if (inp) { inp.focus(); inp.select(); }
+ }, 100);
+ }
+
+ /** 添加门节点到编辑区,自动找空白位置 */
+ function addGateNode(gateType, nextIdx, delayMs, info) {
+ var pos = findEmptyPosition();
+ var node = {
+ type: gateType, id: nextIdx,
+ x: 0, y: 0,
+ _lx: pos.x, _ly: pos.y,
+ saddr: '', desc: '', no: 0
+ };
+ if (delayMs > 0) node.delayMs = delayMs;
+ editNodes.push(node);
+ showToast('已添加: ' + (info ? info.name : NODE_INFO[gateType].name) + ' #' + nextIdx, 'ok');
+ }
+
+ /** 寻找画布上最空白的区域 */
+ function findEmptyPosition() {
+ if (editNodes.length === 0) return { x: 0, y: 0 };
+ // 先计算已有节点的包围盒,在右侧偏下寻找空白
+ var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
+ for (var i = 0; i < editNodes.length; i++) {
+ var n = editNodes[i];
+ if (n._lx < minX) minX = n._lx;
+ if (n._lx > maxX) maxX = n._lx;
+ if (n._ly < minY) minY = n._ly;
+ if (n._ly > maxY) maxY = n._ly;
+ }
+ // 在所有节点下方居中放置
+ var candidates = [
+ { x: 0, y: maxY + 140 },
+ { x: 0, y: minY - 140 },
+ { x: maxX + 300, y: (minY + maxY) / 2 },
+ { x: minX - 300, y: (minY + maxY) / 2 }
+ ];
+ for (var c = 0; c < candidates.length; c++) {
+ if (!isPositionOccupied(candidates[c].x, candidates[c].y)) {
+ return candidates[c];
+ }
+ }
+ return { x: 0, y: maxY + 140 };
+ }
+
+ function isPositionOccupied(x, y) {
+ for (var i = 0; i < editNodes.length; i++) {
+ var n = editNodes[i];
+ if (Math.abs(n._lx - x) < 120 && Math.abs(n._ly - y) < 80) return true;
+ }
+ return false;
+ }
+
+ function showModal(html, onSelect) {
+ var div = document.createElement('div');
+ div.innerHTML = html;
+ document.body.appendChild(div.firstElementChild);
+
+ document.getElementById('plc-modal-cancel').onclick = closeModal;
+
+ // 搜索
+ var searchInput = document.getElementById('plc-modal-search');
+ if (searchInput) {
+ searchInput.oninput = function() {
+ var q = this.value.toLowerCase();
+ var rows = document.querySelectorAll('#plc-modal .plc-modal-row');
+ for (var r = 0; r < rows.length; r++) {
+ var text = (rows[r].textContent || '').toLowerCase();
+ rows[r].style.display = (text.indexOf(q) !== -1) ? '' : 'none';
+ }
+ };
+ }
+
+ var rows = document.querySelectorAll('#plc-modal .plc-modal-row');
+ for (var j = 0; j < rows.length; j++) {
+ rows[j].onclick = function() {
+ if (this.classList.contains('plc-modal-used')) {
+ showToast('该信号已被使用', 'err');
+ return;
+ }
+ var noAttr = this.getAttribute('data-no');
+ var saddr = this.getAttribute('data-saddr');
+ var desc = this.getAttribute('data-desc') || '';
+ var no = parseInt(noAttr) || 0;
+ var result = onSelect(no, saddr, desc);
+ if (result !== false) closeModal();
+ };
+ }
+ }
+
+ function showModalCustom(html, onSelect) {
+ var div = document.createElement('div');
+ div.innerHTML = html;
+ document.body.appendChild(div.firstElementChild);
+
+ document.getElementById('plc-modal-cancel').onclick = closeModal;
+
+ var searchInput = document.getElementById('plc-modal-search');
+ if (searchInput) {
+ searchInput.oninput = function() {
+ var q = this.value.toLowerCase();
+ var rows = document.querySelectorAll('#plc-modal .plc-modal-row');
+ for (var r = 0; r < rows.length; r++) {
+ var text = (rows[r].textContent || '').toLowerCase();
+ rows[r].style.display = (text.indexOf(q) !== -1) ? '' : 'none';
+ }
+ };
+ }
+
+ var rows = document.querySelectorAll('#plc-modal .plc-modal-row');
+ for (var j = 0; j < rows.length; j++) {
+ rows[j].onclick = function() {
+ if (this.classList.contains('plc-modal-used')) {
+ showToast('该信号已被使用', 'err');
+ return;
+ }
+ var noAttr = this.getAttribute('data-no');
+ var saddr = this.getAttribute('data-saddr');
+ var desc = this.getAttribute('data-desc') || '';
+ var no = parseInt(noAttr) || 0;
+ var result = onSelect(no, saddr, desc);
+ if (result !== false) closeModal();
+ };
+ }
+ }
+
+ function closeModal() {
+ var m = document.getElementById('plc-modal');
+ if (m) m.remove();
+ }
+
+ /* ---- 生成并保存配置 ---- */
+ // 生成 XML 配置文件
+ function generateXmlConfig() {
+ var typeNames = {2:'OR',3:'AND',4:'NOT',5:'T62P',6:'T62D',7:'RISING',8:'SR',9:'RS',10:'FALLING'};
+ var adjIn = {}, adjOut = {};
+ for (var i = 0; i < editNodes.length; i++) { adjIn[i] = []; adjOut[i] = []; }
+ for (var j = 0; j < editLinks.length; j++) {
+ var l = editLinks[j];
+ adjOut[l.srcIdx].push(l.dstIdx);
+ adjIn[l.dstIdx].push(l.srcIdx);
+ }
+
+ var visited = {};
+ var chains = [];
+ for (var s = 0; s < editNodes.length; s++) {
+ if (visited[s]) continue;
+ var c = [], q = [s]; visited[s] = true;
+ while (q.length > 0) {
+ var cur = q.shift(); c.push(cur);
+ var nbrs = adjOut[cur].concat(adjIn[cur]);
+ for (var nb = 0; nb < nbrs.length; nb++)
+ if (!visited[nbrs[nb]]) { visited[nbrs[nb]] = true; q.push(nbrs[nb]); }
+ }
+ chains.push(c);
+ }
+
+ function sigXml(n) { return ''; }
+ function gateTag(n, nodeIdx) {
+ var tn = typeNames[n.type] || '?';
+ var a = ' type="' + tn + '" id="' + (n.id & 0xFF) + '" x="' + Math.round(n._lx) + '" y="' + Math.round(n._ly) + '"';
+ if (n.inPins && n.inPins > 2) a += ' inPins="' + n.inPins + '"';
+ if (n.type === NT_62P || n.type === NT_62D) a += ' delayMs="' + (n.delayMs || 1000) + '"';
+ // SR/RS: 输出 R/S 属性
+ if ((n.type === NT_SR || n.type === NT_RS) && nodeIdx !== undefined) {
+ var srLinks = [];
+ for (var li = 0; li < editLinks.length; li++) {
+ if (editLinks[li].dstIdx === nodeIdx) srLinks.push(editLinks[li]);
+ }
+ // 按 dstPin 排序,未设置的用出现顺序兜底
+ var hasPin = false;
+ for (var sl = 0; sl < srLinks.length; sl++) {
+ if (srLinks[sl].dstPin !== undefined) { hasPin = true; break; }
+ }
+ if (!hasPin) {
+ // 兜底:SR 按出现顺序 S→R,RS 按出现顺序 R→S
+ for (var sl2 = 0; sl2 < srLinks.length; sl2++) {
+ if (n.type === NT_SR) srLinks[sl2].dstPin = sl2; // 0=S, 1=R
+ else srLinks[sl2].dstPin = 1 - sl2; // 0=R, 1=S
+ }
+ }
+ for (var sl3 = 0; sl3 < srLinks.length; sl3++) {
+ var l3 = srLinks[sl3];
+ var srcNode = editNodes[l3.srcIdx];
+ var srcDesc = '';
+ if (srcNode.type === NT_INPUT) {
+ srcDesc = 'Input#' + srcNode.id;
+ } else {
+ srcDesc = 'Comb#' + (srcNode.combId !== undefined ? srcNode.combId : (srcNode.id & 0xFF));
+ }
+ if (n.type === NT_SR) {
+ if (l3.dstPin === 0) a += ' S="' + srcDesc + '"';
+ else a += ' R="' + srcDesc + '"';
+ } else {
+ if (l3.dstPin === 1) a += ' S="' + srcDesc + '"';
+ else a += ' R="' + srcDesc + '"';
+ }
+ }
+ }
+ return '';
+ }
+
+ /** 后序遍历分配 combId(自底向上,从 0 起) */
+ function assignCombIds(idx, ctrObj, cGatesArr) {
+ editNodes[idx].combId = undefined;
+ for (var ui = 0; ui < adjIn[idx].length; ui++) {
+ var up = adjIn[idx][ui];
+ if (editNodes[up].type > 1 && cGatesArr.indexOf(up) >= 0) {
+ assignCombIds(up, ctrObj, cGatesArr);
+ }
+ }
+ editNodes[idx].combId = ctrObj.count++;
+ }
+
+ function combXml(idx, indent) {
+ var s = '';
+ var cid = editNodes[idx].combId;
+ s += indent + '\n';
+ var innerIndent = indent + ' ';
+ var di = [];
+ for (var fi = 0; fi < adjIn[idx].length; fi++)
+ if (editNodes[adjIn[idx][fi]].type === NT_INPUT) di.push(adjIn[idx][fi]);
+ if (di.length > 0) {
+ s += innerIndent + '\n';
+ for (var d = 0; d < di.length; d++) s += innerIndent + ' ' + sigXml(editNodes[di[d]]) + '\n';
+ s += innerIndent + '\n';
+ }
+ for (var ui = 0; ui < adjIn[idx].length; ui++)
+ if (editNodes[adjIn[idx][ui]].type > 1 && cGates.indexOf(adjIn[idx][ui]) >= 0) {
+ s += combXml(adjIn[idx][ui], innerIndent);
+ }
+ s += innerIndent + gateTag(editNodes[idx], idx) + '\n';
+ var dos = [];
+ for (var oi = 0; oi < adjOut[idx].length; oi++)
+ if (editNodes[adjOut[idx][oi]].type === NT_OUTPUT) dos.push(adjOut[idx][oi]);
+ if (dos.length > 0) {
+ s += innerIndent + '\n';
+ for (var o = 0; o < dos.length; o++) s += innerIndent + ' ' + sigXml(editNodes[dos[o]]) + '\n';
+ s += innerIndent + '\n';
+ }
+ s += indent + '\n';
+ return s;
+ }
+
+ var xml = '\n';
+ xml += '\n';
+
+ for (var ci = 0; ci < chains.length; ci++) {
+ var chain = chains[ci];
+ var cIns = [], cGates = [], cOuts = [];
+ for (var ni = 0; ni < chain.length; ni++) {
+ var nd = editNodes[chain[ni]];
+ if (nd.type === NT_INPUT) cIns.push(chain[ni]);
+ else if (nd.type === NT_OUTPUT) cOuts.push(chain[ni]);
+ else cGates.push(chain[ni]);
+ }
+ if (cGates.length === 0) continue;
+
+ xml += ' \n';
+
+ if (cGates.length === 1) {
+ // 简单链: Inputs → Gate → Outputs
+ xml += ' \n';
+ for (var ii = 0; ii < cIns.length; ii++) xml += ' ' + sigXml(editNodes[cIns[ii]]) + '\n';
+ xml += ' \n';
+ xml += ' ' + gateTag(editNodes[cGates[0]], cGates[0]) + '\n';
+ xml += ' \n';
+ for (var oo = 0; oo < cOuts.length; oo++) xml += ' ' + sigXml(editNodes[cOuts[oo]]) + '\n';
+ xml += ' \n';
+ } else {
+ // 组合链: 链级门 + Comb + Outputs
+ // 链级门 = 下游没有 cGates 内其他门的门(只连输出或链外)
+ var chainGates = [];
+ for (var gg = 0; gg < cGates.length; gg++) {
+ var allToOut = true;
+ for (var so = 0; so < adjOut[cGates[gg]].length; so++)
+ if (cGates.indexOf(adjOut[cGates[gg]][so]) >= 0) { allToOut = false; break; }
+ if (allToOut) chainGates.push(cGates[gg]);
+ }
+ // combRoots: 直接馈入 chainGate 的门(combXml 会递归处理上游嵌套)
+ var combRootSet = {};
+ for (var cg = 0; cg < chainGates.length; cg++) {
+ for (var ui = 0; ui < adjIn[chainGates[cg]].length; ui++) {
+ var up = adjIn[chainGates[cg]][ui];
+ if (cGates.indexOf(up) >= 0) combRootSet[up] = true;
+ }
+ }
+ // 去重:若 A 是 B 的下游,只保留 B(combXml 从 B 开始会递归包含 A)
+ var combRootArr = Object.keys(combRootSet).map(Number);
+ var combRoots = [];
+ for (var rr = 0; rr < combRootArr.length; rr++) {
+ var isDownstream = false;
+ for (var rr2 = 0; rr2 < combRootArr.length; rr2++) {
+ if (rr === rr2) continue;
+ var visited2 = {};
+ var queue2 = [combRootArr[rr2]];
+ visited2[combRootArr[rr2]] = true;
+ while (queue2.length > 0) {
+ var cur2 = queue2.shift();
+ if (cur2 === combRootArr[rr]) { isDownstream = true; break; }
+ for (var si = 0; si < adjOut[cur2].length; si++) {
+ var nx = adjOut[cur2][si];
+ if (!visited2[nx] && cGates.indexOf(nx) >= 0) { visited2[nx] = true; queue2.push(nx); }
+ }
+ }
+ if (isDownstream) break;
+ }
+ if (!isDownstream) combRoots.push(combRootArr[rr]);
+ }
+ // 输出 Comb(每个 combRoot 对应一个顶层 Comb,内部递归嵌套)
+ // 先分配 combId
+ var combIdCtr = { count: 0 };
+ for (var tt = 0; tt < combRoots.length; tt++) {
+ assignCombIds(combRoots[tt], combIdCtr, cGates);
+ }
+ for (var tt = 0; tt < combRoots.length; tt++) {
+ xml += combXml(combRoots[tt], ' ');
+ }
+ // 输出链级门 — 若存在直接 Input 连接(未进入 Comb 的),先行输出
+ for (var cg = 0; cg < chainGates.length; cg++) {
+ var cgNode = editNodes[chainGates[cg]];
+ var directInputs = [];
+ for (var cui = 0; cui < adjIn[chainGates[cg]].length; cui++) {
+ var upIdx = adjIn[chainGates[cg]][cui];
+ if (editNodes[upIdx].type === NT_INPUT) {
+ directInputs.push(upIdx);
+ }
+ }
+ if (directInputs.length > 0) {
+ xml += ' \n';
+ for (var di2 = 0; di2 < directInputs.length; di2++) {
+ xml += ' ' + sigXml(editNodes[directInputs[di2]]) + '\n';
+ }
+ xml += ' \n';
+ }
+ xml += ' ' + gateTag(cgNode, chainGates[cg]) + '\n';
+ }
+ // 链级 Outputs — 仅包含直接连到 chainGate 的输出信号
+ // (Comb 内部的输出信号已在 combXml 中处理)
+ var chainOutSet = {};
+ for (var cg2 = 0; cg2 < chainGates.length; cg2++) {
+ for (var oi = 0; oi < adjOut[chainGates[cg2]].length; oi++) {
+ var outIdx = adjOut[chainGates[cg2]][oi];
+ if (editNodes[outIdx].type === NT_OUTPUT) chainOutSet[outIdx] = true;
+ }
+ }
+ var chainOuts = Object.keys(chainOutSet).map(Number);
+ // 同时保留直接连到输入信号(无门)的输出
+ for (var oo2 = 0; oo2 < cOuts.length; oo2++) {
+ var hasGateSrc = false;
+ for (var ai = 0; ai < adjIn[cOuts[oo2]].length; ai++) {
+ if (cGates.indexOf(adjIn[cOuts[oo2]][ai]) >= 0) hasGateSrc = true;
+ }
+ if (!hasGateSrc) chainOutSet[cOuts[oo2]] = true;
+ }
+ chainOuts = Object.keys(chainOutSet).map(Number);
+ if (chainOuts.length > 0) {
+ xml += ' \n';
+ for (var oo = 0; oo < chainOuts.length; oo++) xml += ' ' + sigXml(editNodes[chainOuts[oo]]) + '\n';
+ xml += ' \n';
+ }
+ }
+
+ xml += ' \n';
+ }
+
+ xml += '\n';
+ return xml;
+ }
+
+ function escXml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); }
+
+ function generateAndSaveConfig() {
+ if (editNodes.length === 0) { showToast('请先添加节点', 'err'); return; }
+ var hasInput = editNodes.some(function(n) { return n.type === NT_INPUT; });
+ var hasOutput = editNodes.some(function(n) { return n.type === NT_OUTPUT; });
+ if (!hasInput || !hasOutput) { showToast('至少需要一个输入和一个输出', 'err'); return; }
+ if (editLinks.length === 0) { showToast('请先连线', 'err'); return; }
+
+ var seenIds = {};
+ for (var di = 0; di < editNodes.length; di++) {
+ var dn = editNodes[di];
+ if (dn.type === NT_INPUT || dn.type === NT_OUTPUT) continue;
+ var key2 = dn.type + ':' + (dn.id & 0xFF);
+ if (seenIds[key2]) {
+ var next = editNextGateIdx[dn.type] || 0;
+ editNodes[di].id = (dn.id & ~0xFF) | next;
+ editNextGateIdx[dn.type] = next + 1;
+ } else { seenIds[key2] = true; }
+ }
+
+ var fileContent = generateXmlConfig();
+ var preview = fileContent.length > 2000 ? fileContent.substring(0,2000)+'…' : fileContent;
+
+ var previewHtml = ''
+ + '
✅ 配置预览 (XML)
'
+ + '
Key: ' + escHtml(editKey) + ' | 名称: ' + escHtml(editName || 'PLC Logic') + '
'
+ + '
节点: ' + editNodes.length + ' | 连线: ' + editLinks.length + '
'
+ + '
' + escHtml(preview) + '
'
+ + '
'
+ + ''
+ + ''
+ + '
';
+
+ var div = document.createElement('div');
+ div.innerHTML = previewHtml;
+ document.body.appendChild(div.firstElementChild);
+
+ document.getElementById('plc-modal-cancel').onclick = closeModal;
+ document.getElementById('plc-modal-save').onclick = function() {
+ closeModal();
+ var oldContent = loadedFiles['config/PLC/Reclose_logic.xml'] || '';
+ if (oldContent && oldContent.length > 10) {
+ WsClient.send({ type: 'set_file', path: 'config/PLC/Reclose_logic.xml.bak', content: oldContent });
+ }
+ var sent = WsClient.send({
+ type: 'set_file',
+ path: 'config/PLC/Reclose_logic.xml',
+ content: fileContent
+ });
+ if (sent) {
+ showToast('XML 配置已保存!请重启 PLC 模块生效', 'ok');
+ loadedFiles['config/PLC/Reclose_logic.xml'] = null;
+ loadedFiles['config/PLC/Reclose_logic.xml.bak'] = null;
+ logicGraphs = [];
+ } else {
+ showToast('保存失败:WebSocket 未连接', 'err');
+ }
+ };
+ }
+
+ /* ========== 页面生命周期 ========== */
+ function onEnter() {
+ // 重试之前因WS未连接而失败的文件请求
+ if (PlcConfigPage._retryList && PlcConfigPage._retryList.length > 0) {
+ var retry = PlcConfigPage._retryList.slice();
+ PlcConfigPage._retryList = [];
+ for (var ri = 0; ri < retry.length; ri++) {
+ requestFile(retry[ri], PlcConfigPage._pending[retry[ri]] || function(){});
+ }
+ }
+ // 自动加载 datacenter out 信号
+ if (dcOutSignals.length === 0) {
+ loadDcOutSignals();
+ }
+ }
+
+ function onLeave() {
+ editWiring = null;
+ editDragNode = null;
+ editSelectedNode = -1;
+ editSelectedLink = -1;
+ }
+
+ function onData(data) {
+ if (!data || typeof data !== 'object') return;
+
+ // 处理 file_content 响应
+ if (data.type === 'file_content') {
+ var path = data.path;
+ var content = data.content || '';
+ var error = data.error || '';
+ if (error) {
+ // 文件不存在等情况:仍然触发回调(传null),确保 loadLogicFileAll 的 tryCb 能正常计数
+ loadedFiles[path] = null;
+ if (PlcConfigPage._pending && PlcConfigPage._pending[path]) {
+ PlcConfigPage._pending[path](null);
+ delete PlcConfigPage._pending[path];
+ }
+ } else {
+ loadedFiles[path] = content;
+ if (PlcConfigPage._pending && PlcConfigPage._pending[path]) {
+ PlcConfigPage._pending[path](content);
+ delete PlcConfigPage._pending[path];
+ }
+ }
+ if (PlcConfigPage._retryList) {
+ var rIdx = PlcConfigPage._retryList.indexOf(path);
+ if (rIdx >= 0) PlcConfigPage._retryList.splice(rIdx, 1);
+ }
+ return;
+ }
+
+ // 处理 file_saved 响应
+ if (data.type === 'file_saved') {
+ if (data.success) {
+ showToast('文件已保存: ' + data.path, 'ok');
+ } else {
+ showToast('保存失败: ' + (data.error || '未知错误'), 'err');
+ }
+ return;
+ }
+ }
+
+ return {
+ render: render,
+ onEnter: onEnter,
+ onLeave: onLeave,
+ onData: onData,
+ _pending: {}
+ };
+})();
+
+Router.register('#plc_config', function(c) { PlcConfigPage.render(c); });
\ No newline at end of file
diff --git a/config/web/js/pages/plc_debug.js b/config/web/js/pages/plc_debug.js
new file mode 100644
index 0000000..3bb9fdb
--- /dev/null
+++ b/config/web/js/pages/plc_debug.js
@@ -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 += ''
+ + '' + esc(s.saddr) + ''
+ + (s.desc ? ' ' + esc(s.desc) + '' : '')
+ + '' + fmtVal(s.val) + '
';
+ });
+ var ov = document.createElement('div');
+ ov.className = 'modal-overlay';
+ ov.innerHTML = '' + I18n.t('plc_debug.bind_title') + '
'
+ + '
' + rows + '
'
+ + '
';
+ 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 = '' + I18n.t('plc_debug.no_led') + '
';
+ } else {
+ PlcDebugLeds.forEach(function(l, i) {
+ var on = parseFloat(l.val) > 0, chk = !!ledChecked[i];
+ ledHtml += ''
+ + '
'
+ + '
'
+ + (l.saddr ? '' + esc(l.saddr) + ' (' + fmtVal(l.val) + ')'
+ + (l.desc ? '' + esc(l.desc) + '' : '')
+ : '' + I18n.t('plc_debug.unbound') + '')
+ + '
'
+ + '
'
+ + '
';
+ });
+ }
+
+ /* 按键列 */
+ var btnHtml = '';
+ if (!PlcDebugButtons.length) {
+ btnHtml = '' + I18n.t('plc_debug.no_btn') + '
';
+ } else {
+ PlcDebugButtons.forEach(function(b, j) {
+ var chk = !!btnChecked[j];
+ btnHtml += ''
+ + '
'
+ + '
'
+ + (b.saddr ? '' + esc(b.saddr) + ' (' + fmtVal(b.val) + ')'
+ + (b.desc ? '' + esc(b.desc) + '' : '')
+ : '' + I18n.t('plc_debug.unbound') + '')
+ + '
'
+ + '
'
+ + '
';
+ });
+ }
+
+ /* 信号列 */
+ var o = getOut();
+ var sigHtml = '';
+ if (!o.length) {
+ sigHtml = '' + I18n.t('plc_debug.no_sig') + '
';
+ } else {
+ o.forEach(function(s, i) {
+ var chk = !!sigChecked[i];
+ sigHtml += ''
+ + ''
+ + '' + esc(s.saddr) + ''
+ + '' + esc(s.desc || '') + ''
+ + '' + fmtVal(s.val) + ''
+ + '
';
+ });
+ }
+
+ c.innerHTML = ''
+ + '
'
+ + '
'
+ + '
'
+ + '
';
+
+ 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 = '';
+ 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,
+ };
+})();
diff --git a/config/web/js/pages/signals.js b/config/web/js/pages/signals.js
new file mode 100644
index 0000000..f273b4f
--- /dev/null
+++ b/config/web/js/pages/signals.js
@@ -0,0 +1,68 @@
+/**
+ * @file signals.js
+ * @brief 信号管理页面 — out / in / yk / ao (统一渲染)
+ */
+
+
+/* ---- Out ---- */
+Router.register('#out', function(c) {
+ c.innerHTML = renderSignalPage('out',
+ ['', I18n.t('signal.saddr'), I18n.t('signal.desc'), I18n.t('signal.type')],
+ function(s) { return td(esc(s.type || '?'), 'cell-mono'); },
+ function(s) {
+ return ''
+ + ''
+ + ''
+ + '
';
+ }
+ );
+ [].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 = '';
+ });
+});
+
+/* ---- In ---- */
+Router.register('#in', function(c) {
+ c.innerHTML = renderSignalPage('in',
+ ['', 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 '';
+ }
+ );
+ [].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 = '';
+ });
+});
+
+/* ---- 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 ''
+ + ''
+ + ''
+ + '
';
+ }
+ );
+});
+
+/* ---- 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 ''
+ + ''
+ + ''
+ + '
';
+ }
+ );
+});
diff --git a/config/web/js/pages/terminal.js b/config/web/js/pages/terminal.js
new file mode 100644
index 0000000..a3d0567
--- /dev/null
+++ b/config/web/js/pages/terminal.js
@@ -0,0 +1,155 @@
+/**
+ * @file terminal.js
+ * @brief 命令终端页面 — 按钮面板 + 输入框
+ * @details 连接后自动获取命令列表,按模块分组生成按钮。
+ * 点击按钮将命令名填充到输入框,用户可继续输入子命令参数。
+ * 回车执行,输出显示在下方窗口。
+ */
+
+var TermCommands = [];
+
+Router.register('#terminal', function(c) {
+ c.innerHTML =
+ '' +
+ ' ' +
+ '
' +
+ '
' +
+ '
' + (I18n.t('term.loading') || '正在加载命令列表...') + '
' +
+ '
' +
+ '
' + I18n.t('term.welcome') + '
' +
+ '
' +
+ ' RTU>' +
+ ' ' +
+ '
' +
+ '
' +
+ '
';
+
+ 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 += '';
+ html += '
' + meta.icon + ' ' + meta.label + '
';
+ html += '
';
+
+ for (var j = 0; j < groups[mod].length; j++)
+ {
+ var cmd = groups[mod][j];
+ html += '';
+ }
+
+ html += '
';
+ }
+
+ 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;
+}
diff --git a/config/web/js/router.js b/config/web/js/router.js
new file mode 100644
index 0000000..4a5cc5f
--- /dev/null
+++ b/config/web/js/router.js
@@ -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 += '' + I18n.t(secKey) + '
';
+ lastSection = item.section;
+ }
+ html += '' + Icon.get(item.icon, 'nav-icon') + I18n.t(item.key) + '';
+ });
+
+ 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');
+}
diff --git a/config/web/js/signal-store.js b/config/web/js/signal-store.js
new file mode 100644
index 0000000..497948f
--- /dev/null
+++ b/config/web/js/signal-store.js
@@ -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);
+ }
+};
diff --git a/config/web/js/ui.js b/config/web/js/ui.js
new file mode 100644
index 0000000..6d21187
--- /dev/null
+++ b/config/web/js/ui.js
@@ -0,0 +1,220 @@
+/**
+ * @file ui.js
+ * @brief 通用 UI 组件 — Toast, Modal, Table, Icon, Utilities
+ */
+
+
+/* ==================================================================
+ * SVG 内联图标 (Lucide 风格, 18×18)
+ * ================================================================== */
+var Icon = {
+ _map: {
+ monitor: '',
+ dashboard: '',
+ signal: '',
+ link: '',
+ zap: '',
+ sliders: '',
+ settings: '',
+ database: '',
+ bug: '',
+ cpu: '',
+ activity: '',
+ terminal: '',
+ check: '',
+ plus: '',
+ trash: '',
+ edit: '',
+ alert: '',
+ },
+
+ 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 = ''
+ + '
' + title + '
'
+ + '
' + bodyHtml + '
'
+ + '
'
+ + ''
+ + ''
+ + '
';
+
+ 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, '"');
+}
+
+function fmtVal(v) {
+ return (v == null || v === '') ? '\u2014' : esc(String(v));
+}
+
+function td(s, cls) {
+ return '' + s + ' | ';
+}
+
+function th(s) {
+ return '' + s + ' | ';
+}
+
+/**
+ * @brief 渲染信号管理页面通用模板
+ */
+function renderSignalPage(type, headers, extraCols, actionsForRow) {
+ var signals = SignalStore.get(type);
+ var rows = '';
+
+ if (signals.length === 0)
+ {
+ rows = '| ' + I18n.t('signal.no_data') + ' |
';
+ }
+ else
+ {
+ signals.forEach(function(s) {
+ var xtra = extraCols ? extraCols(s) : '';
+ var acts = actionsForRow ? actionsForRow(s) : '';
+ rows += ''
+ + '| ' + esc(s.saddr) + ' | '
+ + '' + esc(s.desc || '') + ' | '
+ + xtra
+ + '' + fmtVal(s.val) + ' | '
+ + '' + acts + ' | '
+ + '
';
+ });
+ }
+
+ return ''
+ + ''
+ + '
'
+ + '' + headers.map(function(h) { return th(h); }).join('') + '| ' + I18n.t('signal.val') + ' | ' + I18n.t('signal.action') + ' |
'
+ + '' + rows + ''
+ + '
';
+}
+
+/* ==================================================================
+ * 信号 CRUD 操作
+ * ================================================================== */
+var SignalActions = {
+ add: function(type) {
+ var body = '';
+ body += '';
+ body += '';
+
+ if (type === 'in')
+ {
+ body += '';
+ }
+ else
+ {
+ body += ''
+ + '
';
+ }
+
+ if (type === 'yk' || type === 'ao' || type === 'param')
+ {
+ body += ''
+ + '
';
+ }
+
+ 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'),
+ '' + I18n.t('signal.confirm_del_msg') + ' ' + esc(saddr) + '?
',
+ 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'),
+ '' + I18n.t('signal.confirm_batch_msg') + ' ' + checked.length + ' ' + I18n.t('signal.confirm_batch_del') + '?
',
+ 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),
+ '',
+ 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'));
+ }
+};
diff --git a/config/web/js/ws-client.js b/config/web/js/ws-client.js
new file mode 100644
index 0000000..f03def8
--- /dev/null
+++ b/config/web/js/ws-client.js
@@ -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);
+ }
+};
diff --git a/src/system/libweb_server/src/web_server.cpp b/src/system/libweb_server/src/web_server.cpp
index 5ebd41d..1eff6fb 100644
--- a/src/system/libweb_server/src/web_server.cpp
+++ b/src/system/libweb_server/src/web_server.cpp
@@ -27,6 +27,7 @@
static struct mg_mgr s_ws_mgr;
static std::vector 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;
+ /* 静态文件根目录: /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;
}
diff --git a/src/system/libweb_server/src/ws_method.cpp b/src/system/libweb_server/src/ws_method.cpp
index c1523d5..a2a4f60 100644
--- a/src/system/libweb_server/src/ws_method.cpp
+++ b/src/system/libweb_server/src/ws_method.cpp
@@ -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 命令 ---- */