refactor: 按角色分 web/ diag/ launch/ 三层, 根目录只留主程序

18 个文件平铺根目录太乱, 按角色归拢:
  web/     ecat.html/css + motor.html/js —— 前端资源
  diag/    ecat_scan / ecat_sniff / ecat_esc_regs —— 独立诊断脚本, 互不依赖
  launch/  5 个 .bat/.vbs 双击入口
根目录只留 ecat_server / ecat_tool / ecat_flash_eeprom / test_motor_backend:
这几个互相 import(tool→flash_eeprom, test→server), 挪进子目录就得改 import
或加包, 收益不抵成本。

配套改动:
  _STATIC_MAP 的值加 web/ 前缀 —— URL 路径刻意保持不变(/、/ecat.css、
  /motor.html), 页面内相对引用、书签、外部链接一律不用改。
  launch/ 里 %~dp0 现在指向 launch\, 五个启动器统一改为先取上一层作 ROOT
  (bat 用 %~dp0.., vbs 用两次 GetParentFolderName), run_scan 顺带指向
  diag\ecat_scan.py。

验证: 7 个 py 全编译通过; test_motor_backend 8 项全通过; 服务实起后 6 条
静态路由全 200 且 MIME 正确(js 是 application/javascript)、404 兜底正常;
Edge headless 渲染主页与 motor 页, CSS 294 规则加载、9 个节点样式生效、
零加载失败零 JS 异常; bat/vbs 的 ROOT 解析实测指到项目根, 五个目标文件
全部命中; ecat_tool adapters 与 diag/ecat_scan.py 实跑正常。
This commit is contained in:
cbb
2026-09-03 22:08:38 +08:00
parent 47ea85fb23
commit beb33ffe50
15 changed files with 62 additions and 31 deletions
+843
View File
@@ -0,0 +1,843 @@
/* motor.css - 电机控制台页面样式
* 由 motor.html 内联 <style>(原 L7-L675)机械外提,内容未改。
* 独立主题(seed tokens),不依赖 base.css/components.css。 */
/* ═══════════════════════ SEED TOKENS ═══════════════════════ */
:root {
--seed-bg: #090e15;
--seed-surface: #0f161f;
--seed-fg: #e2e9f2;
--seed-primary: #22d3ee;
--seed-accent: #f59e0b;
--seed-radius: 10px;
/* derived tokens */
--bg-root: var(--seed-bg);
--bg-panel: var(--seed-surface);
--bg-raised: color-mix(in srgb, var(--seed-surface) 82%, #ffffff 4%);
--fg: var(--seed-fg);
--fg-muted: color-mix(in srgb, var(--seed-fg) 55%, transparent);
--fg-dim: color-mix(in srgb, var(--seed-fg) 35%, transparent);
--border: color-mix(in srgb, var(--seed-fg) 10%, transparent);
--border-strong: color-mix(in srgb, var(--seed-fg) 18%, transparent);
--primary: var(--seed-primary);
--primary-dim: color-mix(in srgb, var(--seed-primary) 18%, transparent);
--primary-glow: color-mix(in srgb, var(--seed-primary) 30%, transparent);
--warn: var(--seed-accent);
--warn-dim: color-mix(in srgb, var(--seed-accent) 15%, transparent);
--ok: #34d399;
--ok-dim: color-mix(in srgb, #34d399 14%, transparent);
--danger: #f87171;
--danger-dim: color-mix(in srgb, #f87171 14%, transparent);
--radius: var(--seed-radius);
--radius-sm: calc(var(--seed-radius) * 0.6);
--radius-lg: calc(var(--seed-radius) * 1.4);
--font-body: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", system-ui, sans-serif;
--font-mono: "Cascadia Code", "Fira Code", "SF Mono", "JetBrains Mono", Consolas, monospace;
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px;
--toolbar-h: 54px;
--statusbar-h: 42px;
}
/* ═══════════════════════ RESET & BASE ═══════════════════════ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 15px; }
body {
font-family: var(--font-body);
background: var(--bg-root);
color: var(--fg);
line-height: 1.5;
height: 100vh;
overflow: hidden;
display: grid;
grid-template-rows: var(--toolbar-h) 1fr var(--statusbar-h);
}
/* ambient grid texture */
body::before {
content: "";
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background-image:
linear-gradient(color-mix(in srgb, var(--seed-primary) 3%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in srgb, var(--seed-primary) 3%, transparent) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 90% 70% at 50% 30%, black 20%, transparent 80%);
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--fg-dim); }
button { font: inherit; cursor: pointer; border: none; background: none; color: inherit; }
input, select { font: inherit; color: inherit; }
label { display: block; }
/* ═══════════════════════ TOOLBAR ═══════════════════════ */
.toolbar {
grid-row: 1;
grid-column: 1;
position: relative; z-index: 10;
display: flex; align-items: center; gap: var(--sp-4);
padding: 0 var(--sp-5);
background: linear-gradient(90deg, color-mix(in srgb, var(--seed-primary) 7%, var(--bg-panel)), var(--bg-panel) 42%);
border-bottom: 1px solid var(--border);
box-shadow: 0 8px 24px rgba(0, 0, 0, .15);
}
.toolbar-brand {
display: flex; align-items: center; gap: var(--sp-3);
font-weight: 600; font-size: 1.05rem; letter-spacing: -0.01em;
white-space: nowrap;
}
.toolbar-brand svg { flex-shrink: 0; }
.toolbar-brand .sub {
font-size: 0.72rem; font-weight: 400; color: var(--fg-muted);
letter-spacing: 0.08em; text-transform: uppercase;
font-family: var(--font-mono);
}
.toolbar-sep { width: 1px; height: 26px; background: var(--border-strong); flex-shrink: 0; }
.conn-group { display: flex; align-items: center; gap: var(--sp-2); }
.conn-ip-label {
font-family: var(--font-mono); font-size: 0.82rem;
color: var(--fg-muted); padding: 4px 8px;
background: var(--bg-root); border: 1px solid var(--border);
border-radius: var(--radius-sm); letter-spacing: 0.02em;
min-width: 7.5em;
}
.toolbar-right a.btn {
text-decoration: none; color: var(--fg-muted);
border-color: var(--border-strong);
}
.toolbar-right a.btn:hover { color: var(--primary); border-color: color-mix(in srgb, var(--seed-primary) 40%, transparent); }
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: var(--radius-sm);
font-size: 0.85rem; font-weight: 550; letter-spacing: 0.02em;
border: 1px solid var(--border-strong);
background: var(--bg-raised);
transition: background .15s, border-color .15s, transform .1s;
white-space: nowrap;
}
.btn:hover { background: color-mix(in srgb, var(--seed-surface) 60%, #ffffff 8%); }
.btn:active { transform: translateY(1px); }
.btn-primary {
background: color-mix(in srgb, var(--seed-primary) 16%, var(--seed-surface));
border-color: color-mix(in srgb, var(--seed-primary) 40%, transparent);
color: var(--primary);
}
.btn-primary:hover { background: color-mix(in srgb, var(--seed-primary) 24%, var(--seed-surface)); }
.btn-danger {
background: var(--danger-dim); border-color: color-mix(in srgb, var(--danger) 40%, transparent);
color: var(--danger);
}
.btn-sm { padding: 4px 10px; font-size: 0.78rem; }
.btn:disabled { opacity: .4; pointer-events: none; }
.led {
width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0;
background: var(--fg-dim); transition: background .3s, box-shadow .3s;
}
.led-on { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
.led-warn { background: var(--warn); box-shadow: 0 0 6px var(--warn); }
.led-err { background: var(--danger); box-shadow: 0 0 6px var(--danger); }
.led-pulse { animation: ledPulse 1.2s ease-in-out infinite; }
@keyframes ledPulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
.ch-select {
padding: 5px 8px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.82rem; outline: none;
}
.ch-select:focus { border-color: var(--primary); }
/* 轮询卡顿:通道下拉框变黄并呼吸发光(fixed-tick 下某拍未按时完成) */
.ch-select.stuck {
border-color: color-mix(in srgb, var(--warn) 65%, transparent);
animation: stuckPulse 1.2s ease-in-out infinite;
}
@keyframes stuckPulse {
0%, 100% { box-shadow: 0 0 2px color-mix(in srgb, var(--warn) 15%, transparent); }
50% { box-shadow: 0 0 10px color-mix(in srgb, var(--warn) 40%, transparent); }
}
/* 刷新速度输入框:与 .ch-select 同风格,宽度收窄 */
.refresh-input {
width: 64px; padding: 5px 6px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.82rem; outline: none;
text-align: center;
}
.refresh-input:focus { border-color: var(--primary); }
.refresh-input::-webkit-inner-spin-button { opacity: .5; }
.refresh-hint { font-size: .68rem; color: var(--fg-dim); font-family: var(--font-mono); letter-spacing: .04em; }
.toolbar-right { margin-left: auto; display: flex; align-items: center; gap: var(--sp-3); }
.node-badge {
font-family: var(--font-mono); font-size: 0.8rem;
padding: 4px 12px; border-radius: 99px;
background: var(--primary-dim); color: var(--primary);
border: 1px solid color-mix(in srgb, var(--seed-primary) 30%, transparent);
letter-spacing: 0.02em;
}
/* ═══════════════════════ MAIN LAYOUT ═══════════════════════ */
.main-grid {
grid-row: 2;
grid-column: 1;
position: relative; z-index: 1;
display: grid;
grid-template-columns: minmax(240px, 290px) minmax(0, 0.9fr) minmax(420px, 1.1fr);
gap: var(--sp-3);
padding: var(--sp-3);
overflow: hidden;
min-height: 0;
}
.panel {
background: linear-gradient(145deg, color-mix(in srgb, var(--bg-panel) 94%, #ffffff 3%), var(--bg-panel) 52%);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
display: flex; flex-direction: column;
min-height: 0; overflow: hidden;
box-shadow: 0 10px 28px rgba(0, 0, 0, .16), inset 0 1px 0 rgba(255, 255, 255, .025);
}
.panel-head {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
background: linear-gradient(90deg, var(--primary-dim), transparent 48%);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.panel-title {
font-size: 0.78rem; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
color: var(--fg-muted); font-family: var(--font-mono);
}
.panel-body { flex: 1; overflow-y: auto; min-height: 0; }
/* ═══════════════════════ LEFT: NODE LIST ═══════════════════════ */
.node-list { padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-2); }
.node-empty { padding: var(--sp-5); text-align: center; color: var(--fg-dim); font-size: .8rem; line-height: 1.7; }
.node-card {
position: relative;
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-root);
cursor: pointer;
transition: border-color .2s, background .2s, transform .12s;
}
.node-card:hover { border-color: var(--border-strong); transform: translateX(2px); }
.node-card.active {
border-color: color-mix(in srgb, var(--seed-primary) 55%, transparent);
background: color-mix(in srgb, var(--seed-primary) 6%, var(--bg-root));
}
.node-card.fault { border-color: color-mix(in srgb, var(--danger) 55%, transparent); }
.node-card.fault.active { border-color: var(--danger); }
.node-card.offline { opacity: .5; }
/* motor.html 的 N1–N9 紧凑列表项(.node-item/.ni-*)。
* 与上面 .node-card 是两套东西: .node-card 供 ecat.html 的单从站卡片,
* 这里是 motor.js renderNodeList() 渲染的九行节点。类名对不上时整列表会裸奔。
* 单行布局: LED | N1 | 故障标签 | 右对齐读数 */
.node-item {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-root);
cursor: pointer;
opacity: .5; /* 默认离线态; .online 时恢复 */
transition: border-color .2s, background .2s, opacity .2s, transform .12s;
}
.node-item:hover { border-color: var(--border-strong); transform: translateX(2px); }
.node-item:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
.node-item.online { opacity: 1; }
.node-item.active {
border-color: color-mix(in srgb, var(--seed-primary) 55%, transparent);
background: color-mix(in srgb, var(--seed-primary) 6%, var(--bg-root));
}
.node-item .ni-led {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
background: var(--fg-dim); transition: background .3s, box-shadow .3s;
}
.node-item.online .ni-led { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
.node-item .ni-name {
font-family: var(--font-mono); font-weight: 700; font-size: .9rem;
letter-spacing: -0.02em; min-width: 2.1em;
}
/* 故障标签默认不占位, 有内容(.has)时才显示 */
.node-item .ni-fault { display: none; }
.node-item .ni-fault.has {
display: inline-block;
font-size: .65rem; padding: 1px 6px; border-radius: 99px;
background: var(--danger-dim); color: var(--danger);
font-weight: 600; letter-spacing: .02em;
max-width: 8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.node-item.online:has(.ni-fault.has) { border-color: color-mix(in srgb, var(--danger) 55%, transparent); }
.node-item .ni-sub {
margin-left: auto;
font-family: var(--font-mono); font-size: .68rem; color: var(--fg-muted);
letter-spacing: .01em; white-space: nowrap;
}
/* 独立 BOOT 徽章:总线上有电机在 Bootloader(0x103 心跳),不归属任何 Nx
* 注意:不要用内联 style="display:none",否则 .show 的 display:flex 盖不过。 */
.boot-badge {
display: none; align-items: center; gap: var(--sp-2);
margin-bottom: var(--sp-2); padding: var(--sp-2) var(--sp-3);
border: 1px solid color-mix(in srgb, var(--warn) 55%, transparent);
border-radius: var(--radius); background: color-mix(in srgb, var(--warn) 12%, transparent);
font-family: var(--font-mono); font-size: .72rem; color: var(--warn);
font-weight: 600; letter-spacing: 0.02em;
}
.boot-badge.show { display: flex; }
.boot-badge .boot-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
background: var(--warn); box-shadow: 0 0 6px var(--warn);
animation: bootpulse 1s ease-in-out infinite;
}
.boot-badge .boot-sub { opacity: .75; font-weight: 400; margin-left: auto; }
@keyframes bootpulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
.nc-top { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-2); }
.nc-id {
font-family: var(--font-mono); font-weight: 700; font-size: 1.05rem;
letter-spacing: -0.02em; min-width: 2.2em;
}
.nc-mode {
font-size: 0.7rem; padding: 2px 8px; border-radius: 99px;
background: var(--primary-dim); color: var(--primary);
font-weight: 550; letter-spacing: 0.02em;
}
.nc-mode.mode-off { background: color-mix(in srgb, var(--fg) 8%, transparent); color: var(--fg-muted); }
.nc-mode.mode-fault { background: var(--danger-dim); color: var(--danger); }
/* 节点在其他通道在线:橙色通道标签,提示切换顶部通道查看 */
.nc-mode.mode-other-ch { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); }
.nc-status-text {
margin-left: auto; font-size: 0.68rem; color: var(--fg-muted);
font-family: var(--font-mono); max-width: 7.5em;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
letter-spacing: 0.01em;
}
.node-card.fault .nc-status-text { color: var(--danger); font-weight: 600; }
.nc-readings {
display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-1);
font-family: var(--font-mono); font-size: 0.78rem;
}
.nc-reading { display: flex; flex-direction: column; gap: 1px; }
.nc-reading .lbl { font-size: 0.62rem; color: var(--fg-dim); letter-spacing: 0.06em; text-transform: uppercase; }
.nc-reading .val { color: var(--fg); transition: color .3s; }
.nc-faults {
position: absolute; top: var(--sp-2); right: var(--sp-2);
display: flex; gap: 3px;
}
.fault-dot {
width: 7px; height: 7px; border-radius: 2px;
background: var(--danger); opacity: .9;
animation: ledPulse 1s ease-in-out infinite;
}
.fault-dot:nth-child(2) { animation-delay: .2s; }
.fault-dot:nth-child(3) { animation-delay: .4s; }
/* ═══════════════════════ CENTER: CONTROL ═══════════════════════ */
.center-panel { display: flex; flex-direction: column; min-height: 0; }
.cmd-section { padding: var(--sp-4); flex-shrink: 0; }
.cmd-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--sp-4);
}
.field { display: flex; flex-direction: column; gap: var(--sp-1); }
.field-label {
font-size: 0.72rem; font-weight: 550; color: var(--fg-muted);
letter-spacing: 0.06em; text-transform: uppercase; font-family: var(--font-mono);
}
.field-unit { color: var(--fg-dim); text-transform: none; letter-spacing: 0; }
.field select, .field input[type="number"], .field input[type="text"] {
padding: 7px 10px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.88rem; outline: none;
transition: border-color .2s;
width: 100%;
}
.field select:focus, .field input:focus { border-color: var(--primary); }
.mode-desc {
font-size: 0.75rem; color: var(--fg-dim); margin-top: 2px;
min-height: 1.2em;
}
.pid-group {
display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-3);
}
/* 快捷运行:一键写目标值并立即发送(目标字段随控制模式联动) */
.quick-run {
display: flex; align-items: center; gap: var(--sp-3);
margin-top: var(--sp-4); flex-wrap: wrap;
}
.qr-label {
font-size: 0.72rem; font-weight: 550; color: var(--fg-muted);
letter-spacing: 0.06em; text-transform: uppercase; font-family: var(--font-mono);
user-select: none;
}
.qr-target { color: var(--fg-dim); text-transform: none; letter-spacing: 0; }
.qr-btns { display: inline-flex; gap: 6px; flex-wrap: wrap; }
.qr-btn {
min-width: 52px; padding: 6px 12px;
background: var(--bg-root); border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); color: var(--fg);
font-family: var(--font-mono); font-size: 0.88rem; font-weight: 600;
cursor: pointer; transition: border-color .15s, color .15s, background .15s;
}
.qr-btn:hover { border-color: var(--primary); color: var(--primary); }
.qr-btn:active { transform: translateY(1px); }
.qr-btn:focus-visible { outline: none; border-color: var(--primary); }
.cmd-actions {
display: flex; gap: var(--sp-3); margin-top: var(--sp-4);
padding-top: var(--sp-3); border-top: 1px solid var(--border);
align-items: center; flex-wrap: wrap;
}
.send-hint { font-size: 0.72rem; color: var(--fg-dim); margin-left: auto; font-family: var(--font-mono); }
.target-hidden { display: none !important; }
.repeat-period {
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-mono); font-size: 0.75rem; color: var(--fg-muted);
user-select: none;
}
.repeat-period input {
width: 4.6em; padding: 5px 6px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.8rem; color: var(--fg);
outline: none; text-align: right; transition: border-color .2s;
}
.repeat-period input:focus { border-color: var(--primary); }
/* tabs */
.tabs-bar {
display: flex; gap: 2px; padding: 0 var(--sp-4);
border-top: 1px solid var(--border); flex-shrink: 0;
background: color-mix(in srgb, var(--bg-root) 50%, var(--bg-panel));
}
.tab-btn {
padding: 9px 16px; font-size: 0.8rem; font-weight: 550;
color: var(--fg-muted); border-bottom: 2px solid transparent;
transition: color .2s, border-color .2s; letter-spacing: 0.02em;
}
.tab-btn:hover { color: var(--fg); }
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
.tab-content { flex: 1; overflow-y: auto; min-height: 0; padding: var(--sp-4); }
.tab-page { display: none; }
.tab-page.active { display: block; }
/* config tab */
.config-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: var(--sp-3); }
.config-btn {
display: flex; flex-direction: column; align-items: center; gap: var(--sp-2);
padding: var(--sp-4); border-radius: var(--radius);
border: 1px solid var(--border-strong); background: var(--bg-root);
transition: border-color .2s, background .2s, transform .1s;
}
.config-btn:hover { border-color: var(--fg-dim); transform: translateY(-1px); }
.config-btn:active { transform: translateY(1px); }
.config-btn svg { opacity: .8; }
.config-btn .cb-name { font-size: 0.82rem; font-weight: 550; }
.config-btn .cb-sig { font-size: 0.68rem; color: var(--fg-dim); font-family: var(--font-mono); }
.config-btn.danger-action:hover { border-color: color-mix(in srgb, var(--danger) 50%, transparent); }
/* version tab */
.version-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-3); }
.ver-card {
padding: var(--sp-4); border-radius: var(--radius);
border: 1px solid var(--border); background: var(--bg-root); text-align: center;
}
.ver-card .ver-type { font-size: 0.68rem; color: var(--fg-muted); letter-spacing: 0.08em; text-transform: uppercase; font-family: var(--font-mono); margin-bottom: var(--sp-2); }
.ver-card .ver-date { font-family: var(--font-mono); font-size: 1.05rem; font-weight: 600; letter-spacing: -0.01em; }
.ver-empty { color: var(--fg-dim); font-size: 0.85rem; }
/* node id tab */
.nodeid-row { display: flex; gap: var(--sp-3); align-items: flex-end; flex-wrap: wrap; }
.nodeid-result {
margin-top: var(--sp-3); padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius-sm); background: var(--bg-root);
border: 1px solid var(--border); font-family: var(--font-mono); font-size: 0.85rem;
color: var(--fg-muted); min-height: 2.6em;
}
/* OTA tab */
.ota-stages {
display: flex; gap: 0; margin-bottom: var(--sp-4); overflow-x: auto;
padding-bottom: var(--sp-1);
}
.ota-stage {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
font-size: 0.72rem; color: var(--fg-dim); white-space: nowrap;
position: relative;
}
.ota-stage::after {
content: ""; width: 20px; height: 1px; background: var(--border-strong);
margin-left: var(--sp-2); flex-shrink: 0;
}
.ota-stage:last-child::after { display: none; }
.ota-stage .st-dot {
width: 20px; height: 20px; border-radius: 50%; flex-shrink: 0;
border: 1.5px solid var(--border-strong);
display: flex; align-items: center; justify-content: center;
font-family: var(--font-mono); font-size: 0.62rem;
transition: border-color .3s, background .3s, color .3s;
}
.ota-stage.done .st-dot { border-color: var(--ok); background: var(--ok-dim); color: var(--ok); }
.ota-stage.current .st-dot { border-color: var(--primary); background: var(--primary-dim); color: var(--primary); animation: ledPulse 1.4s infinite; }
.ota-stage.current { color: var(--fg); }
.ota-stage.done { color: var(--fg-muted); }
.ota-progress-wrap { margin-bottom: var(--sp-4); }
.ota-progress-bar {
height: 8px; border-radius: 4px; background: var(--bg-root);
border: 1px solid var(--border); overflow: hidden;
}
.ota-progress-fill {
height: 100%; width: 0%; border-radius: 4px;
background: linear-gradient(90deg, color-mix(in srgb, var(--seed-primary) 70%, var(--bg-root)), var(--primary));
transition: width .3s ease;
}
.ota-stats {
display: flex; gap: var(--sp-5); margin-top: var(--sp-2);
font-family: var(--font-mono); font-size: 0.78rem; color: var(--fg-muted);
flex-wrap: wrap;
}
.ota-stats .hl { color: var(--primary); }
.ota-file-row { display: flex; gap: var(--sp-3); align-items: center; margin-bottom: var(--sp-4); flex-wrap: wrap; }
.ota-file-name { font-family: var(--font-mono); font-size: 0.8rem; color: var(--fg-muted); }
.ota-log {
margin-top: var(--sp-3); padding: var(--sp-3);
background: var(--bg-root); border: 1px solid var(--border);
border-radius: var(--radius-sm); font-family: var(--font-mono);
font-size: 0.72rem; line-height: 1.7; color: var(--fg-muted);
max-height: 160px; overflow-y: auto;
}
.ota-log .log-ok { color: var(--ok); }
.ota-log .log-err { color: var(--danger); }
.ota-log .log-info { color: var(--primary); }
.ota-log-toolbar {
display: flex; justify-content: flex-end; gap: 6px;
margin-top: var(--sp-3); margin-bottom: -2px;
}
.ota-log-toolbar button {
font-size: 0.68rem; padding: 2px 8px;
border-radius: var(--radius-sm); border: 1px solid var(--border);
background: var(--bg-card); color: var(--fg-muted);
cursor: pointer; transition: background .15s, color .15s;
}
.ota-log-toolbar button:hover { background: rgba(255,255,255,.08); color: var(--fg); }
.ota-log-toolbar button.copied { background: rgba(34,197,94,.18); color: #86efac; }
/* ═══════════════════════ RIGHT: STATUS PANEL ═══════════════════════ */
.status-panel .panel-body { padding: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-4); }
.big-readings { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-3); }
.big-reading {
padding: var(--sp-3) var(--sp-3); text-align: center;
background: var(--bg-root); border: 1px solid var(--border);
border-radius: var(--radius);
transition: border-color .3s, background .2s;
cursor: pointer; position: relative;
}
.big-reading:hover { border-color: var(--border-strong); background: color-mix(in srgb, var(--seed-primary) 4%, var(--bg-root)); }
.big-reading.open { border-color: color-mix(in srgb, var(--seed-primary) 50%, transparent); }
.br-toggle {
font-size: 0.55rem; color: var(--fg-dim); letter-spacing: 0.06em;
opacity: 0; transition: opacity .2s, color .2s; margin-left: 4px;
}
.big-reading:hover .br-toggle { opacity: .8; }
.big-reading.open .br-toggle { opacity: 1; color: var(--primary); }
.br-scope {
max-height: 0; overflow: hidden; margin-top: 0;
transition: max-height .35s cubic-bezier(.4,0,.2,1), margin-top .35s;
}
.big-reading.open .br-scope { max-height: 96px; margin-top: var(--sp-2); }
.br-scope canvas {
width: 100%; height: 84px; display: block;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--seed-bg) 78%, #000000 14%);
border: 1px solid var(--border);
}
.big-reading .br-val {
font-family: var(--font-mono); font-size: clamp(1.5rem, 2vw, 2.4rem);
font-weight: 700; letter-spacing: -0.02em; line-height: 1.2;
transition: color .25s;
}
.big-reading .br-val.flash { color: var(--primary); }
.big-reading .br-unit { font-size: 0.65rem; color: var(--fg-dim); font-family: var(--font-mono); }
.big-reading .br-lbl {
font-size: 0.62rem; color: var(--fg-muted); margin-top: 2px;
letter-spacing: 0.08em; text-transform: uppercase; font-family: var(--font-mono);
}
.big-reading .br-target {
font-size: 0.65rem; color: var(--fg-dim); font-family: var(--font-mono); margin-top: 1px;
}
.big-reading .br-target span { color: var(--primary); font-weight: 600; }
.big-reading .br-target span:empty::before { content: "--"; color: var(--fg-dim); }
.status-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
.temp-block {
padding: var(--sp-3); background: var(--bg-root); border: 1px solid var(--border);
border-radius: var(--radius);
transition: border-color .3s, background .2s; cursor: pointer;
}
.temp-block:hover { border-color: var(--border-strong); background: color-mix(in srgb, var(--seed-primary) 4%, var(--bg-root)); }
.temp-block.open { border-color: color-mix(in srgb, var(--seed-primary) 50%, transparent); }
.temp-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: var(--sp-2); }
.temp-name { font-size: 0.68rem; color: var(--fg-muted); letter-spacing: 0.06em; text-transform: uppercase; font-family: var(--font-mono); }
.temp-toggle {
font-size: 0.55rem; color: var(--fg-dim); letter-spacing: 0.06em;
opacity: 0; transition: opacity .2s, color .2s; margin-left: 6px;
}
.temp-block:hover .temp-toggle { opacity: .8; }
.temp-block.open .temp-toggle { opacity: 1; color: var(--primary); }
.temp-val { font-family: var(--font-mono); font-size: 0.95rem; font-weight: 600; }
.temp-bar { height: 5px; border-radius: 3px; background: var(--border); overflow: hidden; }
.temp-block .br-scope { max-height: 0; overflow: hidden; margin-top: 0; transition: max-height .35s cubic-bezier(.4,0,.2,1), margin-top .35s; }
.temp-block.open .br-scope { max-height: 80px; margin-top: var(--sp-2); }
.temp-block .br-scope canvas { height: 68px; }
.temp-fill {
height: 100%; border-radius: 3px; transition: width .6s ease, background .6s;
background: var(--ok);
}
.temp-fill.warm { background: var(--warn); }
.temp-fill.hot { background: var(--danger); }
.phase-currents { padding: var(--sp-3); background: var(--bg-root); border: 1px solid var(--border); border-radius: var(--radius); }
.pc-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); margin-bottom: var(--sp-2); flex-wrap: wrap; }
.pc-title { font-size: 0.68rem; color: var(--fg-muted); letter-spacing: 0.06em; text-transform: uppercase; font-family: var(--font-mono); }
.pc-live-vals { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
.pc-chip {
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-mono); font-size: 0.72rem; color: var(--fg-muted);
padding: 2px 8px; border-radius: 99px;
background: color-mix(in srgb, var(--ch-color) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--ch-color) 30%, transparent);
}
.pc-chip b { color: var(--ch-color); font-weight: 600; min-width: 4.2em; text-align: right; }
.scope-wrap { position: relative; height: 172px; border-radius: var(--radius-sm); overflow: hidden; background: color-mix(in srgb, var(--seed-bg) 80%, #000000 12%); border: 1px solid var(--border); }
.scope-wrap canvas { width: 100%; height: 100%; display: block; }
.scope-grid-labels {
position: absolute; left: 6px; top: 0; bottom: 0;
display: flex; flex-direction: column; justify-content: space-between;
font-family: var(--font-mono); font-size: 0.58rem; color: var(--fg-dim);
padding: 4px 0; pointer-events: none;
}
.misc-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
.misc-item {
padding: var(--sp-3); background: var(--bg-root);
border: 1px solid var(--border); border-radius: var(--radius);
display: flex; flex-direction: column; gap: 2px;
}
.misc-item .mi-lbl { font-size: 0.62rem; color: var(--fg-muted); letter-spacing: 0.08em; text-transform: uppercase; font-family: var(--font-mono); }
.misc-item .mi-val { font-family: var(--font-mono); font-size: 1rem; font-weight: 600; }
.flag-lights { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--sp-2); padding: var(--sp-3); background: var(--bg-root); border: 1px solid var(--border); border-radius: var(--radius); }
.flag-item {
display: flex; align-items: center; gap: var(--sp-2);
font-size: 0.75rem; font-family: var(--font-mono); color: var(--fg-muted);
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: color-mix(in srgb, var(--seed-surface) 60%, transparent);
transition: background .3s, border-color .3s, color .3s, box-shadow .3s;
}
.flag-item .flag-state { margin-left: auto; font-size: 0.62rem; letter-spacing: 0.08em; text-transform: uppercase; opacity: .7; }
.flag-item.is-ok { border-color: color-mix(in srgb, var(--ok) 25%, transparent); }
.flag-item.is-ok .flag-state { color: var(--ok); }
.flag-item.is-fault {
background: color-mix(in srgb, var(--danger) 16%, var(--seed-bg));
border-color: color-mix(in srgb, var(--danger) 65%, transparent);
color: var(--danger); font-weight: 700;
box-shadow: 0 0 12px color-mix(in srgb, var(--danger) 25%, transparent), inset 0 0 8px color-mix(in srgb, var(--danger) 8%, transparent);
animation: faultPulse 1.1s ease-in-out infinite;
}
.flag-item.is-fault .flag-state { color: var(--danger); opacity: 1; }
@keyframes faultPulse {
0%, 100% { box-shadow: 0 0 12px color-mix(in srgb, var(--danger) 30%, transparent), inset 0 0 8px color-mix(in srgb, var(--danger) 10%, transparent); }
50% { box-shadow: 0 0 22px color-mix(in srgb, var(--danger) 50%, transparent), inset 0 0 12px color-mix(in srgb, var(--danger) 16%, transparent); }
}
.flag-led { width: 9px; height: 9px; border-radius: 50%; background: var(--border-strong); transition: background .3s, box-shadow .3s; flex-shrink: 0; }
.flag-led.on-ok { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
.flag-led.on-err { background: var(--danger); box-shadow: 0 0 8px var(--danger); animation: ledPulse .8s infinite; }
.cpu-ring-wrap { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-root); border: 1px solid var(--border); border-radius: var(--radius); }
.cpu-ring { position: relative; width: 52px; height: 52px; flex-shrink: 0; }
.cpu-ring svg { transform: rotate(-90deg); }
.cpu-ring .ring-bg { fill: none; stroke: var(--border); stroke-width: 5; }
.cpu-ring .ring-fg { fill: none; stroke: var(--primary); stroke-width: 5; stroke-linecap: round; transition: stroke-dashoffset .6s ease; }
.cpu-pct {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700;
}
.cpu-info { display: flex; flex-direction: column; gap: 2px; }
.cpu-info .mi-lbl { font-size: 0.62rem; color: var(--fg-muted); letter-spacing: 0.08em; text-transform: uppercase; font-family: var(--font-mono); }
.cpu-info .mi-sub { font-size: 0.72rem; color: var(--fg-dim); font-family: var(--font-mono); }
.offline-overlay {
display: none; flex-direction: column; align-items: center; justify-content: center;
gap: var(--sp-3); padding: var(--sp-6); color: var(--fg-dim); text-align: center;
}
.offline-overlay.show { display: flex; }
.offline-overlay .off-icon { opacity: .4; }
.status-live { display: flex; flex-direction: column; gap: var(--sp-4); }
.status-live.hide { display: none; }
/* ═══════════════════════ STATUS BAR ═══════════════════════ */
.statusbar {
grid-row: 3;
grid-column: 1;
position: relative; z-index: 10;
display: flex; align-items: center; gap: var(--sp-4);
padding: 0 var(--sp-5);
background: color-mix(in srgb, var(--bg-panel) 92%, #000000 8%);
border-top: 1px solid var(--border);
font-family: var(--font-mono); font-size: 0.72rem; color: var(--fg-muted);
overflow: hidden;
}
.sb-item { display: flex; align-items: center; gap: var(--sp-2); white-space: nowrap; }
.sb-item .sb-val { color: var(--fg); }
.sb-sep { width: 1px; height: 16px; background: var(--border); flex-shrink: 0; }
.sb-last-send { margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; color: var(--fg-dim); }
/* ═══════════════════════ TOAST & MODAL ═══════════════════════ */
.toast-container {
position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: var(--sp-2); z-index: 1000;
pointer-events: none;
}
.toast {
padding: 10px 20px; border-radius: var(--radius);
background: var(--bg-raised); border: 1px solid var(--border-strong);
font-size: 0.82rem; box-shadow: 0 8px 32px rgba(0,0,0,.5);
animation: toastIn .25s ease;
display: flex; align-items: center; gap: var(--sp-2);
max-width: 80vw;
}
.toast.ok { border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
.toast.err { border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
.toast.warn { border-color: color-mix(in srgb, var(--warn) 40%, transparent); }
.toast.out { animation: toastOut .2s ease forwards; }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes toastOut { to { opacity: 0; transform: translateY(6px); } }
.modal-mask {
position: fixed; inset: 0; z-index: 900;
background: rgba(4, 8, 14, .75);
display: none; align-items: center; justify-content: center;
}
.modal-mask.show { display: flex; }
.modal-box {
background: var(--bg-panel); border: 1px solid var(--border-strong);
border-radius: var(--radius-lg); padding: var(--sp-6);
min-width: 320px; max-width: 420px;
box-shadow: 0 24px 64px rgba(0,0,0,.6);
animation: toastIn .2s ease;
}
.modal-title { font-size: 1rem; font-weight: 600; margin-bottom: var(--sp-2); }
.modal-desc { font-size: 0.82rem; color: var(--fg-muted); margin-bottom: var(--sp-5); line-height: 1.6; }
.modal-actions { display: flex; gap: var(--sp-3); justify-content: flex-end; }
/* disconnected banner */
.net-banner {
display: none; grid-row: 1; grid-column: 1;
align-self: end; transform: translateY(100%); z-index: 20;
align-items: center; gap: var(--sp-2);
padding: 4px var(--sp-5); font-size: .72rem; font-family: var(--font-mono);
background: linear-gradient(90deg, color-mix(in srgb, var(--danger) 20%, var(--bg-panel)), var(--danger-dim)); color: var(--danger);
border-bottom: 1px solid color-mix(in srgb, var(--danger) 30%, transparent);
box-shadow: 0 8px 20px rgba(0, 0, 0, .24);
}
.net-banner.show { display: flex; }
body:has(.net-banner.show) .main-grid { padding-top: calc(var(--sp-3) + 29px); }
/* ═══════════════════════ 命令序列 tab ═══════════════════════ */
.seq-toolbar { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-4); }
.seq-status { font-family: var(--font-mono); font-size: 0.78rem; color: var(--fg-dim); margin-left: auto; white-space: nowrap; }
.seq-status.on { color: var(--primary); }
.seq-list { display: flex; flex-direction: column; gap: var(--sp-3); }
.seq-empty {
padding: var(--sp-5); text-align: center; color: var(--fg-dim); font-size: 0.8rem;
border: 1px dashed var(--border-strong); border-radius: var(--radius); line-height: 1.7;
}
.seq-step {
padding: var(--sp-3); border-radius: var(--radius);
border: 1px solid var(--border); background: var(--bg-root);
transition: border-color .2s, background .2s;
}
.seq-step:hover { border-color: var(--border-strong); }
.seq-step.current {
border-color: color-mix(in srgb, var(--seed-primary) 55%, transparent);
background: color-mix(in srgb, var(--seed-primary) 5%, var(--bg-root));
}
.seq-locked .seq-step { opacity: .55; pointer-events: none; }
.seq-step-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-2); }
.seq-idx { font-family: var(--font-mono); font-size: 0.78rem; font-weight: 600; color: var(--primary); min-width: 4.2em; }
.seq-num {
padding: 4px 8px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.78rem; outline: none; color: var(--fg);
transition: border-color .2s;
}
.seq-num:focus { border-color: var(--primary); }
/* 隐藏原生上下箭头(样式丑且尺寸不可控):数值直接键入,聚焦后可用键盘 ↑↓ 微调 */
.seq-num { -moz-appearance: textfield; appearance: textfield; }
.seq-num::-webkit-inner-spin-button,
.seq-num::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.seq-step-head .seq-num { width: 6.8em; text-align: right; }
.seq-step-head select {
padding: 4px 8px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.78rem; outline: none; color: var(--fg);
}
.seq-step-head select:focus { border-color: var(--primary); }
.seq-hold-label, .seq-hold-unit { font-size: 0.68rem; color: var(--fg-dim); font-family: var(--font-mono); }
.seq-ops { margin-left: auto; display: flex; gap: 2px; }
.seq-ops button {
width: 26px; height: 26px; border-radius: var(--radius-sm);
display: inline-flex; align-items: center; justify-content: center;
color: var(--fg-dim); background: transparent; border: none;
transition: background .15s, color .15s;
}
.seq-ops button svg { flex-shrink: 0; }
.seq-ops button:hover:not(:disabled) { background: var(--primary-dim); color: var(--primary); }
.seq-ops button.seq-del:hover:not(:disabled) { background: var(--danger-dim); color: var(--danger); }
.seq-ops button:disabled { opacity: .25; cursor: default; }
.seq-step-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: var(--sp-2); }
.seq-step-fields .field-label { font-size: 0.62rem; }
.seq-step-fields .seq-num { width: 100%; }
/* 动作组:一步内多个电机动作(近同时下发),动作行嵌套在步骤卡片内 */
.seq-count {
font-family: var(--font-mono); font-size: 0.68rem; color: var(--fg-dim);
padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; white-space: nowrap;
}
.seq-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
.seq-action {
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--bg-panel);
}
.seq-action-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-2); }
.seq-action-tag { font-family: var(--font-mono); font-size: 0.7rem; font-weight: 600; color: var(--fg-muted); min-width: 4.2em; }
.seq-action-head select {
padding: 4px 8px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.78rem; outline: none; color: var(--fg);
}
.seq-action-head select:focus { border-color: var(--primary); }
.seq-hint { margin-top: var(--sp-4); font-size: 0.72rem; color: var(--fg-dim); line-height: 1.6; }
/* ═══════════════════════ RESPONSIVE ═══════════════════════ */
@media (max-width: 1365px) {
.main-grid {
grid-template-columns: 1fr minmax(380px, 1.1fr);
grid-template-rows: auto 1fr;
}
.nodes-panel {
grid-column: 1 / -1; grid-row: 1;
}
.nodes-panel .panel-body { overflow-x: auto; overflow-y: hidden; }
.node-list { flex-direction: row; min-width: max-content; }
.node-card { min-width: 200px; }
.center-panel { grid-column: 1; grid-row: 2; }
.status-panel { grid-column: 2; grid-row: 2; }
}
@media (max-width: 900px) {
.main-grid { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
.center-panel { grid-column: 1; grid-row: 2; }
.status-panel { grid-column: 1; grid-row: 3; max-height: 380px; }
.toolbar { flex-wrap: wrap; height: auto; padding: var(--sp-2) var(--sp-4); }
body { grid-template-rows: auto 1fr var(--statusbar-h); overflow: auto; }
}
+598
View File
@@ -0,0 +1,598 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>EtherCAT 控制台 — DCU LAN9252</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="ecat.css?v=20260903b">
<style>
/* ecat 页面专属补充(基于 motor.css 设计体系) */
.ecat-grid { grid-template-columns: minmax(240px, 290px) minmax(0, 1fr) minmax(0, 1.1fr); }
.center-col { display: flex; flex-direction: column; min-height: 0; }
.center-col .global-log { flex: 1; min-height: 90px; max-height: none; }
.mode-op { background: var(--ok-dim); color: var(--ok); }
.mode-safeop { background: var(--warn-dim); color: var(--warn); }
.mode-preop { background: color-mix(in srgb, var(--primary) 16%, transparent); color: var(--primary); }
.eep { margin-top: var(--sp-3); padding: var(--sp-3); background: var(--bg-root);
border: 1px solid var(--border); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.74rem; line-height: 1.7;
color: var(--fg-muted); white-space: pre; overflow: auto; max-height: 170px; }
.btn-row { display: flex; gap: var(--sp-2); flex-wrap: wrap; margin-top: var(--sp-3); align-items: center; }
.br-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-2); margin-top: var(--sp-3); }
.br-row .big-reading { cursor: default; padding: var(--sp-2) var(--sp-2); }
.br-row .br-val { font-size: 0.95rem; }
.stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-2);
margin-top: var(--sp-3); }
.stat-cell { padding: var(--sp-2) var(--sp-3); background: var(--bg-root);
border: 1px solid var(--border); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.7rem; color: var(--fg-dim); }
.stat-cell b { display: block; font-size: 1rem; color: var(--fg); font-weight: 600; }
.stat-cell b.err { color: var(--danger); }
.statusbar .btn { padding: 2px 12px; font-size: .68rem; }
</style>
</head>
<body>
<!-- ═══════════ TOOLBAR ═══════════ -->
<header class="toolbar">
<div class="toolbar-brand">
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" aria-hidden="true">
<rect x="1" y="1" width="24" height="24" rx="6" stroke="var(--primary)" stroke-width="1.5" opacity=".6"/>
<path d="M8 7l-4.2 6L8 19M18 7l4.2 6L18 19" stroke="var(--primary)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="13" cy="13" r="1.6" fill="var(--primary)"/>
</svg>
<span>EtherCAT 控制台</span>
<span class="sub">DCU LAN9252</span>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group">
<span class="led" id="connLed"></span>
<input type="text" id="substr" value="I226" style="width:96px" aria-label="适配器匹配子串">
<button class="btn btn-primary" id="btnConn">连接</button>
<button class="btn" id="btnDisc" disabled>断开</button>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group">
<span class="refresh-hint">ESC EEPROM 直读 · 烧录后断电重启生效</span>
</div>
<div class="toolbar-right">
<a class="btn btn-sm" href="/motor.html" title="电机控制台(经 EtherCAT 过程数据,不走 WiFi/HTTP)">电机控制台</a>
<span class="node-badge" id="alBadge">AL —</span>
</div>
</header>
<div class="net-banner" id="netBanner">
<span class="led led-err led-pulse" style="width:7px;height:7px"></span>
<span id="netBannerText">未连接 — 输入适配器匹配串(默认 I226)后点击连接</span>
</div>
<!-- ═══════════ MAIN GRID ═══════════ -->
<main class="main-grid ecat-grid">
<!-- LEFT: 从站身份 -->
<aside class="panel">
<div class="panel-head">
<span class="panel-title">从站身份</span>
<span style="margin-left:auto;font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono)" id="connDesc">—</span>
</div>
<div class="panel-body" style="padding:var(--sp-3)">
<div class="node-card" id="slaveCard">
<div class="nc-top">
<span class="nc-id">S0</span>
<span class="nc-mode mode-off" id="alMode">—</span>
</div>
<div class="nc-status-text" id="slaveName">未连接</div>
<div class="nc-readings">
<div class="nc-reading"><span class="lbl">VendorID</span><span class="val" id="rVid">—</span></div>
<div class="nc-reading"><span class="lbl">ProductCode</span><span class="val" id="rPid">—</span></div>
<div class="nc-reading"><span class="lbl">Revision</span><span class="val" id="rRev">—</span></div>
<div class="nc-reading"><span class="lbl">AL Status Code</span><span class="val" id="rCode">—</span></div>
</div>
</div>
<div class="mode-desc" style="margin-top:var(--sp-3)">
身份字来自 SII(ESC EEPROM 接口直读, 无需断电重载)。<br>
全 F = EEPROM 无效 → 先在 EEPROM 页烧录。<br>
AL 进 PREOP 后 SDO 可用; OP 需 SII 的 SM/FMMU 配置有效。
</div>
</div>
</aside>
<!-- CENTER: EEPROM + 全局日志 -->
<section class="panel center-col">
<div class="panel-head">
<span class="panel-title">EEPROM · SII / AT24C32</span>
<span style="margin-left:auto;font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono)">4096 B · word 寻址</span>
</div>
<div class="panel-body" style="padding:var(--sp-4);display:flex;flex-direction:column;min-height:0">
<div class="cmd-grid">
<div class="field">
<span class="field-label">start <span class="field-unit">word 地址</span></span>
<input type="text" id="eeStart" value="0x0000">
</div>
<div class="field">
<span class="field-label">count <span class="field-unit">word 数</span></span>
<input type="number" id="eeCount" value="32" min="1" max="2048">
</div>
<div class="field">
<span class="field-label">&nbsp;</span>
<button class="btn" id="btnEeRead" disabled>读取</button>
</div>
</div>
<div class="eep" id="eeOut">—</div>
<div class="btn-row">
<button class="btn btn-danger" id="btnFlashGen" disabled>烧录 gen_eeprom.py 镜像</button>
<button class="btn btn-danger" id="btnFlashFile" disabled>烧录 bin 文件…</button>
<button class="btn" id="btnVerifyFile" disabled>校验 bin 文件…</button>
<input type="file" id="eeFile" style="display:none">
</div>
<div class="mode-desc" style="margin-top:var(--sp-2)">
烧录逐字校验约数秒; 完成后板子断电重启, ESC 才重载 PDI/SYNC 配置。
</div>
<div style="flex:1;min-height:0;display:flex;flex-direction:column;margin-top:var(--sp-4)">
<div class="panel-title" style="margin-bottom:var(--sp-2)">全局日志</div>
<div class="ota-log global-log" id="log"></div>
<div class="ota-log-toolbar">
<button id="btnClearLog">清空</button>
</div>
</div>
</div>
</section>
<!-- RIGHT: PDO / SDO -->
<section class="panel">
<div class="panel-head">
<span class="panel-title">过程数据 · 4×64bit</span>
<span style="margin-left:auto;font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono)" id="pdoSize">输出 — / 输入 —</span>
</div>
<div class="tabs-bar">
<button class="tab-btn active" data-tab="pdo">过程数据</button>
<button class="tab-btn" data-tab="sdo">SDO (CoE)</button>
<button class="tab-btn" data-tab="can">CAN 转发</button>
</div>
<div class="tab-content">
<!-- PDO 页 -->
<div class="tab-page active" id="tabPdo">
<div class="cmd-grid">
<div class="field"><span class="field-label">输出 u64[0]</span><input type="text" id="o0" value="0x0"></div>
<div class="field"><span class="field-label">输出 u64[1]</span><input type="text" id="o1" value="0x0"></div>
<div class="field"><span class="field-label">输出 u64[2]</span><input type="text" id="o2" value="0x0"></div>
<div class="field"><span class="field-label">输出 u64[3]</span><input type="text" id="o3" value="0x0"></div>
</div>
<div class="btn-row">
<div class="field" style="min-width:110px">
<span class="field-label">周期 <span class="field-unit">ms</span></span>
<select id="rate"><option value="10">10</option><option value="50">50</option>
<option value="100" selected>100</option><option value="200">200</option></select>
</div>
<div class="field" style="min-width:150px">
<span class="field-label">DC SYNC0 <span class="field-unit">us</span></span>
<select id="dcCycle">
<option value="0" selected>关 (free-run)</option>
<option value="500">500</option><option value="1000">1000</option>
<option value="2000">2000</option><option value="4000">4000</option>
</select>
</div>
<div class="field" style="min-width:150px;justify-content:flex-end">
<span class="field-label">&nbsp;</span>
<button class="btn btn-primary" id="btnOp" disabled>进入 OP 并循环</button>
</div>
</div>
<div class="br-row">
<div class="big-reading"><div class="br-val" id="in0">—</div><div class="br-lbl">IN u64[0]</div></div>
<div class="big-reading"><div class="br-val" id="in1">—</div><div class="br-lbl">IN u64[1]</div></div>
<div class="big-reading"><div class="br-val" id="in2">—</div><div class="br-lbl">IN u64[2]</div></div>
<div class="big-reading"><div class="br-val" id="in3">—</div><div class="br-lbl">IN u64[3]</div></div>
</div>
<div class="stat-row">
<div class="stat-cell">周期<b id="kCyc">0</b></div>
<div class="stat-cell">WKC 错误<b id="kErr">0</b></div>
<div class="stat-cell">输出区<b id="kOut">—</b></div>
<div class="stat-cell">输入区<b id="kIn">—</b></div>
<div class="stat-cell">SYNC0<b id="kSync0">—</b></div>
<div class="stat-cell">SYNC0/s<b id="kSyncRate">—</b></div>
</div>
<div class="eep" id="inHex" style="margin-top:var(--sp-3)">—</div>
<div class="mode-desc" style="margin-top:var(--sp-2)">
循环期间每周期都把上方 4 个输出值写入输出 PDO, 修改即时生效。
</div>
</div>
<!-- SDO 页 -->
<div class="tab-page" id="tabSdo">
<div class="cmd-grid">
<div class="field"><span class="field-label">index</span><input type="text" id="sdoIdx" value="0x1000"></div>
<div class="field"><span class="field-label">sub</span><input type="text" id="sdoSub" value="0"></div>
<div class="field"><span class="field-label">size <span class="field-unit">读</span></span><input type="number" id="sdoSize" value="8" min="1"></div>
<div class="field"><span class="field-label">&nbsp;</span><button class="btn" id="btnSdoRead" disabled>SDO 读</button></div>
</div>
<div class="cmd-grid" style="margin-top:var(--sp-3)">
<div class="field"><span class="field-label">写 hex <span class="field-unit">字节流</span></span><input type="text" id="sdoHex" placeholder="AABBCC.."></div>
<div class="field"><span class="field-label">&nbsp;</span><button class="btn" id="btnSdoWrite" disabled>SDO 写</button></div>
</div>
<div class="eep" id="sdoOut">—</div>
</div>
<!-- CAN 转发页 -->
<div class="tab-page" id="tabCan">
<div class="cmd-grid">
<div class="field">
<span class="field-label">通道</span>
<select id="canCh">
<option value="0">CH0</option><option value="1">CH1</option><option value="2">CH2</option>
</select>
</div>
<div class="field"><span class="field-label">CAN ID <span class="field-unit">29bit</span></span><input type="text" id="canId" value="0x18DA00F1"></div>
<div class="field"><span class="field-label">DLC <span class="field-unit">≤24</span></span><input type="number" id="canDlc" value="8" min="0" max="24"></div>
</div>
<div class="cmd-grid" style="margin-top:var(--sp-3)">
<div class="field" style="grid-column:1/-1">
<span class="field-label">数据 <span class="field-unit">hex</span></span>
<input type="text" id="canData" value="0102030405060708" placeholder="0102030405060708">
</div>
</div>
<div style="display:flex;gap:var(--sp-3);align-items:flex-end;margin-top:var(--sp-3);flex-wrap:wrap">
<button class="btn btn-primary" id="btnCanSend" disabled>发送一帧</button>
<div class="field" style="width:110px">
<span class="field-label">周期 <span class="field-unit">ms</span></span>
<input type="number" id="canRepeatMs" value="200" min="50" max="10000" step="10">
</div>
<button class="btn" id="btnCanRepeat" disabled>开始周期</button>
</div>
<div class="br-row" style="margin-top:var(--sp-3)">
<div class="big-reading"><div class="br-val" id="canRxId">—</div><div class="br-lbl">最近接收 ID</div></div>
<div class="big-reading"><div class="br-val" id="canRxData">—</div><div class="br-lbl">接收数据</div></div>
</div>
<div class="stat-row">
<div class="stat-cell">接收<b id="canRxCnt">0</b></div>
<div class="stat-cell">发送<b id="canTxCnt">0</b></div>
<div class="stat-cell">来源<b id="canRxCh">—</b></div>
<div class="stat-cell">发送结果<b id="canTxRes">—</b></div>
<div class="stat-cell">队列积压<b id="canBacklog">—</b></div>
<div class="stat-cell">丢帧<b id="canDrop">0</b></div>
</div>
<div class="mode-desc" style="margin-top:var(--sp-2)">
CAN 帧承载在过程数据里(固件 ≥1.5.56 为 384B 出 / 640B 入):输出 PDO = 待发帧, 输入 PDO = 最近收到的帧 + 计数。
发送用<b>序号沿触发</b>, 同一帧不会被每周期重发; 从站回显 ack 即证明整条 输出→应用→输入 链路通。
本调试页只用兼容区(输出前 48B / 输入前 32B, 与 1.5.53 逐字节一致), 接收仍只显示前 8 字节, 超长帧标记截断。
要一周期合并多条 CANFD 帧、或看完整 32B/64B payload, 用<a href="/motor.html">电机控制台</a>(走批量帧区)。
未进 OP 时点发送会自动先进 OP。
</div>
</div>
</div>
</section>
</main>
<!-- ═══════════ STATUSBAR ═══════════ -->
<footer class="statusbar">
<span class="led" id="sbLed"></span>
<span id="sbState">未连接</span>
<span>·</span>
<span id="sbIdent">identity —</span>
<span style="margin-left:auto" id="sbLast"></span>
<button class="btn" id="btnQuit" title="结束后台 Python 服务进程(隐藏窗口模式下的退出入口)">退出服务</button>
</footer>
<script>
"use strict";
const $=id=>document.getElementById(id);
let connected=false, running=false, timer=null, cyc=0, wkcErr=0;
/* ---- 基础 ---- */
function log(msg, cls){
const d=new Date(), t=[d.getHours(),d.getMinutes(),d.getSeconds()]
.map(v=>String(v).padStart(2,"0")).join(":");
const el=document.createElement("div");
if(cls) el.className=cls;
el.innerHTML=`<span style="color:var(--fg-dim)">${t}</span> ${msg}`;
const box=$("log"); box.appendChild(el);
box.scrollTop=box.scrollHeight;
while(box.children.length>400) box.removeChild(box.firstChild);
$("sbLast").textContent=msg.replace(/<[^>]*>/g,"").slice(0,90);
}
function parseNum(v){v=String(v).trim();return /^0x/i.test(v)?parseInt(v,16):parseInt(v,10);}
async function api(path, body){
const r=await fetch(path,{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify(body||{})});
return await r.json();
}
function showLog(res){
(res.log||[]).forEach(l=>{
const err=/失败|FAIL|错误|不符|异常|无效|超/.test(l);
log(l, err?"log-err":(/OK|已进|已回|成功|全部一致/.test(l)?"log-ok":"log-info"));
});
}
function hex8(v){return "0x"+(v>>>0).toString(16).toUpperCase().padStart(8,"0");}
function setConnected(on, st){
connected=on;
["btnDisc","btnEeRead","btnFlashGen","btnFlashFile","btnVerifyFile",
"btnSdoRead","btnSdoWrite","btnCanSend","btnCanRepeat"].forEach(id=>$(id).disabled=!on);
$("btnOp").disabled=!on;
$("btnConn").disabled=on;
$("connLed").className="led "+(on?"led-on":"");
$("sbLed").className="led "+(on?"led-on":"led-err");
$("netBanner").classList.toggle("show",!on);
$("netBannerText").textContent=on?"":"未连接 — 输入适配器匹配串(默认 I226)后点击连接";
if(on&&st) updateSlave(st);
else if(!on){
$("slaveName").textContent="未连接"; $("alMode").textContent="—";
$("alMode").className="nc-mode mode-off"; $("alBadge").textContent="AL —";
["rVid","rPid","rRev","rCode"].forEach(id=>$(id).textContent="—");
$("connDesc").textContent="—"; $("sbState").textContent="未连接"; $("sbIdent").textContent="identity —";
}
}
function alInfo(al){
const name={0:"NONE",1:"INIT",2:"PREOP",3:"BOOT",4:"SAFEOP",8:"OP"}[al]||("0x"+al.toString(16));
const cls=al===8?"mode-op":(al===4?"mode-safeop":(al===2?"mode-preop":"mode-off"));
return {name, cls};
}
function updateSlave(st){
$("connDesc").textContent=st.desc||"—";
$("slaveName").textContent=st.name||"—";
const info=alInfo(st.state);
$("alMode").textContent=info.name; $("alMode").className="nc-mode "+info.cls;
$("alBadge").textContent="AL "+info.name;
$("rVid").textContent=hex8(st.man);
$("rPid").textContent=hex8(st.id);
$("rRev").textContent=(st.rev>>>0).toString(10);
$("rCode").textContent="0x"+(st.al_status>>>0).toString(16).toUpperCase().padStart(4,"0");
$("slaveCard").classList.toggle("fault", st.man===0xFFFFFFFF);
$("sbState").textContent="已连接";
$("sbIdent").textContent=`identity ${hex8(st.man)} / ${hex8(st.id)}`;
}
async function pollStatus(){
if(!connected||running) return;
try{const res=await api("/api/status"); if(res.ok) updateSlave(res.slave);}catch(e){}
}
setInterval(pollStatus,2000);
/* ---- 连接 ---- */
$("btnConn").onclick=async()=>{
const res=await api("/api/open",{substr:$("substr").value});
showLog(res);
if(res.ok){setConnected(true,res.slave); pollStatus();}
};
$("btnDisc").onclick=async()=>{
stopCycle(true);
const res=await api("/api/close");
showLog(res); setConnected(false);
};
let quitArmed=false, quitTimer=null;
$("btnQuit").onclick=async()=>{
if(!quitArmed){
quitArmed=true;
$("btnQuit").textContent="再点一次确认退出";
$("btnQuit").classList.add("btn-danger");
quitTimer=setTimeout(()=>{quitArmed=false;
$("btnQuit").textContent="退出服务";
$("btnQuit").classList.remove("btn-danger");},4000);
return;
}
clearTimeout(quitTimer); quitArmed=false;
$("btnQuit").textContent="退出服务";
$("btnQuit").classList.remove("btn-danger");
stopCycle(true);
const wv=(window.pywebview&&window.pywebview.api)?window.pywebview.api:null;
if(wv&&wv.quit_app){ log("正在退出…","log-info"); wv.quit_app(); return; }
try{await api("/api/shutdown");}catch(e){}
setConnected(false);
$("netBannerText").textContent="后台服务已退出 — 双击 ecat_web.vbs 重新启动";
log("后台服务已退出, 双击 ecat_web.vbs 重新启动","log-info");
};
/* ---- EEPROM ---- */
$("btnEeRead").onclick=async()=>{
const res=await api("/api/eeprom/read",
{start:parseNum($("eeStart").value),count:parseNum($("eeCount").value)});
showLog(res);
if(res.ok) $("eeOut").textContent=res.rows
.map(r=>`0x${r.addr.toString(16).padStart(4,"0")}: ${r.hex}`).join("\n");
};
function readBin(file,cb){
const r=new FileReader();
r.onload=()=>{const u=new Uint8Array(r.result);
cb(Array.from(u).map(b=>b.toString(16).padStart(2,"0")).join(""));};
r.readAsArrayBuffer(file);
}
$("eeFile").onchange=e=>{
const f=e.target.files[0]; if(!f) return;
readBin(f,async hex=>{
const isFlash=e.target.dataset.mode==="flash";
log(isFlash?"烧录上传的 bin…":"校验上传的 bin…");
const res=await api(isFlash?"/api/flash":"/api/eeprom/verify",{data:hex});
showLog(res);
if(res.ok&&isFlash) log(`烧录成功 ${res.written} 字, 记得板子断电重启`,"log-ok");
});
e.target.value="";
};
$("btnFlashGen").onclick=async()=>{
log("烧录 gen_eeprom.py 镜像…");
const res=await api("/api/flash",{});
showLog(res);
if(res.ok) log(`烧录成功 ${res.written} 字, 记得板子断电重启`,"log-ok");
};
$("btnFlashFile").onclick=()=>{$("eeFile").dataset.mode="flash";$("eeFile").click();};
$("btnVerifyFile").onclick=()=>{$("eeFile").dataset.mode="verify";$("eeFile").click();};
$("btnClearLog").onclick=()=>{$("log").innerHTML="";};
/* ---- SDO ---- */
$("btnSdoRead").onclick=async()=>{
const res=await api("/api/sdo",{op:"read",index:parseNum($("sdoIdx").value),
sub:parseNum($("sdoSub").value),size:parseNum($("sdoSize").value)});
showLog(res); $("sdoOut").textContent=res.data?res.data.toUpperCase():"—";
};
$("btnSdoWrite").onclick=async()=>{
const res=await api("/api/sdo",{op:"write",index:parseNum($("sdoIdx").value),
sub:parseNum($("sdoSub").value),hex:$("sdoHex").value.trim()});
showLog(res);
};
/* ---- tabs ---- */
const TAB_PAGE={pdo:"tabPdo", sdo:"tabSdo", can:"tabCan"};
document.querySelectorAll(".tab-btn").forEach(b=>{
b.onclick=()=>{
document.querySelectorAll(".tab-btn").forEach(x=>x.classList.remove("active"));
document.querySelectorAll(".tab-page").forEach(x=>x.classList.remove("active"));
b.classList.add("active");
$(TAB_PAGE[b.dataset.tab]).classList.add("active");
};
});
/* ---- PDO ---- */
function outHex(){
const parts=[];
for(let i=0;i<4;i++){
let v; try{v=BigInt($("o"+i).value.trim()||"0");}catch(e){v=0n;}
if(v<0n) v&=0xFFFFFFFFFFFFFFFFn;
const h=v.toString(16).padStart(16,"0");
parts.push(h.match(/../g).reverse().join(""));
}
return parts.join("");
}
function renderInput(hex){
if(!hex||hex.length<64){["in0","in1","in2","in3"].forEach(id=>$(id).textContent="—");
$("inHex").textContent=hex||"—";return;}
for(let i=0;i<4;i++){
const bytes=hex.substr(i*16,16).match(/../g).reverse().join("");
$("in"+i).textContent="0x"+BigInt("0x"+bytes).toString(16).toUpperCase();
}
$("inHex").textContent="raw: "+hex.toUpperCase();
}
$("btnOp").onclick=async()=>{
if(!running){
const dc=parseNum($("dcCycle").value);
log(dc?`进入 OP(DC SYNC0 ${dc}us)…`:"进入 OP(free-run)…");
const res=await api("/api/op/start",{dc_cycle_us:dc});
showLog(res); if(!res.ok) return;
running=true; cyc=0; wkcErr=0;
$("btnOp").textContent="停止并回 INIT"; $("btnOp").className="btn btn-danger";
$("dcCycle").disabled=true;
$("kOut").textContent=$("kIn").textContent="…";
timer=setInterval(doCycle,parseNum($("rate").value));
doCycle();
syncTimer=setInterval(pollSync,2000); pollSync();
}else stopCycle();
};
function stopCycle(silent){
if(timer){clearInterval(timer);timer=null;}
if(syncTimer){clearInterval(syncTimer);syncTimer=null;}
if(running){
running=false;
api("/api/op/stop").then(res=>{if(!silent)showLog(res);});
$("btnOp").textContent="进入 OP 并循环"; $("btnOp").className="btn btn-primary";
$("dcCycle").disabled=false;
$("kSync0").textContent=$("kSyncRate").textContent="—";
}
}
/* DC 通路观测: 运行期每 2s 经 SDO 读从站 0x2000 SYNC Diag(固件≥1.5.31)。
DC 模式下 SYNC0/s 应≈1e6/周期us(1000us→1000/s); free-run 恒 0。 */
let syncTimer=null;
async function pollSync(){
if(!running) return;
try{
const res=await api("/api/sync_diag");
if(!res.ok){
$("kSync0").textContent="—"; $("kSyncRate").textContent="—";
if(!pollSync.warned){pollSync.warned=true;log(res.log.join(" | "),"log-err");}
return;
}
pollSync.warned=false;
$("kSync0").textContent=res.sync0.toLocaleString();
$("kSyncRate").textContent=res.sync0_rate==null?"…":res.sync0_rate.toLocaleString();
}catch(e){/* 网络瞬断, 下轮重试 */}
}
async function doCycle(){
if(!running) return;
try{
const res=await api("/api/op/cycle",{outputs:outHex()});
if(!res.ok){showLog(res);stopCycle(true);log("循环已停止","log-err");return;}
cyc++;
if(res.wkc<res.expected_wkc) wkcErr++;
$("kCyc").textContent=cyc;
$("kErr").textContent=wkcErr;
$("kErr").className=wkcErr>0?"err":"";
$("kOut").textContent=res.out_len+"B"; $("kIn").textContent=res.in_len+"B";
renderInput(res.input);
}catch(e){/* 网络瞬断, 下个周期重试 */}
}
window.addEventListener("beforeunload",()=>{
if(running) navigator.sendBeacon("/api/op/stop");
});
/* ---- CAN 转发 ---- */
let canRepeatTimer=null;
function renderCanState(r){
if(!r || r.valid===false) return;
$("canRxCnt").textContent=r.rx_count;
$("canTxCnt").textContent=r.tx_count;
$("canDrop").textContent=r.drop;
$("canDrop").className=r.drop>0?"err":"";
$("canBacklog").textContent=r.backlog;
$("canTxRes").textContent=r.tx_result_text||"—";
$("canTxRes").className=(r.tx_result&&r.tx_result!==0)?"err":"";
if(r.rx_valid){
$("canRxId").textContent="0x"+r.id.toString(16).toUpperCase();
$("canRxData").textContent=(r.data||"—")+(r.truncated?" ⋯":"");
$("canRxCh").textContent="CH"+r.ch+(r.truncated?" (截断)":"");
}else{
$("canRxId").textContent="—"; $("canRxData").textContent="—";
$("canRxCh").textContent="尚无接收";
}
}
async function doCanSend(quiet){
const body={ch:parseNum($("canCh").value), id:$("canId").value.trim(),
dlc:parseNum($("canDlc").value), data:$("canData").value.replace(/\s+/g,""),
dc_cycle_us:parseNum($("dcCycle").value)};
const res=await api("/api/can/send",body);
if(!quiet||!res.ok) showLog(res);
if(res.ok) renderCanState(res);
return res.ok;
}
$("btnCanSend").onclick=async()=>{
$("btnCanSend").disabled=true;
try{ await doCanSend(false); }
catch(e){ log("发送异常: "+e,"log-err"); }
finally{ $("btnCanSend").disabled=!connected; }
};
$("btnCanRepeat").onclick=()=>{
if(canRepeatTimer){
clearInterval(canRepeatTimer); canRepeatTimer=null;
$("btnCanRepeat").textContent="开始周期";
$("btnCanRepeat").classList.remove("btn-primary");
log("周期发送已停止");
return;
}
const period=Math.max(50,parseNum($("canRepeatMs").value)||200);
canRepeatTimer=setInterval(async()=>{
try{
// 一帧没发成功就停下来,避免掉线后无声空转刷屏
if(!await doCanSend(true)){
clearInterval(canRepeatTimer); canRepeatTimer=null;
$("btnCanRepeat").textContent="开始周期";
$("btnCanRepeat").classList.remove("btn-primary");
log("周期发送因错误停止","log-err");
}
}catch(e){/* 网络瞬断, 下个周期重试 */}
},period);
$("btnCanRepeat").textContent="停止周期";
$("btnCanRepeat").classList.add("btn-primary");
log(`周期发送已启动: 每 ${period}ms 一帧`);
};
// CAN 页可见且未在周期发送时, 1s 轮询一次桥状态(读接收帧/计数)
setInterval(async()=>{
if(!connected || canRepeatTimer) return;
if(!$("tabCan").classList.contains("active")) return;
try{
const r=await api("/api/can/state",{});
if(r.ok) renderCanState(r);
}catch(e){/* 网络瞬断, 下轮重试 */}
},1000);
window.addEventListener("beforeunload",()=>{
if(canRepeatTimer) clearInterval(canRepeatTimer);
});
/* ---- 初始状态 ---- */
setConnected(false);
log("页面就绪: 连接后可扫描 / 烧录 EEPROM / 收发过程数据。");
</script>
</body>
</html>
+411
View File
@@ -0,0 +1,411 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>电机控制台 (EtherCAT) — DCU LAN9252</title>
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<!-- 样式直接复用 ecat.css(它本身就是 ESP32 motor.css 的副本,md5 一致),
不再拷第三份,避免以后三处漂移。改 CSS 后记得 bump ?v= -->
<link rel="stylesheet" href="ecat.css?v=20260903b">
<style>
/* 本页专有:EtherCAT 链路状态条 + 槽扫描指示 */
.link-chips{display:flex;gap:var(--sp-2);flex-wrap:wrap;align-items:center}
.lchip{font-family:var(--font-mono);font-size:.68rem;padding:2px 7px;border-radius:var(--radius-sm);
border:1px solid var(--border);color:var(--fg-muted);background:var(--bg-raised)}
.lchip b{color:var(--fg);font-weight:600}
.lchip.ok{border-color:var(--ok);color:var(--ok)}
.lchip.err{border-color:var(--danger);color:var(--danger)}
.scan-bar{display:flex;gap:3px;margin-top:var(--sp-2)}
.scan-cell{flex:1;height:4px;border-radius:2px;background:var(--border);transition:background .12s}
.scan-cell.hit{background:var(--primary)}
</style>
</head>
<body>
<!-- ═══════════ TOOLBAR ═══════════ -->
<header class="toolbar">
<div class="toolbar-brand">
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" aria-hidden="true">
<rect x="1" y="1" width="24" height="24" rx="6" stroke="var(--primary)" stroke-width="1.5" opacity=".6"/>
<circle cx="13" cy="13" r="5.5" stroke="var(--primary)" stroke-width="1.8"/>
<path d="M13 4v3.5M13 18.5V22M4 13h3.5M18.5 13H22" stroke="var(--primary)" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="13" cy="13" r="1.8" fill="var(--primary)"/>
</svg>
<span>电机控制台</span>
<span class="sub">EtherCAT PDO</span>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group" title="EtherCAT 网卡与从站状态;连接配置在 EtherCAT 主页">
<span class="led" id="connLed"></span>
<span class="conn-ip-label" id="ipLabel">未连接</span>
<span style="font-size:.72rem;color:var(--fg-dim);font-family:var(--font-mono)" id="devStatus">探测中…</span>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group">
<label for="chSelect" style="font-size:.72rem;color:var(--fg-muted);font-family:var(--font-mono);letter-spacing:.06em">通道</label>
<select class="ch-select" id="chSelect">
<option value="0">CH0</option>
<option value="1">CH1</option>
<option value="2">CH2</option>
</select>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group" title="后台 PDO 循环周期(ms,1~100)。这是真实的总线节拍:状态窗口每周期滚动 7 槽,4 个周期扫完 27 槽">
<label for="periodMs" class="refresh-hint">PDO 周期</label>
<input type="number" id="periodMs" class="refresh-input" value="4" min="1" max="100" step="1" aria-label="PDO 循环周期(毫秒)" />
<span class="refresh-hint">ms</span>
</div>
<div class="conn-group" title="页面读取快照的间隔(ms)。读快照不碰总线,可以比 PDO 周期慢">
<label for="refreshRate" class="refresh-hint">界面刷新</label>
<input type="number" id="refreshRate" class="refresh-input" value="100" min="30" max="2000" step="10" aria-label="界面刷新间隔(毫秒)" />
<span class="refresh-hint">ms</span>
</div>
<div class="toolbar-right">
<a class="btn btn-sm" href="/" title="返回 EtherCAT 主页配置连接">返回主页</a>
<span class="node-badge" id="nodeBadge">NODE -</span>
</div>
</header>
<div class="net-banner" id="netBanner">
<span class="led led-err led-pulse" style="width:7px;height:7px"></span>
<span id="netBannerText">未连接 — 请先在 EtherCAT 主页点「连接」,再回到本页启动 PDO 循环</span>
</div>
<!-- ═══════════ MAIN 3-ZONE GRID ═══════════ -->
<main class="main-grid">
<!-- LEFT: NODE OVERVIEW -->
<aside class="panel nodes-panel">
<div class="panel-head">
<span class="panel-title">节点总览</span>
<span style="margin-left:auto;font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono)" id="nodeSummary">0/9 在线 · PDO</span>
</div>
<div class="panel-body">
<!-- 独立 BOOT 状态:总线检测到 0x103 心跳时显示,不归属任何 Nx -->
<div id="bootBadge" class="boot-badge" aria-hidden="true">
<span class="boot-dot" aria-hidden="true"></span>
<span class="boot-txt">电机在 BOOT</span>
<span class="boot-sub" id="bootBadgeSub">0x103</span>
</div>
<div class="node-list" id="nodeList" role="listbox" aria-label="电机节点列表 N1-N9(离线可选)">
<!-- N1–N9 由 JS 固定渲染,离线也可选中 -->
</div>
</div>
</aside>
<!-- CENTER: CONTROL -->
<section class="panel center-panel">
<div class="panel-head">
<span class="panel-title">CMD 控制</span>
<span style="font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono);margin-left:auto" id="cmdIdLabel">ID 0x000-0200 · DLC 32</span>
</div>
<div class="cmd-section">
<div class="cmd-grid">
<div class="field">
<span class="field-label">Control_Mode <span class="field-unit">控制模式</span></span>
<select id="modeSelect" aria-label="控制模式">
<option value="0">0 — Disable 关闭</option>
<option value="1">1 — Angle 位置控制</option>
<option value="2">2 — Velocity 速度控制</option>
<option value="3" selected>3 — Velocity_Angle 速度+位置</option>
<option value="4">4 — MIT Mode</option>
<option value="5">5 — Current 电流/力矩</option>
<option value="6">6 — 开环速度</option>
<option value="7">7 — 开环速度 (2)</option>
</select>
<span class="mode-desc" id="modeDesc">速度+位置环联合控制,目标速度与目标角度同时生效</span>
</div>
<div class="field">
<span class="field-label">Limit_Voltage <span class="field-unit">V · 0~65.535</span></span>
<input type="number" id="inVoltage" value="24.000" step="0.5" min="0" max="65.535" aria-label="电压限制" />
</div>
<div class="field" id="fldCurrent">
<span class="field-label">Target_Current <span class="field-unit">mA</span></span>
<input type="number" id="inCurrent" value="0" step="100" aria-label="目标电流" />
</div>
<div class="field" id="fldVelocity">
<span class="field-label">Target_Velocity <span class="field-unit">deg/s</span></span>
<input type="number" id="inVelocity" value="360" step="10" aria-label="目标速度" />
</div>
<div class="field" id="fldAngle">
<span class="field-label">Target_Angle <span class="field-unit">deg</span></span>
<input type="number" id="inAngle" value="180.0" step="1" aria-label="目标角度" />
</div>
</div>
<div class="field" style="margin-top:var(--sp-4)">
<span class="field-label">PID 增益 <span class="field-unit">P × 3 环</span></span>
<div class="pid-group">
<div class="field"><span class="field-label" style="font-size:.62rem">电流环 P</span><input type="number" id="pidCurP" value="100" step="1" /></div>
<div class="field"><span class="field-label" style="font-size:.62rem">速度环 P</span><input type="number" id="pidVelP" value="100" step="1" /></div>
<div class="field"><span class="field-label" style="font-size:.62rem">角度环 P</span><input type="number" id="pidAngP" value="100" step="1" /></div>
</div>
</div>
<div class="quick-run" id="quickRun" title="把值写入当前模式的主目标字段并立即发送 CMD">
<span class="qr-label">快捷运行 <span class="qr-target" id="qrTargetHint">→ 目标角度 deg</span></span>
<div class="qr-btns">
<button class="qr-btn" data-val="-9999">-9999</button>
<button class="qr-btn" data-val="-200">-200</button>
<button class="qr-btn" data-val="-100">-100</button>
<button class="qr-btn" data-val="0">0</button>
<button class="qr-btn" data-val="50">50</button>
<button class="qr-btn" data-val="100">100</button>
<button class="qr-btn" data-val="200">200</button>
<button class="qr-btn" data-val="1000">1000</button>
<button class="qr-btn" data-val="9999">9999</button>
</div>
</div>
<div class="cmd-actions">
<button class="btn btn-primary" id="sendCmdBtn">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M1.5 7h9M8 3.5L11.5 7 8 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
发送 CMD
</button>
<button class="btn" id="sendRepeatBtn">周期发送</button>
<label class="repeat-period" title="周期发送间隔(最小 20ms,EtherCAT 下不受 HTTP socket 限制)">
<input type="number" id="repeatPeriod" value="50" min="20" max="10000" step="10" />
<span>ms</span>
</label>
<span class="send-hint" id="sendHint">POST /api/motor/send · CH0</span>
</div>
</div>
<!-- TABS -->
<nav class="tabs-bar" aria-label="辅助功能">
<button class="tab-btn active" data-tab="config">配置操作</button>
<button class="tab-btn" data-tab="version">版本信息</button>
<button class="tab-btn" data-tab="nodeid">节点 ID</button>
<button class="tab-btn" data-tab="link">链路诊断</button>
<button class="tab-btn" data-tab="log">日志</button>
</nav>
<div class="tab-content">
<!-- CONFIG TAB -->
<div class="tab-page active" id="tab-config">
<div class="config-grid">
<button class="config-btn danger-action" data-action="motReset">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><path d="M4 11a7 7 0 1 1 2 4.9M4 16v-5h5" stroke="var(--danger)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span class="cb-name">电机复位</span><span class="cb-sig">motReset · bit0</span>
</button>
<button class="config-btn" data-action="saveConfig">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><path d="M5 3h9l4 4v12H5V3z" stroke="var(--primary)" stroke-width="1.6" stroke-linejoin="round"/><path d="M8 3v5h6V3M8 18v-5h7v5" stroke="var(--primary)" stroke-width="1.4"/></svg>
<span class="cb-name">保存配置</span><span class="cb-sig">saveConfig · bit2</span>
</button>
<button class="config-btn" data-action="angleReset">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><circle cx="11" cy="11" r="7.5" stroke="var(--primary)" stroke-width="1.6"/><path d="M11 6v5l3.5 2" stroke="var(--primary)" stroke-width="1.5" stroke-linecap="round"/></svg>
<span class="cb-name">角度置零</span><span class="cb-sig">angle_reset · bit3</span>
</button>
<button class="config-btn" data-action="zeroCalib">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><circle cx="11" cy="11" r="7" stroke="var(--primary)" stroke-width="1.6"/><path d="M11 2v4M11 16v4M2 11h4M16 11h4" stroke="var(--primary)" stroke-width="1.5" stroke-linecap="round"/><circle cx="11" cy="11" r="1.5" fill="var(--primary)"/></svg>
<span class="cb-name">零点校准</span><span class="cb-sig">zero_calibration · bit5</span>
</button>
<button class="config-btn" data-action="sleep">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><path d="M17 13.5A7 7 0 0 1 8.5 5a7 7 0 1 0 8.5 8.5z" stroke="var(--warn)" stroke-width="1.6" stroke-linejoin="round"/></svg>
<span class="cb-name">休眠</span><span class="cb-sig">SLEEP · bit56</span>
</button>
</div>
<p style="margin-top:var(--sp-4);font-size:.72rem;color:var(--fg-dim);line-height:1.6">
CMD_CONFIG (0x000<span class="cfgNodeNibble">-</span>0301) · DLC 24 · 每个操作发送单帧脉冲信号,操作前需确认
</p>
</div>
<!-- VERSION TAB -->
<div class="tab-page" id="tab-version">
<button class="btn btn-sm" id="queryVerBtn">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5" stroke="currentColor" stroke-width="1.3"/><path d="M6.5 4.2v.1M6.5 6v3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
查询版本 (GET_VERSION)
</button>
<div class="version-grid" style="margin-top:var(--sp-4)" id="versionGrid">
<div class="ver-card"><div class="ver-type">软件版本</div><div class="ver-date ver-empty" id="verSoft">--</div></div>
<div class="ver-card"><div class="ver-type">硬件版本</div><div class="ver-date ver-empty" id="verHard">--</div></div>
<div class="ver-card"><div class="ver-type">Bootloader</div><div class="ver-date ver-empty" id="verBoot">--</div></div>
</div>
<p style="margin-top:var(--sp-3);font-size:.72rem;color:var(--fg-dim)">
请求/回报共用 0x000<span class="cfgNodeNibble">-</span>00FF(同 ID)· DLC 24 · major.minor.patch.build ×3 (soft/hard/boot)。
回报帧经输入 PDO 段 3「长帧快照」回传 —— 段 1 只有 8 字节,装不下 DLC 24。
</p>
</div>
<!-- NODE ID TAB -->
<div class="tab-page" id="tab-nodeid">
<div class="nodeid-row">
<div class="field" style="min-width:140px">
<span class="field-label">目标 Node ID</span>
<input type="number" id="nodeIdInput" min="1" max="9" value="3" />
</div>
<button class="btn btn-primary btn-sm" id="setNodeIdBtn">设置 ID (0xN0400)</button>
<button class="btn btn-sm" id="getNodeIdBtn">读取 ID (0xN0401)</button>
</div>
<div class="nodeid-result" id="nodeIdResult">连接设备后读取节点 ID</div>
<p style="margin-top:var(--sp-3);font-size:.72rem;color:var(--fg-dim);line-height:1.6">
SET_NODE_ID (0x000N0400 DLC2) / GET_NODE_ID (0x000N0401 DLC2)。写入后 CAN ID 前缀变为 0x000Nxxxx。
</p>
</div>
<!-- LINK DIAG TAB -->
<div class="tab-page" id="tab-link">
<div class="link-chips">
<span class="lchip">从站 <b id="dgSlave">—</b></span>
<span class="lchip">AL <b id="dgAl">—</b></span>
<span class="lchip">WKC <b id="dgWkc">—</b></span>
<span class="lchip">PDO <b id="dgPdo">—</b></span>
<span class="lchip">周期数 <b id="dgCycles">0</b></span>
<span class="lchip">循环 <b id="dgLoop">停止</b></span>
</div>
<div class="link-chips" style="margin-top:var(--sp-3)">
<span class="lchip">ack <b id="dgAck">—</b></span>
<span class="lchip">TX <b id="dgTx">0</b></span>
<span class="lchip">RX <b id="dgRx">0</b></span>
<span class="lchip">丢帧 <b id="dgDrop">0</b></span>
<span class="lchip">积压 <b id="dgBacklog">0</b></span>
<span class="lchip">发送结果 <b id="dgTxRes">—</b></span>
</div>
<div class="link-chips" style="margin-top:var(--sp-3)">
<span class="lchip" title="上一个 PDO 周期实际发出的帧数(批量下发时 >1)">本周期发 <b id="dgSentN">0</b></span>
<span class="lchip" title="待下发队列深度">待发 <b id="dgQueue">0</b></span>
<span class="lchip" title="上一周期从批量接收区回收的帧数">本周期收 <b id="dgRxN">0</b></span>
<span class="lchip" title="从站环形队列仍待回收的帧数,持续 >0 说明该调小 PDO 周期">待收 <b id="dgRxPend">0</b></span>
<span class="lchip" title="从站接收队列满导致的丢帧(增量)">收丢 <b id="dgRxLost">0</b></span>
</div>
<div class="scan-bar" id="scanBar" title="状态窗口滚动扫描进度:每格 = 一个 PDO 周期覆盖的 7 个槽"></div>
<div style="display:flex;gap:var(--sp-3);margin-top:var(--sp-4);flex-wrap:wrap">
<button class="btn btn-primary btn-sm" id="loopStartBtn">启动 PDO 循环</button>
<button class="btn btn-sm" id="loopStopBtn">停止循环</button>
</div>
<p style="margin-top:var(--sp-3);font-size:.72rem;color:var(--fg-dim);line-height:1.7">
过程数据 <b>384B 出 / 640B 入</b>(固件 ≥1.5.56,需重刷 EEPROM 并<b>冷启动</b>)。
这是 LAN9252 的硬上限:邮箱占 0x1000-0x13FF,过程数据区只剩 3072B,
而 SM2/SM3 都是 3-buffer 实占 3×长度 ⇒ 出+入 ≤ 1024B,384+640 恰好用满。
输出 PDO 段 2 是 CANFD 批量发送区(<b id="dgTxCap">332</b>B,
一周期可发 8 帧 DLC32 或 4 帧 DLC64),多轴指令因此能在<b>同一个 PDO 周期</b>下发,
而不是排队一周期一帧;输入 PDO 段 2 每周期回传 7 个电机状态槽,
<b id="dgScanInfo">4 周期</b> 扫完 27 槽(node×channel 双键),
段 4 是批量接收区(<b id="dgRxCap">276</b>B),一周期可回收多帧而非只带最近一帧。
后台循环持续喂 1000ms 过程数据看门狗 —— 停掉循环超过 1 秒,从站会掉出 OP 报 AL 0x001B。
</p>
</div>
<!-- LOG TAB -->
<div class="tab-page" id="tab-log">
<div class="ota-log-toolbar">
<button id="logCopyBtn" title="复制全部日志">复制</button>
<button id="logClearBtn" title="清空日志">清空</button>
</div>
<div class="ota-log" id="opLog" aria-live="polite">
<div class="log-info">[就绪] EtherCAT 电机控制台 · 状态经输入 PDO 段 2 回传,命令经输出 PDO 批量区同周期下发</div>
</div>
</div>
</div>
</section>
<!-- RIGHT: STATUS FEEDBACK -->
<section class="panel status-panel">
<div class="panel-head">
<span class="panel-title">STATUS 实时反馈</span>
<span style="font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono);margin-left:auto" id="statusIdLabel">0x000-0100 · PDO</span>
</div>
<div class="panel-body">
<div class="offline-overlay show" id="offlineOverlay">
<svg class="off-icon" width="40" height="40" viewBox="0 0 40 40" fill="none" aria-hidden="true"><circle cx="20" cy="20" r="16" stroke="var(--fg-dim)" stroke-width="1.5" stroke-dasharray="4 4"/><path d="M14 26l12-12M26 26L14 14" stroke="var(--fg-dim)" stroke-width="1.5" stroke-linecap="round"/></svg>
<span style="font-size:.85rem" id="offlineTitle">节点离线 — 未收到 STATUS 帧</span>
<span style="font-size:.72rem" id="offlineSub">检查电机供电与 CAN 总线连接</span>
</div>
<div class="status-live hide" id="statusLive">
<div class="big-readings">
<div class="big-reading" data-signal="angle" title="点击展开/收起角度波形">
<div class="br-val" id="rtAngle">--</div>
<div class="br-unit">deg</div>
<div class="br-lbl">RT_Angle <span class="br-toggle">▾ 波形</span></div>
<div class="br-target">目标 <span id="tgtAngle">--</span> deg</div>
<div class="br-scope"><canvas id="miniAngle" aria-label="角度波形" role="img"></canvas></div>
</div>
<div class="big-reading" data-signal="velocity" title="点击展开/收起速度波形">
<div class="br-val" id="rtVelocity">--</div>
<div class="br-unit">deg/s</div>
<div class="br-lbl">RT_Velocity <span class="br-toggle">▾ 波形</span></div>
<div class="br-target">目标 <span id="tgtVelocity">--</span> deg/s</div>
<div class="br-scope"><canvas id="miniVelocity" aria-label="速度波形" role="img"></canvas></div>
</div>
<div class="big-reading" data-signal="current" title="点击展开/收起电流波形">
<div class="br-val" id="rtCurrent">--</div>
<div class="br-unit">mA</div>
<div class="br-lbl">RT_Current <span class="br-toggle">▾ 波形</span></div>
<div class="br-target">目标 <span id="tgtCurrent">--</span> mA</div>
<div class="br-scope"><canvas id="miniCurrent" aria-label="电流波形" role="img"></canvas></div>
</div>
</div>
<div class="status-row">
<div class="temp-block" data-signal="mcuTemp" title="点击展开/收起 MCU 温度曲线">
<div class="temp-head"><span class="temp-name">MCU_Temp<span class="temp-toggle">▾ 曲线</span></span><span class="temp-val" id="mcuTemp">-- °C</span></div>
<div class="temp-bar"><div class="temp-fill" id="mcuTempBar" style="width:0%"></div></div>
<div class="br-scope"><canvas id="miniMcuTemp" aria-label="MCU温度曲线" role="img"></canvas></div>
</div>
<div class="temp-block" data-signal="motTemp" title="点击展开/收起电机温度曲线">
<div class="temp-head"><span class="temp-name">Motor_Temp<span class="temp-toggle">▾ 曲线</span></span><span class="temp-val" id="motTemp">-- °C</span></div>
<div class="temp-bar"><div class="temp-fill" id="motTempBar" style="width:0%"></div></div>
<div class="br-scope"><canvas id="miniMotTemp" aria-label="电机温度曲线" role="img"></canvas></div>
</div>
</div>
<div class="phase-currents">
<div class="pc-head">
<span class="pc-title">三相电流 U / V / W</span>
<div class="pc-live-vals">
<span class="pc-chip" style="--ch-color:var(--primary)">U <b id="valU">+0</b> mA</span>
<span class="pc-chip" style="--ch-color:var(--ok)">V <b id="valV">+0</b> mA</span>
<span class="pc-chip" style="--ch-color:var(--warn)">W <b id="valW">+0</b> mA</span>
</div>
</div>
<div class="scope-wrap">
<canvas id="scopeCanvas" aria-label="三相电流实时波形" role="img"></canvas>
<div class="scope-grid-labels"><span>+5A</span><span>0</span><span>-5A</span></div>
</div>
</div>
<div class="misc-grid">
<div class="misc-item"><span class="mi-lbl">Power_Voltage</span><span class="mi-val" id="pwrVolt">-- V</span></div>
<div class="misc-item"><span class="mi-lbl">test_float</span><span class="mi-val" id="testFloat">--</span></div>
<div class="misc-item"><span class="mi-lbl">test_U16</span><span class="mi-val" id="testU16">--</span></div>
<div class="cpu-ring-wrap" style="grid-column:2;grid-row:2">
<div class="cpu-ring">
<svg width="52" height="52" viewBox="0 0 52 52" role="img" aria-label="CPU占用率">
<circle class="ring-bg" cx="26" cy="26" r="21"/>
<circle class="ring-fg" id="cpuRingFg" cx="26" cy="26" r="21" stroke-dasharray="131.9" stroke-dashoffset="131.9"/>
</svg>
<span class="cpu-pct" id="cpuPct">--%</span>
</div>
<div class="cpu-info"><span class="mi-lbl">CPU_Usage</span><span class="mi-sub" id="cpuSub">-- </span></div>
</div>
</div>
<div class="flag-lights">
<div class="flag-item" id="flagItemMot"><span class="flag-led" id="flagMot"></span>motStatus 使能<span class="flag-state" id="flagMotState">—</span></div>
<div class="flag-item" id="flagItemMag"><span class="flag-led" id="flagMag"></span>magStatus 磁编码<span class="flag-state" id="flagMagState">—</span></div>
<div class="flag-item" id="flagItemThermal"><span class="flag-led" id="flagThermal"></span>thermal 过温<span class="flag-state" id="flagThermalState">—</span></div>
<div class="flag-item" id="flagItemEeprom"><span class="flag-led" id="flagEeprom"></span>EEPROM 存储<span class="flag-state" id="flagEepromState">—</span></div>
</div>
</div>
</div>
</section>
</main>
<!-- ═══════════ STATUS BAR ═══════════ -->
<footer class="statusbar">
<div class="sb-item"><span class="led" id="sbLed" style="width:7px;height:7px"></span> <span id="sbCh">CH0</span> <span class="sb-val" id="sbRx">RX --</span></div>
<div class="sb-sep"></div>
<div class="sb-item">TX <span class="sb-val" id="sbTx">--</span></div>
<div class="sb-sep"></div>
<div class="sb-item">WKC <span class="sb-val" id="sbWkc">--</span></div>
<div class="sb-sep"></div>
<div class="sb-item">状态刷新 <span class="sb-val" id="sbHz">--</span></div>
<span class="sb-last-send" id="sbLastSend">尚未发送</span>
</footer>
<!-- TOAST & MODAL -->
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
<div class="modal-mask" id="modalMask" role="dialog" aria-modal="true">
<div class="modal-box">
<div class="modal-title" id="modalTitle">确认操作</div>
<div class="modal-desc" id="modalDesc"></div>
<div class="modal-actions">
<button class="btn" id="modalCancel">取消</button>
<button class="btn btn-primary" id="modalConfirm">确认执行</button>
</div>
</div>
</div>
<script src="motor.js?v=20260903a"></script>
</body>
</html>
+1098
View File
File diff suppressed because it is too large Load Diff