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.quick_actions') + '
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '
' + I18n.t('dash.connection') + '
' + + '
' + + 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 = '' + + 'idsaddrdescvaltypectrllink'; + + 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 = '
' + + '
' + I18n.t('dc.title') + '' + + '
' + + '' + + '
' + + '
' + + '
' + + '' + + '0' + + '
' + + '' + + '
' + + '' + + '' + + '
'; + 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 = '
' + + '
' + I18n.t('mon.title') + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; + 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 = '
' + + '
PARAM \u2014 ' + signals.length + ' signals' + + '' + + '
' + + '
' + + '' + + '' + rows + '' + + '
' + I18n.t('signal.saddr') + '' + I18n.t('signal.desc') + '' + I18n.t('signal.type') + '' + I18n.t('signal.zone') + '' + I18n.t('signal.action') + '
'; +}); + +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 = ''; + 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 = ''; + 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 = ''; + 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 = ''; + + 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 = ''; + + 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 = ''; + + 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 = ''; + + 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 = ''; + + // 自定义 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 = ''; + 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 = ''; + + 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 = ''; + 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 = '
' + + '
💡 ' + I18n.t('plc_debug.led_col') + '' + + '
' + + '' + + '
' + ledHtml + '
' + + '
📋 ' + I18n.t('plc_debug.sig_col') + '' + + '
' + + '' + + '' + + '
' + sigHtml + '
' + + '
🔘 ' + I18n.t('plc_debug.btn_col') + '' + + '
' + + '' + + '
' + btnHtml + '
' + + '
'; + + 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 = '
' + + '
' + I18n.t('plc_debug.title') + '' + + '
' + + '' + + '' + + '
' + + '
'; + 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.title') + '
' + + '
' + + '
' + + '
' + (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 += ''; + 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 = ''; + + 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 '
' + + '
' + type.toUpperCase() + ' \u2014 ' + signals.length + ' signals' + + '
' + + '' + + '' + + '
' + + '
' + + '' + headers.map(function(h) { return th(h); }).join('') + '' + + '' + rows + '' + + '
' + I18n.t('signal.val') + '' + I18n.t('signal.action') + '
'; +} + +/* ================================================================== + * 信号 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 命令 ---- */