commit d2c86c425aee0e71e1ac54f53b646601d50bbc3a
Author: cbb <2625274797@qq.com>
Date: Thu Sep 3 21:30:32 2026 +0800
init: DCU ESP32 调试控制台前端与本地页面服务
纯前端上位机界面 + esp32_server.py 静态服务/桌面窗口外壳, 不含固件、
不直接访问硬件; 业务 API 由 ESP32_COMM 固件 80 端口跨域提供。
- tabs.html 单窗口多标签外壳, iframe 承载各功能页
- index.html OTA 中继(ESP32 WiFi / GD32 relay / BLE), js/ota + js/ble
- can.html / can_monitor.html CAN 通信与接收监控, js/can
- motor.html 电机 CAN FD 控制台, js/motor
- continuity_test.html LAN9252 通断测试, spi_test.html SPI 测速(临时, 自包含)
- 三入口: esp32_app.vbs 桌面窗口 / esp32_web.vbs 浏览器 / serve.bat 控制台调试
- 补 README(页面与目录约定、?v= 缓存版本等改动须知)、.gitignore、
.gitattributes(* -text 保持 Windows 换行原样)
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..3b3ecb8
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# 该工具集在 Windows 下开发运行,保持文件换行符原样,避免 checkout/commit 时被改写
+* -text
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96b7e06
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,23 @@
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+.pytest_cache/
+
+# 虚拟环境(本地生成,勿入库)
+venv/
+.venv/
+env/
+
+# 固件 / 日志产物(本目录只做前端与本地服务,固件由 ESP32_COMM 构建)
+*.bin
+*.elf
+*.map
+*.log
+
+# 编辑器 / 系统
+.vscode/
+.idea/
+*.swp
+Thumbs.db
+desktop.ini
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f4d40bf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,63 @@
+# DCU ESP32 调试控制台(Web 前端 + 本地页面服务)
+
+`G:\Robot_v2\DCU\tools\ESP32` —— ESP32-C3(`DCU/ESP32_COMM` 固件)的上位机调试界面。
+
+本目录**不含固件代码,也不直接访问硬件**:HTML/CSS/JS 是纯前端,页面本体由
+`esp32_server.py` 以静态文件方式提供(同源相对路径),而所有业务 API 由 ESP32
+固件的 80 端口跨域提供。设备 IP 存在浏览器 `localStorage` 的 `dcu_esp_ip`
+(默认 `192.168.31.127`,见 `js/common/base-url.js`)。
+
+## 启动方式
+
+| 入口 | 形态 | 说明 |
+|------|------|------|
+| `esp32_app.vbs` | 桌面窗口 | pywebview + WebView2,无控制台窗口;服务跑在独立子进程 |
+| `esp32_web.vbs` | 默认浏览器标签页 | 无控制台窗口 |
+| `serve.bat` | 控制台模式 | 端口 8080,窗口内可见服务器日志,用于调试 |
+
+窗口模式为何拆两个进程:`pythonw` + `pythonnet`(pywebview 依赖)环境下 Python
+后台线程会被静默杀死,把 HTTP 服务放进独立子进程可规避,与 `tools/ecat/ecat_server.py`
+同款结构。pywebview 窗口没有地址栏与刷新按钮,因此服务器会向 HTML 响应注入
+`_reload.html`(右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 拦截)。
+
+`build_mem.bat` 是从 Git Bash 调用 ESP-IDF 构建 `DCU/ESP32_COMM` 的辅助脚本
+(会先清掉 MSYS 环境变量绕过 IDF 的 MSys 检测)。
+
+## 页面
+
+`tabs.html` 是单窗口多标签外壳,用 iframe 承载下列功能页,激活标签记忆在
+`localStorage` 的 `dcu_tab`:
+
+| 页面 | 用途 | 主要脚本 |
+|------|------|----------|
+| `index.html` | OTA 中继控制台(ESP32 WiFi OTA / GD32 relay / BLE OTA) | `js/app.js`、`js/ota/*`、`js/ble_ota.js`、`js/ble/*` |
+| `can.html` | CAN 通信(DBC 解析、消息编辑与批量发送) | `js/can.js`、`js/can/*` |
+| `can_monitor.html` | CAN 接收监控 | `js/can_monitor.js` |
+| `motor.html` | 电机控制台(CAN FD) | `js/motor/main.js`、`js/motor/tick.worker.js` |
+| `continuity_test.html` | LAN9252 通断测试(自包含,含 WebGL 液态玻璃效果) | 内联 |
+| `spi_test.html` | 【临时功能 SPI-SPEEDTEST】SPI 链路测速,完全自包含,删文件即可移除 | 内联 |
+
+## 目录约定
+
+- `css/` —— `base`(变量/基础)、`layout`、`components`、`can`、`motor`、`responsive`。
+- `js/common/` —— 跨页公用:`base-url`(设备 IP)、`connection`、`polling`、`log`、`toast`、`utils`、`heap-low-history`。
+- `js/ota/`、`js/ble/`、`js/can/`、`js/motor/` —— 按功能域拆分的 ES module。
+- 子系统配色:ESP32 = 青绿 · GD32 = 琥珀 · BLE = 蓝 · CAN = 绿 · 内存/调试 = 紫。
+
+## 改动须知
+
+- **改 CSS/JS 后必须 bump 引用处的 `?v=` 缓存版本**,否则浏览器会加载旧代码。
+- OTA 流程以设备端为真相来源:WiFi OTA 由设备回读分区算 SHA-256 后自动重启;
+ BLE OTA 由前端发 `0xF8 + 32B SHA-256`、设备端比较并通过 FINISH 事件报结果
+ (前端不做 SHA 预比较);GD32 relay 状态以 `/system/status` 为准。
+- 监控 UI 需区分 `valid`(通信是否成功)与 `count > 0`(是否有数据),
+ 以便分辨「正常空闲」和「真实通信错误」。
+- 响应式 grid 表隐藏列用 `0fr` 宽度实现,不要改变 grid track 数量。
+- 新增 CAN 工作模式时,需在页面上显式补按钮与说明文字。
+- DBC 文件常为 GBK/GB2312:先按 UTF-8(`{fatal: true}`)尝试,失败回退 GB18030,并去 BOM。
+
+## 相关仓库
+
+- `DCU/ESP32_COMM` —— ESP32-C3 固件(OTA、SPI Link v2、CAN FD bridge)。
+- `DCU/DCU_APP`、`DCU/GD32E50X_BOOT` —— GD32 侧应用与 bootloader。
+- `DCU/tools/ecat` —— LAN9252 EtherCAT 主站工具集(同类结构的姊妹工具)。
diff --git a/build_mem.bat b/build_mem.bat
new file mode 100644
index 0000000..847f972
--- /dev/null
+++ b/build_mem.bat
@@ -0,0 +1,23 @@
+@echo off
+REM Clear MSYS/MINGW env vars to bypass IDF MSys detection when called from Git Bash
+set MSYSTEM=
+set MSYSTEM_CHOST=
+set MSYSTEM_PREFIX=
+set MINGW_CHOST=
+set MINGW_PREFIX=
+set MINGW_PACKAGE_PREFIX=
+set CHERE_INVOKING=
+set MSYS2_PATH_TYPE=
+
+REM Toolchain installed at C:\Espressif (see .vscode/settings.json idf.toolsPath)
+set IDF_TOOLS_PATH=C:\Espressif
+
+call "F:\software\.espressif\v6.0\esp-idf\export.bat"
+if errorlevel 1 (
+ echo EXPORT_FAILED
+ exit /b 1
+)
+
+cd /d "G:\Robot_v2\DCU\ESP32_COMM"
+idf.py build
+exit /b %errorlevel%
\ No newline at end of file
diff --git a/can.html b/can.html
new file mode 100644
index 0000000..36ab440
--- /dev/null
+++ b/can.html
@@ -0,0 +1,334 @@
+
+
+
+
+
+ESP32 CAN 通信控制台
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📚
+
报文列表
+
+
+
+ 0文件
+ 0报文
+ 0信号
+ 0节点
+ 0 B
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📥
+
导入 DBC 开始编辑报文
+
点击此处或拖拽 .dbc 文件到列表区域
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ✓
+ 操作成功
+
+
+
+
+
diff --git a/can_monitor.html b/can_monitor.html
new file mode 100644
index 0000000..7518b57
--- /dev/null
+++ b/can_monitor.html
@@ -0,0 +1,189 @@
+
+
+
+
+
+ESP32 CAN 接收监控
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📊 三路 CAN 通道反馈(1Hz)
+
+
+
+
+ CAN0
+ ▾
+
+
+
状态--
+
接收数--
+
发送数--
+
丢帧--
+
错误--
+
+
+
TEC--
+
REC--
+
总线状态--
+
最新ID--
+
DLC--
+
FD/BRS/ESI--
+
Seq--
+
Δrx/s--
+
最新HEX--
+
+
+
+
+
+ CAN1
+ ▾
+
+
+
状态--
+
接收数--
+
发送数--
+
丢帧--
+
错误--
+
+
+
TEC--
+
REC--
+
总线状态--
+
最新ID--
+
DLC--
+
FD/BRS/ESI--
+
Seq--
+
Δrx/s--
+
最新HEX--
+
+
+
+
+
+ CAN2
+ ▾
+
+
+
状态--
+
接收数--
+
发送数--
+
丢帧--
+
错误--
+
+
+
TEC--
+
REC--
+
总线状态--
+
最新ID--
+
DLC--
+
FD/BRS/ESI--
+
Seq--
+
Δrx/s--
+
最新HEX--
+
+
+
+
+
+
+
+
+
+ 🔬 每 ID 快照表(1Hz 刷新)
+
+
+ --
+
+
+
+
+
+
+
+ | # | CAN ID | DLC |
+ FD | BRS | ESI | 数据 (HEX) |
+
+
+
+ | 等待数据... |
+
+
+
+
+
+
+
+
+
+ ✓
+ 操作成功
+
+
+
+
+
\ No newline at end of file
diff --git a/continuity_test.html b/continuity_test.html
new file mode 100644
index 0000000..22d621f
--- /dev/null
+++ b/continuity_test.html
@@ -0,0 +1,754 @@
+
+
+
+
+
+LAN9252 通断测试
+
+
+
+
+
+
+
+
LAN9252 通断测试
+
通过 ESP32 /spi/send → SPI 0xA2 → GD32 驱动 EXMC 引脚,万用表测 LAN9252 端电压
+
+
+ ⚠️
+ 不点按钮时 GD32 引脚为高阻输入态(安全)。点击模式后先预览引脚图,点"确认应用"才实际驱动引脚。测量完毕请点"停止/恢复"释放。
+
+
+
+
+
+ 设备 IP
+ 读取中...
+ 共用主页"设备连接"配置的 IP
+
+
+
+
+
+
LAN9252 64-QFN 引脚图
+
俯视,Pin1 右上角。点击下方模式按钮预览引脚电压,点"确认应用"发送命令。绿色=3.3V,红色=0V,橙色=控制线测试中。
+
+
+
+
+ HIGH 3.3V
+ LOW 0V
+ 控制线测试
+ 数据线空闲
+ 控制线空闲
+ 电源/其他
+ Pin1
+
+
+
+
+
+
模式选择
+
点击预览引脚图,再点"确认应用"发送命令
+
+
数据线 pattern · D0-D15
+
+
+
+
+
+
+
+
控制线测试
+
+
+
+
+
+
+
+
+
读周期保持 · 判定芯片是否驱动总线
+
+
+
+
+ 数据线切为高阻输入、CS#+RD# 持续拉低(读 IDX_0)。用万用表在 LAN9252 芯片引脚 上量数据线:
+ 芯片活着 → D2(Pin12)、D4(Pin49)、D5(Pin50)、D9(Pin19)、D12(Pin21) ≈ 3.3V,其余数据脚 ≈ 0V(全部确定电平,即 0x1234);
+ 全部中电平/漂移 → 芯片未驱动总线。图上预览色 = 芯片应答的期望值。
+
+
+
+
+
+
操作
+
+
+
+
+
+
+ 确认应用 · 发送当前选中的模式到 GD32
+ 循环切换 · 每5s在 0x0000 ↔ 当前选中模式 之间切换(需先选数据线 pattern)
+ 停止 / 恢复 · 恢复 EXMC 正常模式
+
+
+
+
+
+
+
+
+
+
+
diff --git a/css/base.css b/css/base.css
new file mode 100644
index 0000000..28c07c5
--- /dev/null
+++ b/css/base.css
@@ -0,0 +1,135 @@
+/* ============================================================
+ base.css — DCU Mission Control · 设计令牌与全局基底
+ 风格:深色测控台(deep blue-slate),多色子系统语义配色
+ 子系统色:ESP32=青绿 · GD32=琥珀 · BLE=蓝 · CAN=绿 · 内存/调试=紫
+ ============================================================ */
+
+:root {
+ /* ---- 底色层次:深蓝灰,层层抬升,拒绝纯黑 ---- */
+ --bg-root: #0a0f1a;
+ --bg-panel: #0e1624;
+ --bg-card: #131d2e;
+ --bg-elevated: #1a2740;
+ --bg-input: #0b1220;
+ --bg-hover: #20304c;
+
+ /* ---- 边框 ---- */
+ --border: #21304a;
+ --border-strong: #2e4262;
+ --border-subtle: #1a2740;
+
+ /* ---- 文字 ---- */
+ --text: #e9f0fa;
+ --text-dim: #93a8c4;
+ --text-label: #617a99;
+
+ /* ---- 子系统语义色 ---- */
+ --esp32: #2fd8c1; /* 青绿:ESP32 / 主操作 / WiFi OTA */
+ --esp32-hi: #6ff2e0;
+ --esp32-dim: rgba(47,216,193,.14);
+ --gd32: #fcbf3f; /* 琥珀:GD32 / 中继 OTA */
+ --gd32-hi: #ffd97a;
+ --gd32-dim: rgba(252,191,63,.14);
+ --ble: #5fa8ff; /* 蓝:BLE 蓝牙通道 */
+ --ble-hi: #93c6ff;
+ --ble-dim: rgba(95,168,255,.14);
+ --can: #43d97b; /* 绿:CAN 总线 / 健康 */
+ --can-hi: #7deda6;
+ --can-dim: rgba(67,217,123,.14);
+ --motor: #22d3ee; /* 青:电机控制台 */
+ --motor-hi: #67e8f9;
+ --motor-dim: rgba(34,211,238,.14);
+ --mem: #a88bfa; /* 紫:内存 / 调试 / 日志 */
+ --mem-hi: #c7b3ff;
+ --mem-dim: rgba(168,139,250,.14);
+
+ /* ---- 状态语义色 ---- */
+ --green: #3ddc8e;
+ --amber: #fcbf3f;
+ --red: #ff6b6b;
+ --red-dim: rgba(255,107,107,.14);
+
+ /* ---- 兼容旧令牌(大量旧样式引用,勿删)---- */
+ --accent: var(--esp32);
+ --accent-dim: var(--esp32-dim);
+ --border-focus: var(--esp32);
+
+ /* ---- 圆角 ---- */
+ --radius-sm: 8px;
+ --radius: 12px;
+ --radius-lg: 16px;
+
+ /* ---- 字体 ----
+ 沿用原始系统字体方案:中文优先苹方/雅黑,西文 Segoe UI;
+ mono 用于 SHA / HEX / 日志 / 数值(SF Mono → Fira Code → Cascadia → Consolas)。
+ --font-display 保留变量名以兼容既有引用,指向同一系统栈。 */
+ --font-display: "PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif;
+ --font: "PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif;
+ --font-mono: "SF Mono","Fira Code","Cascadia Code","Consolas",monospace;
+
+ /* ---- 间距 / 阴影 ---- */
+ --gap: 18px;
+ --shadow-card: 0 10px 30px rgba(3,7,16,.45), 0 1px 0 rgba(255,255,255,.03) inset;
+ --shadow-pop: 0 18px 48px rgba(3,7,16,.6);
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+html, body {
+ height: 100%;
+ background: var(--bg-root);
+ color: var(--text);
+ font-family: var(--font);
+ font-size: 14px;
+ line-height: 1.55;
+ -webkit-font-smoothing: antialiased;
+ overflow-x: hidden;
+}
+
+/* ---- 环境光背景:细网格 + 双色氛围辉光 + 顶部扫光 ----
+ 网格提供"工程图纸"质感;左上青绿、右下琥珀的两团极淡辉光
+ 暗示 ESP32 / GD32 双核子系统,随视口固定,营造纵深。 */
+body::before {
+ content: "";
+ position: fixed; inset: 0; z-index: 0; pointer-events: none;
+ background-image:
+ linear-gradient(rgba(148,180,226,.035) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(148,180,226,.035) 1px, transparent 1px);
+ background-size: 44px 44px;
+ mask-image: radial-gradient(ellipse 120% 90% at 50% -8%, black 30%, transparent 78%);
+}
+body::after {
+ content: "";
+ position: fixed; inset: 0; z-index: 0; pointer-events: none;
+ background:
+ radial-gradient(900px 520px at 8% -10%, rgba(47,216,193,.10), transparent 60%),
+ radial-gradient(820px 560px at 100% 112%, rgba(252,191,63,.07), transparent 62%),
+ radial-gradient(700px 480px at 88% -6%, rgba(95,168,255,.06), transparent 60%);
+}
+
+.container {
+ position: relative; z-index: 1;
+ max-width: 1480px; margin: 0 auto; padding: 26px 32px 44px;
+}
+
+/* ---- 全局滚动条 ---- */
+::-webkit-scrollbar { width: 9px; height: 9px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb {
+ background: var(--border-strong); border-radius: 99px;
+ border: 2px solid var(--bg-root);
+}
+::-webkit-scrollbar-thumb:hover { background: #3a5378; }
+
+/* ---- 选区 ---- */
+::selection { background: rgba(47,216,193,.28); color: #eafffb; }
+
+/* ---- 通用等宽数字(让指标/百分比对齐、不跳动)---- */
+.tabular { font-variant-numeric: tabular-nums; }
+
+/* ---- focus 可见性(键盘可达性)---- */
+:focus-visible {
+ outline: 2px solid var(--esp32);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
diff --git a/css/can.css b/css/can.css
new file mode 100644
index 0000000..3484997
--- /dev/null
+++ b/css/can.css
@@ -0,0 +1,1712 @@
+/* === can.css — CAN 面板/工作台/1Hz反馈/ID快照/报文行展开/节点过滤 等 CAN 专用样式 === */
+/* === CAN PANEL === */
+.can-grid {
+ display: grid; grid-template-columns: 70px 1fr 60px 1fr; gap: 10px;
+}
+.can-launch-card {
+ background:
+ radial-gradient(circle at top right, rgba(67,217,123,.16), transparent 28%),
+ linear-gradient(138deg, rgba(18,29,41,.96), rgba(20,29,40,.92) 48%, rgba(14,21,31,.98) 100%);
+ border: 1px solid rgba(67,217,123,.16);
+ border-radius: 14px;
+ padding: 20px;
+ box-shadow:
+ inset 0 0 0 1px rgba(255,255,255,.02),
+ 0 18px 40px rgba(0,0,0,.18);
+ position: relative;
+ overflow: hidden;
+}
+.can-launch-card::before {
+ content: "";
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: 4px;
+ background: linear-gradient(180deg, rgba(67,217,123,.9), rgba(67,217,123,.18));
+}
+.can-launch-copy {
+ position: relative;
+ z-index: 1;
+}
+.can-launch-top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 10px;
+}
+.can-launch-copy h3 {
+ font-size: 22px;
+ line-height: 1.2;
+ margin: 6px 0 0;
+}
+.can-launch-desc {
+ color: var(--text-dim);
+ font-size: 13px;
+ line-height: 1.7;
+ max-width: 700px;
+}
+.can-launch-kicker {
+ color: #8ef0b4;
+ font-size: 10px;
+ letter-spacing: .18em;
+ text-transform: uppercase;
+ opacity: .92;
+}
+.can-launch-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 7px 12px;
+ border-radius: 999px;
+ background: rgba(67,217,123,.09);
+ border: 1px solid rgba(67,217,123,.22);
+ color: #a9f5c8;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: .12em;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+.can-launch-points {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 14px;
+}
+.can-launch-point {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 11px;
+ border-radius: 999px;
+ background: rgba(255,255,255,.03);
+ border: 1px solid rgba(255,255,255,.06);
+ color: #c6d7e8;
+ font-size: 12px;
+}
+.can-launch-point::before {
+ content: "";
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--can);
+ box-shadow: 0 0 10px rgba(67,217,123,.35);
+}
+.can-launch-actions {
+ display: grid;
+ grid-template-columns: minmax(0, 1.15fr) minmax(180px, .85fr);
+ gap: 10px;
+ margin-top: 18px;
+ position: relative;
+ z-index: 1;
+}
+.can-launch-primary,
+.can-launch-secondary {
+ min-height: 42px;
+}
+.can-launch-primary {
+ background: linear-gradient(135deg, var(--can) 0%, #22b45f 100%);
+ color: #04240f;
+ box-shadow: 0 10px 24px rgba(67,217,123,.2);
+}
+.can-launch-secondary {
+ color: #d5e4f4;
+ border-color: rgba(255,255,255,.08);
+ background: rgba(255,255,255,.015);
+}
+.can-launch-secondary:hover:not(:disabled) {
+ background: rgba(255,255,255,.05);
+ border-color: rgba(67,217,123,.25);
+}
+.can-launch-foot {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ margin-top: 16px;
+ padding-top: 14px;
+ border-top: 1px solid rgba(255,255,255,.06);
+ color: #91a6bc;
+ font-size: 12px;
+ line-height: 1.7;
+ position: relative;
+ z-index: 1;
+}
+/* 电机入口:复用 can-launch 布局,换青色强调 */
+.motor-launch-card {
+ background:
+ radial-gradient(circle at top right, rgba(34,211,238,.16), transparent 28%),
+ linear-gradient(138deg, rgba(18,29,41,.96), rgba(20,29,40,.92) 48%, rgba(14,21,31,.98) 100%);
+ border-color: rgba(34,211,238,.16);
+}
+.motor-launch-card::before {
+ background: linear-gradient(180deg, rgba(34,211,238,.9), rgba(34,211,238,.18));
+}
+.motor-launch-card .can-launch-kicker { color: #67e8f9; }
+.motor-launch-card .can-launch-badge {
+ background: rgba(34,211,238,.09);
+ border-color: rgba(34,211,238,.22);
+ color: #a5f3fc;
+}
+.motor-launch-card .can-launch-point::before {
+ background: var(--motor, #22d3ee);
+ box-shadow: 0 0 10px rgba(34,211,238,.35);
+}
+.motor-launch-primary {
+ background: linear-gradient(135deg, var(--motor, #22d3ee) 0%, #0891b2 100%) !important;
+ color: #042f2e !important;
+ box-shadow: 0 10px 24px rgba(34,211,238,.2);
+}
+.motor-launch-card .can-launch-secondary:hover:not(:disabled) {
+ border-color: rgba(34,211,238,.25);
+}
+
+.can-launch-foot-label {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 40px;
+ padding: 4px 8px;
+ border-radius: 999px;
+ background: rgba(255,255,255,.04);
+ color: #d8e6f4;
+ font-size: 10px;
+ letter-spacing: .12em;
+ text-transform: uppercase;
+}
+/* === CAN WORKSPACE PAGE === */
+/* 右侧监控抽屉 translate 出屏时,防止 body 被撑出横向滚动条 */
+body.can-page {
+ overflow-x: hidden;
+ max-width: 100%;
+}
+.can-page::after {
+ content: "";
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ background:
+ radial-gradient(circle at 10% 18%, rgba(34,211,238,.08), transparent 26%),
+ radial-gradient(circle at 85% 14%, rgba(245,158,11,.05), transparent 24%);
+}
+.can-container {
+ max-width: min(1700px, 100%);
+ width: 100%;
+ box-sizing: border-box;
+}
+.can-header {
+ position: sticky;
+ top: 16px;
+ z-index: 5;
+ backdrop-filter: blur(14px);
+ background: rgba(15,22,31,.86);
+}
+/* sticky+backdrop-filter 合成层冲突致 fadeInUp 卡首帧(translateY(10px)+opacity:0),
+ * 禁用入场动画。选择器需 .header-bar.can-header 以胜过 responsive.css 的 .header-bar 规则 */
+.header-bar.can-header { animation: none; }
+.can-header-actions {
+ margin-left: auto;
+}
+.can-link-pill {
+ text-decoration: none;
+ color: var(--text);
+}
+.can-toolbar-card,
+.can-table-card,
+.can-editor-layout {
+ margin-top: 18px;
+}
+.can-toolbar-grid {
+ display: grid;
+ grid-template-columns: minmax(220px, 1.1fr) 140px minmax(200px, 1fr);
+ gap: 14px;
+ align-items: start;
+}
+.can-toolbar-grid-compact {
+ grid-template-columns: 140px minmax(220px, 1fr);
+}
+.can-toolbar-grid-dbc-only {
+ grid-template-columns: 1fr;
+ max-width: 420px;
+}
+
+/* DBC 并入报文列表卡 */
+.can-table-card.dbc-dropzone {
+ position: relative;
+ transition: box-shadow .15s, border-color .15s;
+}
+.can-table-card.dbc-dropzone.dragover {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-dim);
+}
+.can-table-card.dbc-dropzone.dragover::after {
+ content: '松开以导入 DBC';
+ position: absolute;
+ inset: 0;
+ z-index: 5;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(9, 14, 21, 0.72);
+ color: var(--accent);
+ font-weight: 700;
+ font-size: 15px;
+ pointer-events: none;
+ border-radius: inherit;
+}
+/* 标题栏内:统计 + 文件 chip 同一行;chip 先铺满横轴,真不够再折行 */
+.dbc-summary-inline {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: 8px 10px;
+ margin: 0 0 0 8px;
+ flex: 1 1 auto;
+ min-width: 0;
+ max-width: none;
+ padding: 2px 6px;
+ white-space: nowrap;
+ overflow: hidden;
+}
+.dbc-summary-inline .dbc-summary-meta {
+ flex: 0 0 auto;
+}
+.dbc-file-chips {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap; /* 仅在占满标题栏剩余宽度后折行 */
+ align-items: center;
+ align-content: flex-start;
+ gap: 4px 6px;
+ flex: 1 1 auto;
+ min-width: 0;
+ max-width: none; /* 去掉 40vw/360px 上限,吃满横轴 */
+ overflow: hidden;
+}
+.dbc-file-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ max-width: 140px;
+ padding: 1px 2px 1px 6px;
+ border-radius: 999px;
+ background: rgba(34, 211, 238, 0.1);
+ border: 1px solid rgba(34, 211, 238, 0.25);
+ font-size: 10px;
+ color: var(--text);
+ flex: 0 0 auto;
+}
+.dbc-file-chip-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 110px;
+}
+.dbc-file-chip-remove {
+ border: none;
+ background: transparent;
+ color: var(--text-dim);
+ cursor: pointer;
+ font-size: 14px;
+ line-height: 1;
+ padding: 0 4px;
+ border-radius: 50%;
+}
+.dbc-file-chip-remove:hover {
+ color: var(--red);
+ background: rgba(239, 68, 68, 0.12);
+}
+.dbc-empty-drop {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-height: 280px;
+ padding: 32px 16px;
+ text-align: center;
+ color: var(--text-dim);
+ cursor: pointer;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius-sm);
+ margin: 12px;
+ background: rgba(255, 255, 255, 0.02);
+}
+.dbc-empty-drop:hover {
+ border-color: var(--accent);
+ color: var(--text);
+}
+.dbc-empty-drop h4 {
+ margin: 0;
+ font-size: 15px;
+ color: var(--text);
+ font-weight: 600;
+}
+.dbc-empty-drop p {
+ margin: 0;
+ font-size: 12px;
+}
+.can-table-card-body .helper-note {
+ margin: 0 12px 12px;
+}
+.can-toolbar-block {
+ min-width: 0;
+}
+.can-toolbar-upload .upload-zone {
+ min-height: 72px;
+ padding: 12px 12px;
+}
+.can-toolbar-upload .upload-zone h4 { font-size: 13px; margin: 4px 0 2px; }
+.can-toolbar-upload .upload-zone p { font-size: 11px; margin: 0; }
+/* DBC 解析摘要:强制单行 */
+.dbc-summary-strip {
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: 6px 8px;
+ margin-top: 8px;
+ padding: 6px 8px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: rgba(255, 255, 255, 0.02);
+ font-size: 11px;
+ color: var(--text-dim);
+ min-width: 0;
+ white-space: nowrap;
+}
+.dbc-summary-strip[hidden] { display: none !important; }
+.dbc-summary-name {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text);
+ font-weight: 600;
+}
+.dbc-summary-meta {
+ display: inline-flex;
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: 0 8px;
+ flex: 0 0 auto;
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+}
+.dbc-summary-meta > span {
+ white-space: nowrap;
+ flex: 0 0 auto;
+}
+.dbc-summary-meta b {
+ color: var(--text);
+ font-weight: 600;
+ margin-right: 1px;
+}
+.dbc-summary-strip .btn { flex: 0 0 auto; padding: 2px 8px; white-space: nowrap; }
+.can-toolbar-foot {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px 16px;
+ margin-top: 12px;
+ align-items: center;
+}
+.can-toolbar-foot .helper-note {
+ flex: 1 1 160px;
+ margin: 0;
+ font-size: 11px;
+}
+
+/* 报文列表标题栏:标题 | 摘要+chip(吃满中间) | 清除/添加 */
+.can-msg-list-head {
+ flex-wrap: nowrap;
+ row-gap: 8px;
+ min-width: 0;
+}
+.can-msg-list-head > .dbc-summary-inline {
+ margin-left: 8px;
+ flex: 1 1 auto;
+ min-width: 0;
+}
+.can-msg-head-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex: 0 0 auto;
+ margin-left: 8px;
+}
+.can-msg-list-head > .dbc-summary-inline + .can-msg-head-actions {
+ margin-left: 8px;
+}
+.can-msg-head-actions #clearDbcBtn[hidden] {
+ display: none !important;
+}
+/* 通道 / 搜索 / 勾选 / 节点 / 批量 — 同一行工具栏 */
+.can-msg-toolbar {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px 10px;
+ padding: 8px 14px;
+ border-bottom: 1px solid var(--border);
+ background: rgba(15, 23, 42, 0.35);
+ min-width: 0;
+ /* 不可用 overflow-x:auto:会把 overflow-y 也变成 clip,节点下拉被裁切 */
+ overflow: visible;
+ position: relative;
+ z-index: 40; /* 高于 .message-table-head / 列表,避免下拉沉底 */
+}
+.can-msg-channel {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex: 0 0 auto;
+ font-size: 11px;
+ color: var(--text-dim);
+ text-transform: none;
+ letter-spacing: 0;
+ font-weight: 500;
+ cursor: pointer;
+ margin: 0;
+}
+.can-msg-channel-label {
+ white-space: nowrap;
+}
+.can-msg-channel-select {
+ width: 72px;
+ min-width: 72px;
+ height: 30px;
+ padding: 2px 6px;
+ font-size: 12px;
+}
+.can-msg-search-wrap {
+ flex: 1 1 140px;
+ min-width: 120px;
+ max-width: 260px;
+}
+.can-msg-search {
+ width: 100%;
+ padding: 5px 10px;
+ font-size: 12px;
+ height: 30px;
+}
+.can-msg-toolbar .can-batch-actions {
+ flex: 0 1 auto;
+ margin-left: auto;
+ flex-wrap: nowrap;
+ gap: 6px;
+ min-width: 0;
+}
+.can-msg-toolbar .pill {
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+.can-msg-toolbar .btn-sm {
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+.can-msg-toolbar .can-batch-all-label {
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+.can-table-card-body {
+ padding: 0;
+}
+.can-editor-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 340px);
+ gap: 18px;
+ align-items: start;
+ min-width: 0;
+ width: 100%;
+ max-width: 100%;
+}
+.can-editor-layout > .can-main-col,
+.can-editor-layout > .can-sidepanel {
+ min-width: 0;
+ max-width: 100%;
+}
+.can-sidepanel {
+ display: flex;
+ flex-direction: column;
+ gap: 10px; /* 两卡间距略紧,仍保持独立卡片 */
+ width: 100%;
+}
+.can-sidepanel > .card {
+ margin-top: 0;
+ min-width: 0;
+ max-width: 100%;
+}
+.can-preview-card .card-body,
+.can-raw-card .card-body {
+ padding-top: 12px;
+ padding-bottom: 12px;
+}
+.dbc-dropzone {
+ position: relative;
+ overflow: hidden;
+}
+.dbc-dropzone::after {
+ content: "";
+ position: absolute;
+ inset: -40% auto auto -10%;
+ width: 180px;
+ height: 180px;
+ background: radial-gradient(circle, rgba(34,211,238,.18), transparent 68%);
+ pointer-events: none;
+}
+.message-list {
+ max-height: 390px;
+ overflow-x: hidden !important;
+ overflow-y: auto;
+ overscroll-behavior-x: none;
+}
+.message-table {
+ /* 列宽随左栏宽度自适应压缩,禁止撑出横向滚动 */
+ min-width: 0 !important;
+ width: 100%;
+ max-width: 100%;
+ table-layout: fixed;
+}
+.message-table-body {
+ min-width: 0;
+ width: 100%;
+ max-width: 100%;
+ overflow-x: hidden;
+}
+.message-table-head,
+.message-row {
+ display: grid;
+ /* 报文名称勿抢宽;标识符/DLC/信号/节点给固定下限,避免被挤没 */
+ grid-template-columns:
+ 34px /* 勾选 */
+ 34px /* 展开 */
+ 28px /* # */
+ minmax(0, 0.55fr) /* DBC名称 */
+ minmax(0, 1fr) /* 报文名称(收窄,长名省略) */
+ minmax(7.2em, 0.65fr)/* 标识符 */
+ 0fr /* 通道 */
+ 0fr /* 类型 */
+ 2.8em /* DLC */
+ 2.8em /* 信号 */
+ minmax(5.5em, 0.8fr) /* 发送节点 */
+ minmax(0, 0.65fr); /* 注释 */
+ align-items: center;
+ min-width: 0;
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+}
+.message-col-name {
+ /* 名称过长只省略,不撑开其它列 */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.message-col-id,
+.message-col-dlc,
+.message-col-signal,
+.message-col-node {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.message-table-head {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background: #182330;
+ border-bottom: 1px solid var(--border);
+}
+.message-row {
+ width: 100%;
+ border: 0;
+ background: transparent;
+ color: var(--text);
+ text-align: left;
+ cursor: pointer;
+ border-bottom: 1px solid rgba(255,255,255,.04);
+ transition: background .12s ease, border-color .12s ease;
+}
+.message-row:hover {
+ background: rgba(255,255,255,.03);
+}
+.message-row.active {
+ background: linear-gradient(90deg, rgba(34,211,238,.2), rgba(34,211,238,.07));
+ box-shadow: inset 3px 0 0 0 var(--accent);
+}
+.message-col {
+ padding: 8px 6px;
+ font-size: 12px;
+ line-height: 1.45;
+ border-right: 1px solid rgba(255,255,255,.03);
+ min-width: 0;
+ box-sizing: border-box;
+}
+.message-table-head .message-col {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: .08em;
+ color: #c8d6e7;
+ text-transform: uppercase;
+}
+.message-col:last-child {
+ border-right: 0;
+}
+.message-col-index,
+.message-col-dlc,
+.message-col-signal,
+.message-col-channel,
+.message-col-type {
+ text-align: center;
+}
+.message-col-id,
+.history-item-hex,
+.can-frame-hex,
+.can-title,
+.can-subtitle,
+.byte-pill,
+.history-item-main,
+.history-item-foot,
+.signal-table,
+.signal-cell-meta,
+.signal-cell-range {
+ font-family: var(--font-mono);
+}
+.message-col-id {
+ color: var(--accent);
+ font-size: 12px;
+}
+.message-col-dbc {
+ color: var(--accent);
+ font-size: 11px;
+ font-weight: 600;
+ opacity: .9;
+}
+.can-empty-state {
+ display: grid;
+ place-items: center;
+ min-height: 300px;
+ text-align: center;
+ border: 1px dashed rgba(34,211,238,.2);
+ border-radius: var(--radius);
+ background: linear-gradient(180deg, rgba(26,36,50,.7), rgba(20,29,40,.96));
+ padding: 28px;
+}
+.can-empty-icon {
+ width: 70px;
+ height: 70px;
+ border-radius: 18px;
+ display: grid;
+ place-items: center;
+ margin-bottom: 14px;
+ background: rgba(34,211,238,.12);
+ font-size: 32px;
+ box-shadow: inset 0 0 0 1px rgba(34,211,238,.16);
+}
+.can-empty-state h3 {
+ font-size: 22px;
+ margin-bottom: 8px;
+}
+.can-empty-state p {
+ max-width: 560px;
+ color: var(--text-dim);
+ line-height: 1.75;
+}
+.can-title {
+ font-size: 24px;
+ font-weight: 700;
+ letter-spacing: .02em;
+}
+.can-subtitle {
+ margin-top: 6px;
+ color: var(--text-dim);
+ font-size: 13px;
+}
+.can-selection-bar {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+.can-selection-actions {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+.can-meta-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
+ margin-top: 16px;
+}
+.can-meta-card {
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 12px;
+}
+.can-meta-card span {
+ display: block;
+ font-size: 10px;
+ color: var(--text-label);
+ letter-spacing: .08em;
+ text-transform: uppercase;
+ margin-bottom: 4px;
+}
+.can-meta-card b {
+ font-size: 14px;
+}
+.can-comment-note {
+ margin-top: 16px;
+}
+.can-frame-panel {
+ background: linear-gradient(180deg, rgba(26,36,50,.95), rgba(13,19,27,.98));
+ border: 1px solid rgba(34,211,238,.12);
+ border-radius: 16px;
+ padding: 18px;
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,.02);
+}
+.can-frame-panel-compact {
+ padding: 16px;
+}
+.can-panel-kicker {
+ color: var(--text-label);
+ font-size: 10px;
+ letter-spacing: .12em;
+ text-transform: uppercase;
+}
+.can-panel-title {
+ margin-top: 6px;
+ font-size: 16px;
+ font-weight: 700;
+}
+.can-frame-hex {
+ margin-top: 14px;
+ padding: 16px 18px;
+ border-radius: 12px;
+ background: #081019;
+ color: #7ef3ff;
+ font-size: 18px;
+ line-height: 1.6;
+ word-break: break-all;
+ border: 1px solid rgba(34,211,238,.16);
+}
+.can-byte-strip {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 8px;
+}
+.byte-pill {
+ min-width: 0;
+ width: 36px;
+ padding: 3px 2px 4px;
+ border-radius: 6px;
+ background: rgba(34,211,238,.08);
+ border: 1px solid rgba(34,211,238,.16);
+ text-align: center;
+ font-size: 11px;
+ line-height: 1.2;
+ font-family: var(--font-mono);
+ flex: 0 0 auto;
+}
+.byte-pill b {
+ display: block;
+ color: var(--text-label);
+ font-size: 9px;
+ font-weight: 600;
+ margin-bottom: 1px;
+ letter-spacing: 0;
+ font-family: var(--font);
+}
+.byte-pill > span {
+ font-variant-numeric: tabular-nums;
+}
+.signal-table-host {
+ display: block;
+ margin-top: 18px;
+ min-width: 0;
+ max-width: 100%;
+}
+.signal-table {
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ overflow: hidden;
+}
+.signal-table-head,
+.signal-row {
+ display: grid;
+ /* 全部可收缩,禁止固定 min 像素把容器撑宽 */
+ grid-template-columns:
+ 2.2em
+ minmax(0, 1.15fr)
+ minmax(0, 0.9fr)
+ minmax(0, 0.95fr)
+ minmax(0, 0.85fr)
+ minmax(0, 0.9fr);
+ align-items: stretch;
+ min-width: 0;
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+}
+.signal-table-inline .signal-cell {
+ padding: 8px 8px;
+}
+.signal-table-inline .signal-cell b,
+.signal-table-inline .signal-cell small {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 100%;
+}
+.signal-table-head {
+ background: #182330;
+ border-bottom: 1px solid var(--border);
+}
+.signal-table-body {
+ max-height: 520px;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+.signal-row {
+ border-bottom: 1px solid rgba(255,255,255,.04);
+ background: rgba(255,255,255,.01);
+}
+.signal-row:nth-child(even) {
+ background: rgba(255,255,255,.025);
+}
+.signal-cell {
+ padding: 11px 12px;
+ border-right: 1px solid rgba(255,255,255,.03);
+ font-size: 12px;
+ line-height: 1.5;
+ min-width: 0;
+}
+.signal-cell:last-child {
+ border-right: 0;
+}
+.signal-table-head .signal-cell {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: .08em;
+ color: #c8d6e7;
+ text-transform: uppercase;
+}
+.signal-cell-index {
+ text-align: center;
+}
+.signal-cell-name b,
+.signal-cell-range b,
+.signal-cell-meta b {
+ display: block;
+ font-size: 12px;
+ color: var(--text);
+}
+.signal-cell-name small,
+.signal-cell-range small,
+.signal-cell-meta small,
+.signal-cell-comment {
+ display: block;
+ margin-top: 4px;
+ color: var(--text-dim);
+ font-size: 11px;
+ line-height: 1.45;
+}
+.signal-input-compact {
+ min-width: 0;
+ padding: 8px 10px;
+ font-size: 12px;
+}
+.can-response {
+ margin-top: 14px;
+ padding: 12px 14px;
+ border-radius: 12px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ word-break: break-all;
+}
+.can-response.ok {
+ border-color: rgba(34,197,94,.25);
+ color: #c7f9d5;
+}
+.can-response.err {
+ border-color: rgba(239,68,68,.25);
+ color: #fecaca;
+}
+.can-response-label {
+ color: var(--text-label);
+ font-size: 11px;
+ margin-bottom: 6px;
+ font-family: var(--font);
+}
+.history-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ max-height: 620px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ padding-right: 4px;
+ min-width: 0;
+ max-width: 100%;
+}
+.history-list-compact {
+ max-height: 420px;
+}
+/* 单行紧凑历史:充分利用横轴,减少行高 */
+.history-item {
+ padding: 5px 10px;
+ border-radius: 8px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ min-width: 0;
+ max-width: 100%;
+ box-sizing: border-box;
+}
+.history-item-row {
+ display: grid;
+ grid-template-columns:
+ auto
+ 56px
+ minmax(0, 0.7fr)
+ minmax(0, 0.8fr)
+ minmax(0, 1.6fr);
+ align-items: center;
+ gap: 8px 10px;
+ min-width: 0;
+ max-width: 100%;
+}
+.history-item-row.is-err {
+ border-color: rgba(239, 68, 68, 0.35);
+}
+.history-item-time {
+ color: var(--text-label);
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+.history-item-tag {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2px 7px;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: .06em;
+ white-space: nowrap;
+ line-height: 1.3;
+}
+.history-item-tag.ok {
+ background: rgba(34,197,94,.12);
+ color: #b8f5cb;
+}
+.history-item-tag.err {
+ background: rgba(239,68,68,.12);
+ color: #fecaca;
+}
+.history-item-main {
+ margin-top: 0;
+ font-size: 12px;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+.history-item-hex {
+ margin-top: 0;
+ font-size: 11px;
+ color: #7ef3ff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ word-break: normal;
+}
+.history-item-foot {
+ margin-top: 0;
+ font-size: 11px;
+ color: var(--text-dim);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ text-align: right;
+}
+/* Click-to-Copy 样式 */
+.history-item-row .copyable {
+ cursor: pointer;
+ border-radius: 4px;
+ padding: 1px 4px;
+ margin: -1px -4px;
+ transition: background .15s, color .15s;
+}
+.history-item-row .copyable:hover {
+ background: rgba(255,255,255,.08);
+}
+.history-item-row .copyable:active {
+ background: rgba(255,255,255,.14);
+}
+.history-item-row .copyable.copied {
+ background: rgba(34,197,94,.18);
+ color: #86efac;
+}
+
+/* === CAN 1Hz FEEDBACK === */
+.can-feedback-card { margin-top: 18px; }
+.can-feedback-card .card-head {
+ display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px;
+}
+.feedback-controls { display: flex; align-items: center; gap: 8px; }
+.feedback-grid {
+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px;
+}
+/* can.html 右侧监控抽屉:竖向排三路,窄宽也能读 */
+.feedback-grid-drawer {
+ grid-template-columns: 1fr;
+ gap: 12px;
+}
+.can-monitor-drawer {
+ /* 用 100% 视口减量,避免 100vw 含滚动条宽度把页面撑出横条 */
+ --sys-drawer-panel-w: min(440px, calc(100% - 48px));
+ max-width: 100%;
+}
+.can-monitor-drawer .sys-drawer-body .status-section-title:first-child {
+ margin-top: 0;
+}
+.can-monitor-drawer .conn-indicator {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ margin: 0;
+ background: transparent;
+ border: none;
+}
+.feedback-channel.ch-disabled {
+ opacity: 0.45;
+ filter: grayscale(0.35);
+}
+.feedback-channel.ch-disabled .feedback-ch-head {
+ cursor: default;
+}
+.feedback-channel {
+ background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.07);
+ border-radius: 10px; padding: 14px 16px;
+}
+.feedback-ch-head {
+ font-weight: 700; font-size: 15px; margin-bottom: 10px;
+ display: flex; align-items: center; gap: 8px;
+ cursor: pointer;
+ user-select: none;
+}
+.feedback-expand-caret {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--text-dim);
+ transition: transform .15s ease;
+ opacity: .7;
+}
+.feedback-channel.collapsed .feedback-expand-caret {
+ transform: rotate(-90deg);
+}
+.feedback-channel.collapsed .feedback-detail {
+ display: none;
+}
+.feedback-channel:not(.collapsed) .feedback-ch-head {
+ margin-bottom: 8px;
+}
+.feedback-primary .feedback-row:last-child {
+ border-bottom: 1px solid rgba(255,255,255,.04);
+}
+.feedback-channel:not(.collapsed) .feedback-primary .feedback-row:last-child {
+ border-bottom: 1px solid rgba(255,255,255,.04);
+}
+.feedback-detail {
+ margin-top: 2px;
+ padding-top: 2px;
+ border-top: 1px dashed rgba(255,255,255,.06);
+}
+.ch-dot {
+ width: 10px; height: 10px; border-radius: 50%; display: inline-block;
+}
+.ch-dot.ch0 { background: #22d3ee; }
+.ch-dot.ch1 { background: #a78bfa; }
+.ch-dot.ch2 { background: #f59e0b; }
+.feedback-row {
+ display: flex; justify-content: space-between; padding: 3px 0;
+ font-size: 13px; border-bottom: 1px solid rgba(255,255,255,.04);
+}
+.feedback-row:last-child { border-bottom: none; }
+.fb-label { color: var(--muted, #8892a4); }
+.fb-val { font-weight: 600; font-variant-numeric: tabular-nums; cursor: pointer; border-radius: 4px; padding: 1px 4px; margin: -1px -4px; transition: background .15s; }
+.fb-val:hover { background: rgba(255,255,255,.08); }
+.fb-val:active { background: rgba(255,255,255,.14); }
+.fb-val.copied { background: rgba(34,197,94,.18); color: #86efac; }
+.fb-val.mono { font-family: monospace; }
+.fb-val.ok { color: #34d399; }
+.fb-val.warn { color: #f59e0b; }
+.fb-val.err { color: #f87171; }
+.fb-val.zero { color: #64748b; }
+@media (max-width: 900px) {
+ .feedback-grid { grid-template-columns: 1fr; }
+}
+
+/* === ID SNAPSHOT TABLE === */
+.idsnap-table-wrap { overflow-x: auto; }
+.idsnap-table {
+ width: 100%; border-collapse: collapse; font-size: 13px;
+ font-variant-numeric: tabular-nums;
+}
+.idsnap-table th {
+ text-align: left; padding: 6px 10px; font-weight: 600;
+ border-bottom: 2px solid rgba(255,255,255,.12); color: var(--muted, #8892a4);
+ white-space: nowrap;
+}
+.idsnap-table td {
+ padding: 5px 10px; border-bottom: 1px solid rgba(255,255,255,.05);
+ white-space: nowrap;
+}
+.idsnap-table td.mono { font-family: monospace; }
+.idsnap-table tr:hover td { background: rgba(255,255,255,.03); }
+.idsnap-table .flag-on { color: #34d399; font-weight: 600; }
+.idsnap-table .flag-off { color: #475569; }
+.idsnap-data { font-family: monospace; max-width: 320px; overflow: hidden; text-overflow: ellipsis; }
+
+.fb-data-cell {
+ max-width: 200px; overflow: hidden; text-overflow: ellipsis;
+ font-size: 11px; word-break: break-all; line-height: 1.4;
+}
+
+/* === CAN message row expand / batch additions === */
+.message-col-check,
+.message-col-toggle {
+ text-align: center;
+ padding: 6px 2px;
+ /* 勿被全局单元格 overflow:hidden 裁切勾选/展开 */
+ overflow: visible !important;
+ text-overflow: clip;
+}
+/* 统一:报文行 / 全选 / 节点过滤 勾选框外观 */
+.can-page input[type="checkbox"],
+.message-check,
+.checkbox-inline input[type="checkbox"],
+.can-node-filter-item input[type="checkbox"] {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 15px;
+ height: 15px;
+ margin: 0;
+ padding: 0;
+ flex-shrink: 0;
+ box-sizing: border-box;
+ border: 1.5px solid rgba(148, 163, 184, 0.55);
+ border-radius: 4px;
+ background: rgba(15, 23, 42, 0.9);
+ cursor: pointer;
+ vertical-align: middle;
+ position: relative;
+ display: inline-block;
+ transition: border-color .12s ease, background .12s ease, box-shadow .12s ease;
+}
+.can-page input[type="checkbox"]:hover,
+.message-check:hover,
+.checkbox-inline input[type="checkbox"]:hover,
+.can-node-filter-item input[type="checkbox"]:hover {
+ border-color: rgba(34, 211, 238, 0.65);
+}
+.can-page input[type="checkbox"]:checked,
+.message-check:checked,
+.checkbox-inline input[type="checkbox"]:checked,
+.can-node-filter-item input[type="checkbox"]:checked {
+ background: var(--accent, #22d3ee);
+ border-color: var(--accent, #22d3ee);
+ box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.18);
+}
+.can-page input[type="checkbox"]:checked::after,
+.message-check:checked::after,
+.checkbox-inline input[type="checkbox"]:checked::after,
+.can-node-filter-item input[type="checkbox"]:checked::after {
+ content: "";
+ position: absolute;
+ left: 4px;
+ top: 1px;
+ width: 4px;
+ height: 8px;
+ border: solid #0b1220;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+}
+.can-page input[type="checkbox"]:focus-visible,
+.message-check:focus-visible,
+.checkbox-inline input[type="checkbox"]:focus-visible,
+.can-node-filter-item input[type="checkbox"]:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.28);
+}
+.message-toggle {
+ width: 26px;
+ height: 26px;
+ min-width: 26px;
+ border-radius: 6px;
+ border: 1px solid rgba(255,255,255,.14);
+ background: rgba(255,255,255,.04);
+ color: #c8d6e7;
+ cursor: pointer;
+ font-size: 12px;
+ line-height: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ box-sizing: border-box;
+ transition: background .12s ease, border-color .12s ease, color .12s ease;
+}
+.message-toggle:hover {
+ background: rgba(34,211,238,.12);
+ border-color: rgba(34,211,238,.4);
+ color: #e6fbff;
+}
+.message-toggle.expanded {
+ background: rgba(34,211,238,.18);
+ border-color: rgba(34,211,238,.55);
+ color: #e6fbff;
+}
+
+.message-row-expand {
+ grid-column: 1 / -1;
+ background: rgba(255,255,255,.02);
+ border-top: 1px dashed rgba(255,255,255,.06);
+ border-bottom: 1px solid rgba(255,255,255,.04);
+ padding: 10px 12px 12px;
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+ /* 禁止展开区出现横向滑条;内容省略/折行 */
+ overflow-x: hidden;
+}
+.message-row-expand[hidden] {
+ display: none;
+}
+.message-row-expand .signal-table-host {
+ margin-top: 0;
+ min-width: 0;
+ max-width: 100%;
+ overflow-x: hidden;
+}
+.message-row-expand .signal-table {
+ border-radius: 10px;
+ min-width: 0 !important;
+ max-width: 100%;
+}
+.message-row-expand .signal-table-body {
+ max-height: 360px;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+.message-row-expand .signal-cell {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+.message-row-expand .signal-input,
+.message-row-expand .signal-input-compact,
+.message-row-expand select.input,
+.message-row-expand input.input {
+ width: 100%;
+ min-width: 0 !important;
+ max-width: 100%;
+ box-sizing: border-box;
+}
+
+.can-batch-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ margin-left: auto;
+ flex-wrap: wrap;
+}
+.checkbox-inline {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--text-dim, #93a4bf);
+ cursor: pointer;
+ user-select: none;
+}
+.checkbox-inline input {
+ cursor: pointer;
+}
+.card-head-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+.btn.btn-sm {
+ padding: 4px 10px;
+ font-size: 12px;
+}
+.btn[disabled] {
+ opacity: .55;
+ cursor: not-allowed;
+}
+
+/* === 2026-06-30c: 报文列表与右侧操作栏并列布局调整 === */
+/* 左列:报文列表 + 最近发送;右列:帧预览等 */
+.can-editor-layout > .can-main-col {
+ margin-top: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ min-width: 0;
+ max-width: 100%;
+}
+.can-main-col > .can-table-card {
+ margin-top: 0;
+ min-width: 0;
+ max-width: 100%;
+ /* 保持 visible,避免裁切「发送节点」下拉;横向由内部 message-list 锁死 */
+ overflow: visible;
+}
+.can-main-col > .can-history-card {
+ margin-top: 0;
+ align-self: stretch;
+ min-width: 0;
+ max-width: 100%;
+ overflow-x: hidden;
+}
+.can-table-card-body,
+.can-msg-list-head,
+.can-msg-toolbar {
+ min-width: 0;
+ max-width: 100%;
+}
+.can-frame-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 10px;
+ flex-wrap: wrap;
+}
+.can-frame-actions .btn { flex: 1 1 auto; min-width: 120px; }
+.can-frame-panel-compact .can-frame-hex {
+ word-break: break-all;
+}
+
+
+/* === 2026-06-30c: 报文列表压缩列宽 + 纵向滚动美化 === */
+.message-list {
+ /* 提高可视高度,并改用美化的纵向滚动条 */
+ max-height: calc(100vh - 300px);
+ min-height: 420px;
+ overflow-x: hidden !important;
+ overflow-y: auto;
+ overscroll-behavior-x: none;
+ min-width: 0;
+ width: 100%;
+ max-width: 100%;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(34,211,238,.35) transparent;
+}
+.message-list::-webkit-scrollbar { width: 8px; }
+.message-list::-webkit-scrollbar-track {
+ background: transparent;
+ margin: 4px 0;
+}
+.message-list::-webkit-scrollbar-thumb {
+ background: linear-gradient(180deg, rgba(34,211,238,.45), rgba(34,211,238,.18));
+ border-radius: 6px;
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+.message-list::-webkit-scrollbar-thumb:hover {
+ background: linear-gradient(180deg, rgba(34,211,238,.65), rgba(34,211,238,.28));
+ background-clip: padding-box;
+ border: 2px solid transparent;
+}
+
+.msg-src {
+ display: inline-block;
+ max-width: 4.5em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ vertical-align: bottom;
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--accent);
+ opacity: .85;
+ margin-right: 2px;
+}
+/* 单元格统一省略号;切断 grid 项默认 min-content 最小宽 */
+.message-row .message-col,
+.message-table-head .message-col {
+ min-width: 0 !important;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ box-sizing: border-box;
+}
+.message-col {
+ padding: 8px 6px;
+}
+/* 紧凑列的内边距收一点,腾出空间给名称/注释 */
+.message-col-index,
+.message-col-channel,
+.message-col-type,
+.message-col-dlc,
+.message-col-signal {
+ padding-left: 6px;
+ padding-right: 6px;
+}
+.message-col-check,
+.message-col-toggle {
+ padding-left: 2px;
+ padding-right: 2px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+/* 注释列允许浅色降权显示 */
+.message-row .message-col-comment {
+ color: var(--muted);
+}
+
+/* === 窄屏报文表降级(0fr 隐藏列,保持 track 数量) === */
+/* 列序:check toggle # dbc name id channel type dlc signal node comment */
+/* 隐藏列必须把 padding/border/内容贡献压到 0,否则仍会撑出横向滚动 */
+.message-col-channel,
+.message-col-type {
+ padding: 0 !important;
+ border: 0 !important;
+ margin: 0 !important;
+ overflow: hidden !important;
+ min-width: 0 !important;
+ max-width: 0 !important;
+ font-size: 0 !important;
+ line-height: 0 !important;
+ visibility: hidden;
+ pointer-events: none;
+}
+@media (max-width: 1200px) {
+ .message-table-head,
+ .message-row {
+ grid-template-columns:
+ 34px 34px 28px
+ minmax(0, 0.5fr) /* dbc */
+ minmax(0, 1fr) /* name */
+ minmax(6.8em, 0.6fr)/* id */
+ 0fr 0fr
+ 2.6em /* dlc */
+ 0fr /* signal */
+ minmax(5em, 0.7fr) /* node */
+ minmax(0, 0.55fr); /* comment */
+ }
+ .message-col-signal {
+ padding: 0 !important;
+ border: 0 !important;
+ overflow: hidden !important;
+ min-width: 0 !important;
+ max-width: 0 !important;
+ font-size: 0 !important;
+ visibility: hidden;
+ pointer-events: none;
+ }
+}
+@media (max-width: 1024px) {
+ .message-table-head,
+ .message-row {
+ grid-template-columns:
+ 34px 34px 26px
+ minmax(0, 0.5fr)
+ minmax(0, 1fr)
+ minmax(6.5em, 0.55fr)
+ 0fr 0fr
+ 2.6em
+ 0fr
+ 0fr /* node */
+ minmax(0, 0.55fr);
+ }
+ .message-col-node {
+ padding: 0 !important;
+ border: 0 !important;
+ overflow: hidden !important;
+ min-width: 0 !important;
+ max-width: 0 !important;
+ font-size: 0 !important;
+ visibility: hidden;
+ pointer-events: none;
+ }
+}
+@media (max-width: 900px) {
+ .message-table-head,
+ .message-row {
+ grid-template-columns:
+ 34px 34px 26px
+ 0fr /* dbc */
+ minmax(0, 1fr)
+ minmax(6.2em, 0.55fr)
+ 0fr 0fr
+ 2.6em
+ 0fr 0fr
+ minmax(0, 0.5fr);
+ }
+ .message-col-dbc {
+ padding: 0 !important;
+ border: 0 !important;
+ overflow: hidden !important;
+ min-width: 0 !important;
+ max-width: 0 !important;
+ font-size: 0 !important;
+ visibility: hidden;
+ pointer-events: none;
+ }
+}
+
+
+/* 工作台单独一行 */
+.can-top-layout {
+ margin-top: 18px;
+ display: block;
+}
+.can-top-layout > .can-toolbar-card {
+ margin-top: 0;
+}
+/* 历史卡:报文列表下方,单行紧凑约 8~10 条可见 */
+.can-history-card .card-body {
+ display: flex;
+ flex-direction: column;
+ padding-top: 10px;
+ padding-bottom: 10px;
+}
+.can-history-card .history-list {
+ max-height: min(42vh, 420px);
+ overflow-y: auto;
+ padding-right: 4px;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(34,211,238,.35) transparent;
+}
+.can-history-card .history-list::-webkit-scrollbar { width: 8px; }
+.can-history-card .history-list::-webkit-scrollbar-track { background: transparent; margin: 4px 0; }
+.can-history-card .history-list::-webkit-scrollbar-thumb {
+ background: linear-gradient(180deg, rgba(34,211,238,.45), rgba(34,211,238,.18));
+ border-radius: 6px;
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+.can-history-card .history-list::-webkit-scrollbar-thumb:hover {
+ background: linear-gradient(180deg, rgba(34,211,238,.65), rgba(34,211,238,.28));
+ background-clip: padding-box;
+}
+
+
+/* === 2026-07-01a: 发送节点过滤下拉 === */
+.can-node-filter {
+ position: relative;
+ display: inline-flex;
+ z-index: 1;
+}
+/* 面板打开时整块提到最前 */
+.can-node-filter.is-open {
+ z-index: 50;
+}
+.can-node-filter-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+}
+.can-node-filter-btn.active {
+ border-color: rgba(34,211,238,.65);
+ color: var(--accent);
+ background: rgba(34,211,238,.08);
+}
+.can-node-filter-caret {
+ font-size: 10px;
+ opacity: .75;
+}
+.can-node-filter-panel {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ z-index: 60;
+ min-width: 260px;
+ max-width: 320px;
+ background: #182330;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ box-shadow: 0 18px 38px rgba(0,0,0,.45);
+ padding: 10px;
+}
+.can-node-filter-panel[hidden] { display: none; }
+.can-node-filter-head {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+.can-node-filter-search {
+ width: 100%;
+ padding: 6px 8px;
+ font-size: 12px;
+}
+.can-node-filter-actions {
+ display: flex;
+ gap: 6px;
+ justify-content: flex-end;
+}
+.can-node-filter-list {
+ max-height: 260px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(34,211,238,.35) transparent;
+}
+.can-node-filter-list::-webkit-scrollbar { width: 8px; }
+.can-node-filter-list::-webkit-scrollbar-thumb {
+ background: rgba(34,211,238,.35);
+ border-radius: 6px;
+}
+.can-node-filter-item {
+ display: grid;
+ grid-template-columns: 18px 1fr auto;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--text);
+ transition: background .12s;
+}
+.can-node-filter-item:hover {
+ background: rgba(255,255,255,.04);
+}
+.can-node-filter-item input[type="checkbox"] {
+ margin: 0;
+}
+.can-node-filter-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--font-mono);
+}
+.can-node-filter-count {
+ font-size: 11px;
+ color: var(--muted);
+ background: rgba(255,255,255,.05);
+ border-radius: 999px;
+ padding: 1px 8px;
+ min-width: 26px;
+ text-align: center;
+}
diff --git a/css/components.css b/css/components.css
new file mode 100644
index 0000000..c913767
--- /dev/null
+++ b/css/components.css
@@ -0,0 +1,944 @@
+/* ============================================================
+ components.css — 上传 / 文件 / 进度 / 指标 / 徽章 / 阶段流 /
+ 堆概况 / 任务栈 / 日志 / Toast / Tooltip 等组件
+ ============================================================ */
+
+/* ================= 上传区 ================= */
+.upload-zone {
+ border: 2px dashed var(--border-strong); border-radius: var(--radius);
+ padding: 30px 16px; text-align: center; cursor: pointer;
+ background: rgba(255,255,255,.015);
+ transition: border-color .22s, background .22s, transform .22s, box-shadow .22s;
+}
+.upload-zone:hover, .upload-zone.dragover {
+ border-color: var(--esp32);
+ background: var(--esp32-dim);
+ box-shadow: 0 0 0 4px rgba(47,216,193,.07);
+}
+.upload-zone .upload-ico { font-size: 32px; margin-bottom: 10px; }
+.upload-zone h4 {
+ font-family: var(--font-display);
+ font-size: 14px; font-weight: 600; margin-bottom: 4px; letter-spacing: .02em;
+}
+.upload-zone p { font-size: 12px; color: var(--text-dim); }
+.upload-zone-sm { padding: 18px 12px; }
+.upload-zone-sm .upload-ico { font-size: 24px; margin-bottom: 6px; }
+.upload-zone-sm h4 { font-size: 13px; }
+
+/* ================= 文件信息 ================= */
+.file-info {
+ display: none; margin-top: 12px;
+ background: var(--bg-elevated); border: 1px solid var(--border);
+ border-radius: var(--radius-sm); padding: 13px 15px;
+}
+.file-info.show { display: block; }
+.file-info-row { display: flex; justify-content: space-between; align-items: center; }
+.file-info-row + .file-info-row { margin-top: 10px; }
+.file-info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 10px; }
+.file-meta-item {
+ background: var(--bg-card); border: 1px solid var(--border-subtle);
+ padding: 8px 11px; border-radius: 7px; font-size: 12px;
+}
+.file-meta-item span {
+ color: var(--text-label); display: block;
+ font-family: var(--font-display);
+ font-size: 10px; letter-spacing: .1em; text-transform: uppercase; margin-bottom: 3px;
+}
+.file-meta-item b { font-weight: 600; font-family: var(--font-mono); }
+
+/* ================= 阶段切换胶囊(ESP32 OTA / GD32 OTA)================= */
+.progress-phase-row {
+ display: flex; align-items: center; gap: 4px; margin-bottom: 15px;
+ background: var(--bg-input); border: 1px solid var(--border);
+ border-radius: 999px; padding: 4px;
+}
+.pp-step {
+ flex: 1;
+ display: flex; align-items: center; justify-content: center; gap: 6px;
+ padding: 8px 10px; border-radius: 999px;
+ font-family: var(--font-display);
+ font-size: 12px; font-weight: 600; letter-spacing: .03em;
+ color: var(--text-dim); background: transparent;
+ transition: all .25s ease; white-space: nowrap; cursor: default;
+}
+.pp-ico { font-size: 13px; opacity: .45; transition: opacity .25s ease; }
+.pp-step.active {
+ color: #04231e; background: linear-gradient(135deg, var(--esp32), #14b8a6);
+ box-shadow: 0 2px 12px rgba(47,216,193,.35);
+}
+.pp-step.active .pp-ico { opacity: 1; }
+.pp-step.done { color: var(--can-hi); background: rgba(61,220,142,.13); }
+.pp-step.done .pp-ico { opacity: 1; }
+.pp-step.done .pp-txt::after { content: ' ✓'; font-size: 10px; }
+
+/* ================= 进度条 ================= */
+.progress-wrap { margin-top: 0; display: none; }
+.progress-wrap.show { display: block; }
+.progress-track {
+ width: 100%; height: 11px; border-radius: 999px;
+ background: var(--bg-input); overflow: hidden;
+ border: 1px solid var(--border-subtle);
+ box-shadow: inset 0 1px 3px rgba(0,0,0,.4);
+}
+.progress-fill {
+ height: 100%; border-radius: 999px;
+ background: linear-gradient(90deg, var(--esp32), #14b8a6);
+ width: 0%; transition: width .3s ease;
+ position: relative;
+ box-shadow: 0 0 10px rgba(47,216,193,.4);
+}
+.progress-fill::after {
+ content: ""; position: absolute; inset: 0;
+ background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.35) 50%, transparent 100%);
+ animation: sweep 1.6s linear infinite;
+}
+.progress-fill.done {
+ background: linear-gradient(90deg, var(--green), #16a374);
+ box-shadow: 0 0 10px rgba(61,220,142,.4);
+}
+@keyframes sweep { 0% { transform: translateX(-100%);} 100% { transform: translateX(100%);} }
+.progress-text {
+ display: flex; justify-content: space-between; margin-top: 8px;
+ font-size: 12px; color: var(--text-dim);
+}
+.progress-text #progressPct {
+ font-family: var(--font-display); font-weight: 700;
+ color: var(--esp32-hi); font-variant-numeric: tabular-nums;
+}
+
+/* ================= 按钮行 ================= */
+.btn-row { display: flex; gap: 10px; margin-top: 16px; }
+.btn-row .btn { flex: 1; }
+
+/* ================= 运行指标 ================= */
+.metric-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
+.metric {
+ position: relative;
+ background: var(--bg-elevated); border: 1px solid var(--border);
+ border-radius: var(--radius-sm); padding: 13px 15px;
+ overflow: hidden;
+ transition: border-color .22s, transform .22s, box-shadow .22s;
+}
+.metric::before {
+ content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
+ background: var(--metric-accent, var(--esp32));
+ opacity: .55;
+}
+.metric:nth-child(1) { --metric-accent: var(--esp32); } /* ESP32 温度 */
+.metric:nth-child(2) { --metric-accent: var(--gd32); } /* GD32 温度 */
+.metric:nth-child(3) { --metric-accent: var(--ble); } /* 输入 VCC */
+.metric:nth-child(4) { --metric-accent: var(--mem); } /* VDDA */
+.metric:nth-child(5) { --metric-accent: var(--gd32); } /* GD32 APP */
+.metric:nth-child(6) { --metric-accent: var(--can); } /* GD32 BOOT */
+.metric:hover {
+ border-color: color-mix(in srgb, var(--metric-accent, var(--esp32)) 45%, var(--border));
+ transform: translateY(-2px);
+ box-shadow: 0 8px 20px rgba(3,7,16,.4);
+}
+.metric .k {
+ font-family: var(--font);
+ font-size: 11px; font-weight: 500; color: var(--text-label);
+ letter-spacing: .04em; margin-bottom: 6px;
+}
+.metric .v {
+ font-family: var(--font);
+ font-size: 20px; font-weight: 700; line-height: 1.2;
+ word-break: break-word;
+ font-variant-numeric: tabular-nums;
+}
+.metric .v.highlight, .metric .v.accent { color: var(--esp32-hi); }
+.metric .v.ok { color: var(--green); }
+.metric .v.warn { color: var(--amber); }
+.metric .v.err { color: var(--red); }
+
+.status-kv-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
+.status-kv {
+ background: var(--bg-elevated); border: 1px solid var(--border);
+ border-radius: var(--radius-sm); padding: 10px 13px;
+}
+.status-kv .k {
+ display: block; font-family: var(--font-display);
+ font-size: 10px; color: var(--text-label);
+ text-transform: uppercase; letter-spacing: .1em; margin-bottom: 4px;
+}
+.status-kv b {
+ display: block; color: var(--text); font-size: 13px; font-weight: 700;
+ word-break: break-word; font-family: var(--font-mono);
+}
+
+/* ================= 主状态徽章 ================= */
+.badge-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
+.badge-stat {
+ background: var(--bg-elevated); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 16px 16px 13px; text-align: center;
+ transition: border-color .22s, transform .22s, box-shadow .22s;
+}
+.badge-stat:hover {
+ border-color: var(--border-strong); transform: translateY(-2px);
+ box-shadow: 0 8px 20px rgba(3,7,16,.4);
+}
+.badge-stat .v {
+ font-family: var(--font-display);
+ font-size: 23px; font-weight: 700; letter-spacing: .01em;
+ font-variant-numeric: tabular-nums;
+}
+.badge-stat .v.accent { color: var(--esp32-hi); }
+.badge-stat .v.ok { color: var(--green); text-shadow: 0 0 14px rgba(61,220,142,.35); }
+.badge-stat .v.warn { color: var(--amber); }
+.badge-stat .v.err { color: var(--red); }
+.badge-stat .v.bad { color: var(--red); }
+.badge-stat .k {
+ font-family: var(--font-display);
+ font-size: 10px; color: var(--text-label);
+ text-transform: uppercase; letter-spacing: .13em; margin-top: 4px;
+}
+
+/* ---- 健康状态 → 启动自检 ---- */
+.badge-stat-click { cursor: pointer; user-select: none; }
+.badge-stat-click:hover, .badge-stat-click:focus-visible {
+ outline: none;
+ border-color: color-mix(in srgb, var(--esp32) 45%, var(--border));
+ background: color-mix(in srgb, var(--esp32) 7%, var(--bg-elevated));
+}
+.badge-stat-click.is-open {
+ border-color: color-mix(in srgb, var(--esp32) 50%, var(--border));
+}
+.health-boot-hint {
+ font-size: 9px; letter-spacing: 0; text-transform: none;
+ color: var(--text-dim); font-weight: 500; margin-left: 3px;
+}
+.badge-stat-click.is-open .health-boot-hint { color: var(--esp32-hi); }
+.health-boot-panel {
+ margin-top: 12px; padding: 13px 15px;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--border); background: var(--bg-elevated);
+}
+.health-boot-panel.collapsed, .health-boot-panel[hidden] { display: none; }
+.health-boot-head {
+ display: flex; align-items: baseline; justify-content: space-between;
+ gap: 10px; margin-bottom: 7px;
+}
+.health-boot-title {
+ font-family: var(--font-display);
+ font-size: 11px; color: var(--text-label);
+ text-transform: uppercase; letter-spacing: .1em; font-weight: 600;
+}
+.health-boot-summary { font-family: var(--font-display); font-size: 14px; font-weight: 700; color: var(--text); text-align: right; }
+.health-boot-summary.ok { color: var(--green); }
+.health-boot-summary.warn{ color: var(--amber); }
+.health-boot-summary.err { color: var(--red); }
+.health-boot-failed { font-size: 12px; color: var(--text-dim); margin-bottom: 9px; }
+.health-boot-failed b { color: var(--text); font-weight: 600; }
+.health-boot-failed b.ok { color: var(--green); }
+.health-boot-failed b.warn{ color: var(--amber); }
+.health-boot-failed b.err { color: var(--red); }
+
+/* ---- 启动模块芯片 ---- */
+.boot-module-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; min-height: 8px; }
+.boot-mod {
+ display: inline-flex; align-items: center; gap: 6px;
+ padding: 4px 9px; border-radius: 999px;
+ border: 1px solid var(--border); background: var(--bg-root);
+ font-family: var(--font-mono);
+ font-size: 11px; font-weight: 600; color: var(--text-dim); line-height: 1.2;
+}
+.boot-mod .boot-mod-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-dim); flex-shrink: 0; }
+.boot-mod.ok { color: var(--can-hi); border-color: rgba(61,220,142,.4); background: rgba(61,220,142,.08); }
+.boot-mod.ok .boot-mod-dot { background: var(--green); }
+.boot-mod.fail { color: #ff9d9d; border-color: rgba(255,107,107,.4); background: rgba(255,107,107,.08); }
+.boot-mod.fail .boot-mod-dot { background: var(--red); }
+.boot-mod.fail.optional { color: var(--gd32-hi); border-color: rgba(252,191,63,.4); background: rgba(252,191,63,.08); }
+.boot-mod.fail.optional .boot-mod-dot { background: var(--amber); }
+
+.status-section-title {
+ margin-top: 15px; margin-bottom: 9px;
+ font-family: var(--font-display);
+ font-size: 11px; color: var(--text-label);
+ text-transform: uppercase; letter-spacing: .12em; font-weight: 600;
+}
+
+/* ================= 堆概况 / CPU / 任务栈 ================= */
+.heap-group {
+ background: rgba(255,255,255,.02);
+ border: 1px solid var(--border); border-radius: 9px;
+ padding: 13px 15px; margin-bottom: 10px;
+}
+.heap-group:last-child { margin-bottom: 0; }
+.cpu-group { margin-bottom: 8px; }
+.cpu-task-list { margin-bottom: 12px; }
+.cpu-task-list .status-task { background: rgba(255,255,255,.025); }
+.cpu-fill { background: linear-gradient(90deg, var(--ble), var(--esp32)); }
+
+.heap-group-title {
+ font-family: var(--font-display);
+ font-size: 11px; font-weight: 600; color: var(--text-label);
+ text-transform: uppercase; letter-spacing: .08em; margin-bottom: 10px;
+}
+.heap-bar-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
+.heap-bar-text {
+ order: 1; flex: 1 1 auto; min-width: 0;
+ font-family: var(--font-display);
+ font-size: 13px; font-weight: 600; white-space: nowrap; color: var(--text);
+}
+.heap-bar-pct {
+ order: 2; flex: 0 0 auto;
+ font-family: var(--font-mono);
+ font-size: 12px; font-weight: 600; min-width: 44px; text-align: right;
+ font-variant-numeric: tabular-nums; color: var(--text-dim);
+}
+.heap-bar-track {
+ order: 3; flex: 1 1 100%; width: 100%; height: 14px;
+ background: rgba(255,255,255,.06); border-radius: 7px;
+ overflow: hidden; position: relative;
+}
+.heap-bar-fill {
+ display: block; height: 100%; border-radius: 7px;
+ background: linear-gradient(90deg, var(--esp32), #5eead4);
+ transition: width .5s cubic-bezier(.4,0,.2,1);
+}
+.heap-bar-fill.warn { background: linear-gradient(90deg, var(--amber), #d97706); }
+.heap-bar-fill.danger { background: linear-gradient(90deg, var(--red), #dc2626); }
+
+.heap-detail-row {
+ display: flex; gap: 16px; flex-wrap: wrap;
+ font-size: 11px; color: var(--text-dim);
+ margin-top: 7px; padding-top: 7px;
+ border-top: 1px solid rgba(255,255,255,.05);
+}
+.heap-detail-row span { display: inline-flex; align-items: center; gap: 4px; }
+.heap-detail-row b { color: var(--text); font-weight: 500; font-family: var(--font-mono); font-size: 11px; }
+
+/* ---- SRAM 分段条 ---- */
+.mem-sram-track { display: flex; overflow: hidden; gap: 0; background: rgba(255,255,255,.04); }
+.mem-seg { display: block; height: 100%; min-width: 0; transition: width .5s ease; }
+.mem-seg-iram { background: linear-gradient(90deg, var(--mem), #7c5ce0); }
+.mem-seg-data { background: linear-gradient(90deg, #fb923c, #ea580c); }
+.mem-seg-bss { background: linear-gradient(90deg, #94a3b8, #64748b); }
+.mem-seg-static { background: linear-gradient(90deg, #64748b, #475569); }
+.mem-seg-used { background: linear-gradient(90deg, var(--esp32), #5eead4); }
+.mem-seg-free { background: rgba(255,255,255,.08); }
+.heap-legend { display: flex; flex-wrap: wrap; gap: 8px 14px; margin-top: 9px; font-size: 11px; color: var(--text-dim); }
+.heap-legend-item { display: inline-flex; align-items: center; gap: 5px; }
+.heap-legend-item b { color: var(--text); font-weight: 500; font-family: var(--font-mono); font-size: 11px; }
+.mem-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; flex: 0 0 auto; }
+.mem-dot-iram { background: var(--mem); }
+.mem-dot-data { background: #f97316; }
+.mem-dot-bss { background: #64748b; }
+.mem-dot-static { background: #64748b; }
+.mem-dot-used { background: var(--esp32); }
+.mem-dot-free { background: rgba(255,255,255,.18); border: 1px solid rgba(255,255,255,.22); box-sizing: border-box; }
+.mem-static-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: var(--text-dim); }
+.mem-static-row {
+ display: grid; grid-template-columns: 1fr auto auto; gap: 8px; align-items: center;
+ padding: 3px 0; border-bottom: 1px solid rgba(255,255,255,.04);
+}
+.mem-static-row:last-child { border-bottom: 0; }
+.mem-static-name { color: var(--text-label); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.mem-static-size { font-family: var(--font-mono); color: var(--text); font-variant-numeric: tabular-nums; }
+.mem-static-pct { font-family: var(--font-mono); min-width: 42px; text-align: right; color: var(--text-dim); }
+.heap-hint {
+ margin-top: 8px; padding-top: 6px;
+ border-top: 1px solid rgba(255,255,255,.05);
+ font-size: 10px; line-height: 1.5; color: var(--text-dim); opacity: .9;
+}
+
+/* ---- 任务列表 ---- */
+.status-task-list { display: grid; gap: 8px; }
+.status-task-list.list-scroll-top3 {
+ --status-task-row-est: 6.9rem;
+ max-height: calc(3 * var(--status-task-row-est) + 2 * 8px);
+ overflow-y: auto; overflow-x: hidden;
+ padding-right: 4px; scrollbar-gutter: stable; overscroll-behavior: contain;
+}
+.status-task-list.list-scroll-top3::-webkit-scrollbar { width: 6px; }
+.status-task-list.list-scroll-top3::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 999px; }
+.status-task-list.list-scroll-top3::-webkit-scrollbar-track { background: transparent; }
+.status-task {
+ background: var(--bg-elevated); border: 1px solid var(--border);
+ border-radius: var(--radius-sm); padding: 11px 13px;
+}
+.status-task-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
+.status-task-name { font-size: 12px; font-weight: 700; color: var(--text); font-family: var(--font-mono); }
+.status-task-ratio { font-size: 11px; color: var(--text-dim); font-family: var(--font-mono); }
+.status-task-bar { width: 100%; height: 8px; border-radius: 999px; background: var(--bg-input); overflow: hidden; margin-bottom: 8px; }
+.status-task-bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--esp32), #14b8a6); }
+.status-task-bar-fill.warn { background: linear-gradient(90deg, var(--amber), #d97706); }
+.status-task-bar-fill.err { background: linear-gradient(90deg, var(--red), #dc2626); }
+.status-task-meta { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
+.status-task-meta span {
+ display: block; font-family: var(--font-display);
+ font-size: 10px; color: var(--text-label);
+ text-transform: uppercase; letter-spacing: .07em;
+}
+.status-task-meta b { display: block; margin-top: 2px; color: var(--text); font-size: 11px; font-family: var(--font-mono); }
+
+/* ---- 二级折叠 + 原始 JSON ---- */
+.status-subfold { margin-top: 15px; }
+.status-subfold-head {
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
+ cursor: pointer; user-select: none; padding: 6px 0; border-radius: var(--radius-sm);
+}
+.status-subfold-head:hover { color: var(--text); }
+.status-subfold-title { margin: 0 !important; flex: 1; }
+.status-subfold-head .collapse-arrow { flex: none; font-size: 10px; color: var(--text-dim); transition: transform .15s ease; }
+.status-subfold.collapsed .collapse-arrow { transform: rotate(-90deg); }
+.status-subfold.collapsed .status-subfold-body { display: none; }
+.status-subfold-body { margin-top: 6px; }
+.status-json {
+ margin-top: 0; padding: 12px;
+ border-radius: var(--radius-sm); border: 1px solid var(--border);
+ background: var(--bg-input); color: var(--text-dim);
+ font-size: 11px; line-height: 1.65;
+ max-height: 280px; overflow: auto;
+ white-space: pre-wrap; word-break: break-word;
+ font-family: var(--font-mono); resize: vertical; min-height: 80px;
+}
+.status-empty {
+ padding: 13px; border: 1px dashed var(--border-strong);
+ border-radius: var(--radius-sm); color: var(--text-dim); font-size: 12px;
+}
+
+/* 抽屉内强制等宽 */
+.sys-drawer-body .heap-bar-pct,
+.sys-drawer-body .heap-detail-row b,
+.sys-drawer-body .status-task-name,
+.sys-drawer-body .status-task-ratio,
+.sys-drawer-body .status-task-meta b,
+.sys-drawer-body .status-json { font-family: var(--font-mono); }
+.sys-drawer-body .status-section-title,
+.sys-drawer-body .heap-group-title,
+.sys-drawer-body .heap-bar-text { font-family: var(--font-display); color: inherit; }
+.sys-drawer-body .status-section-title { color: var(--text-label); font-size: 11px; font-weight: 600; }
+.sys-drawer-body .heap-bar-text { color: var(--text); font-size: 13px; font-weight: 600; }
+.sys-drawer-body .status-json { color: var(--text-dim); font-size: 11px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
+
+/* ================= OTA 阶段流 ================= */
+.stage-flow { display: flex; align-items: flex-start; gap: 0; padding: 10px 0 6px; }
+.stage-node { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 7px; position: relative; }
+.stage-node:not(:last-child)::after {
+ content: ""; position: absolute; top: 19px; left: calc(50% + 20px);
+ width: calc(100% - 40px); height: 2px;
+ background: var(--border); transition: background .3s;
+}
+.stage-node.done:not(:last-child)::after,
+.stage-node.live:not(:last-child)::after { background: var(--esp32); }
+.stage-dot {
+ width: 38px; height: 38px; border-radius: 50%;
+ background: var(--bg-input); border: 2px solid var(--border-strong);
+ display: flex; align-items: center; justify-content: center;
+ font-family: var(--font-display); font-size: 16px; font-weight: 700;
+ color: var(--text-label);
+ z-index: 1; transition: all .3s;
+}
+.stage-node.live .stage-dot {
+ background: linear-gradient(135deg, var(--esp32), #14b8a6);
+ border-color: var(--esp32); color: #04231e;
+ box-shadow: 0 0 16px rgba(47,216,193,.45);
+}
+.stage-node.done .stage-dot {
+ background: linear-gradient(135deg, var(--green), #16a374);
+ border-color: var(--green); color: #03231a;
+ box-shadow: 0 0 12px rgba(61,220,142,.35);
+}
+.stage-node.err .stage-dot {
+ background: linear-gradient(135deg, var(--red), #dc4c4c);
+ border-color: var(--red); color: #fff;
+ box-shadow: 0 0 12px rgba(255,107,107,.4);
+}
+.stage-label {
+ font-family: var(--font-display);
+ font-size: 11px; color: var(--text-label); text-align: center; line-height: 1.3;
+ letter-spacing: .02em;
+}
+.stage-detail {
+ font-size: 10px; color: var(--esp32-hi); text-align: center; line-height: 1.2;
+ margin-top: 2px; min-height: 14px;
+ font-family: var(--font-mono); transition: color .3s;
+}
+.stage-node.live .stage-label,
+.stage-node.done .stage-label,
+.stage-node.err .stage-label { color: var(--text); }
+.stage-node.live .stage-detail { color: var(--esp32-hi); }
+.stage-node.done .stage-detail { color: var(--green); }
+.stage-node.err .stage-detail { color: var(--red); }
+
+/* ---- 阶段状态条 ---- */
+.stage-status-bar {
+ display: flex; align-items: center; gap: 10px;
+ margin-top: 15px; padding: 11px 17px;
+ border-radius: 999px;
+ background: var(--bg-elevated); border: 1px solid var(--border);
+ transition: all .3s;
+}
+.stage-status-bar .ssb-dot {
+ width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
+ background: var(--text-dim); animation: dotPulse 2.5s ease-in-out infinite;
+}
+.stage-status-bar .ssb-text {
+ font-family: var(--font-display);
+ color: var(--text-dim); font-size: 13px; font-weight: 600; letter-spacing: .02em;
+}
+@keyframes dotPulse { 0%,100% { opacity: .45;} 50% { opacity: .85;} }
+.stage-status-bar.active {
+ border-color: rgba(47,216,193,.35);
+ background: linear-gradient(135deg, rgba(47,216,193,.09), rgba(47,216,193,.02));
+}
+.stage-status-bar.active .ssb-dot {
+ background: var(--esp32); box-shadow: 0 0 8px var(--esp32-dim);
+ animation: dotPulseLive 1.2s ease-in-out infinite;
+}
+.stage-status-bar.active .ssb-text { color: var(--esp32-hi); }
+@keyframes dotPulseLive { 0%,100% { opacity: .7; transform: scale(1);} 50% { opacity: 1; transform: scale(1.3);} }
+.stage-status-bar.ok {
+ border-color: rgba(61,220,142,.35);
+ background: linear-gradient(135deg, rgba(61,220,142,.09), rgba(61,220,142,.02));
+}
+.stage-status-bar.ok .ssb-dot { background: var(--green); box-shadow: 0 0 8px rgba(61,220,142,.4); animation: none; }
+.stage-status-bar.ok .ssb-text { color: var(--green); }
+.stage-status-bar.err {
+ border-color: rgba(255,107,107,.35);
+ background: linear-gradient(135deg, rgba(255,107,107,.09), rgba(255,107,107,.02));
+}
+.stage-status-bar.err .ssb-dot { background: var(--red); box-shadow: 0 0 8px rgba(255,107,107,.4); animation: none; }
+.stage-status-bar.err .ssb-text { color: var(--red); }
+
+/* ---- 校验信息面板 ---- */
+.stage-verify-info {
+ margin-top: 12px; padding: 13px 15px;
+ border-radius: var(--radius-sm);
+ background: var(--bg-elevated); border: 1px solid var(--border);
+}
+.svi-row {
+ display: flex; align-items: flex-start; gap: 12px;
+ padding: 7px 0; font-size: 12px;
+ border-bottom: 1px solid rgba(255,255,255,.04);
+}
+.svi-row:last-child { border-bottom: none; }
+.svi-key {
+ color: var(--text-label); font-weight: 600;
+ font-family: var(--font-display);
+ text-transform: uppercase; letter-spacing: .07em; font-size: 10px;
+ min-width: 104px; flex-shrink: 0; padding-top: 2px;
+}
+.svi-val { color: var(--text-dim); font-family: var(--font-mono); font-size: 11px; word-break: break-all; }
+.svi-val.ok { color: var(--green); font-weight: 600; }
+.svi-val.err { color: var(--red); font-weight: 600; }
+
+/* ---- 阶段结果面板 ---- */
+.stage-result {
+ margin-top: 12px; padding: 15px 17px;
+ border-radius: var(--radius-sm);
+ background: var(--bg-elevated); border: 1px solid var(--border);
+ display: flex; flex-direction: column; gap: 10px;
+}
+.stage-result.ok { border-color: rgba(61,220,142,.5); background: rgba(61,220,142,.08); }
+.stage-result.err { border-color: rgba(255,107,107,.5); background: rgba(255,107,107,.08); }
+.stage-result .sr-head { display: flex; align-items: center; gap: 10px; }
+.stage-result .sr-ico {
+ width: 32px; height: 32px; border-radius: 50%;
+ display: inline-flex; align-items: center; justify-content: center;
+ font-size: 18px; font-weight: 700; color: #04231e;
+}
+.stage-result.ok .sr-ico { background: var(--green); }
+.stage-result.err .sr-ico { background: var(--red); color: #fff; }
+.stage-result .sr-title { font-family: var(--font-display); font-size: 16px; font-weight: 700; }
+.stage-result.ok .sr-title { color: var(--green); }
+.stage-result.err .sr-title { color: var(--red); }
+.stage-result .sr-detail { font-size: 13px; color: var(--text); line-height: 1.55; }
+.stage-result .sr-actions { display: flex; justify-content: flex-end; gap: 8px; }
+
+/* ================= 日志面板 ================= */
+.log-panel { display: flex; flex-direction: column; min-height: 380px; }
+.log-panel .card-body { flex: 1; display: flex; flex-direction: column; padding: 0; }
+.log-toolbar {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 11px 16px; border-bottom: 1px solid var(--border-subtle);
+ gap: 10px; flex-wrap: wrap;
+}
+.log-filters { display: flex; gap: 6px; }
+.log-filter {
+ padding: 5px 12px; border-radius: 999px;
+ font-family: var(--font-display);
+ font-size: 11px; font-weight: 600; letter-spacing: .03em;
+ border: 1px solid var(--border); background: transparent; color: var(--text-dim);
+ cursor: pointer; transition: all .18s;
+}
+.log-filter.active {
+ background: linear-gradient(135deg, var(--esp32), #14b8a6);
+ color: #04231e; border-color: var(--esp32);
+ box-shadow: 0 2px 10px rgba(47,216,193,.3);
+}
+.log-filter:hover:not(.active) { border-color: var(--border-strong); color: var(--text); }
+
+.log-scroll {
+ flex: 1; overflow-y: auto; padding: 12px 16px;
+ font-family: var(--font-mono); font-size: 12px; line-height: 1.7;
+ max-height: 420px; background: rgba(4,8,16,.35);
+}
+.log-scroll::-webkit-scrollbar { width: 5px; }
+.log-scroll::-webkit-scrollbar-track { background: transparent; }
+.log-scroll::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
+
+.log-line {
+ display: flex; gap: 9px; padding: 3px 0;
+ border-bottom: 1px solid rgba(255,255,255,.025);
+}
+.log-line .lt { color: var(--text-label); flex-shrink: 0; }
+.log-line .lv { font-weight: 700; min-width: 20px; flex-shrink: 0; }
+.log-line .lv.E { color: var(--red); }
+.log-line .lv.W { color: var(--amber); }
+.log-line .lv.I { color: var(--esp32); }
+.log-line .lv.D { color: var(--text-label); }
+.log-line .lg, .log-line .tag { color: var(--mem-hi); flex-shrink: 0; }
+.log-line .lm { color: var(--text); word-break: break-all; }
+.log-line .t { color: var(--text-label); flex-shrink: 0; }
+
+.log-foot {
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 9px 16px; border-top: 1px solid var(--border-subtle);
+ font-size: 11px; color: var(--text-label);
+}
+
+/* ================= Toast ================= */
+.toast {
+ position: fixed; top: 45%; left: 50%;
+ transform: translateX(-50%) translateY(30px);
+ z-index: 9999;
+ display: flex; align-items: center; gap: 11px;
+ padding: 14px 26px; border-radius: 12px;
+ font-family: var(--font-display);
+ font-size: 14px; font-weight: 600; letter-spacing: .02em;
+ color: #fff; pointer-events: none; opacity: 0;
+ transition: all .35s cubic-bezier(.4,0,.2,1);
+ box-shadow: 0 12px 40px rgba(2,5,12,.55);
+ border: 1px solid rgba(255,255,255,.12);
+}
+.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; }
+.toast.ok { background: linear-gradient(135deg, #16a374, #0e8a5f); }
+.toast.err { background: linear-gradient(135deg, #dc4c4c, #c03535); }
+.toast.warn { background: linear-gradient(135deg, #d98a06, #b87405); }
+.toast .toast-ico { font-size: 18px; }
+
+/* ================= 杂项 ================= */
+.row-2, .row-3, .row-4 { display: grid; gap: 10px; }
+.row-2 { grid-template-columns: 1fr 1fr; }
+.row-3 { grid-template-columns: repeat(3, 1fr); }
+.row-4 { grid-template-columns: repeat(4, 1fr); }
+.sub-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
+.footer-note { margin-top: 12px; font-size: 12px; line-height: 1.7; color: var(--text-dim); }
+code { color: var(--esp32-hi); font-family: var(--font-mono); }
+
+/* ================= 自定义 Tooltip(pill)================= */
+.pill[data-tooltip] { position: relative; cursor: help; }
+.pill[data-tooltip]:hover::after {
+ content: attr(data-tooltip);
+ position: absolute; top: calc(100% + 10px); left: 50%;
+ transform: translateX(-50%);
+ background: var(--bg-elevated); color: var(--text);
+ font-size: 11px; font-weight: 400; font-family: var(--font-mono);
+ padding: 6px 12px; border-radius: 7px;
+ border: 1px solid var(--border-strong);
+ box-shadow: 0 6px 20px rgba(2,5,12,.5);
+ white-space: nowrap; z-index: 1000; pointer-events: none;
+ animation: tooltipFadeIn .15s ease-out;
+}
+.pill[data-tooltip]:hover::before {
+ content: ''; position: absolute; top: calc(100% + 4px); left: 50%;
+ transform: translateX(-50%);
+ border: 6px solid transparent; border-bottom-color: var(--border-strong);
+ z-index: 1001; pointer-events: none;
+ animation: tooltipFadeIn .15s ease-out;
+}
+@keyframes tooltipFadeIn {
+ from { opacity: 0; transform: translateX(-50%) translateY(3px); }
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
+}
+
+/* ================= 堆低水位 Tooltip ================= */
+.heap-low-tooltip {
+ position: fixed; z-index: 9999;
+ min-width: 480px; max-width: 640px;
+ padding: 11px 13px 9px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-strong);
+ border-radius: 9px;
+ box-shadow: 0 8px 28px rgba(2,5,12,.6);
+ font-size: 11px; color: var(--text);
+ pointer-events: auto; visibility: hidden;
+}
+.heap-low-title { font-weight: 600; margin-bottom: 6px; color: var(--text); display: flex; align-items: center; }
+.heap-low-sub { font-weight: 400; color: var(--text-dim); margin-left: 6px; }
+.heap-low-clear {
+ margin-left: auto; margin-right: 6px; height: 22px; padding: 0 9px;
+ border: 1px solid rgba(255,107,107,.45); border-radius: 5px;
+ background: rgba(255,107,107,.12); color: #ff9d9d;
+ font-size: 11px; line-height: 20px; cursor: pointer;
+ transition: background .12s, color .12s, border-color .12s;
+}
+.heap-low-clear:hover:not(:disabled) { background: rgba(255,107,107,.22); border-color: rgba(255,107,107,.7); color: #ffcdd2; }
+.heap-low-clear:disabled { opacity: .55; cursor: wait; }
+.heap-low-close {
+ width: 22px; height: 22px; padding: 0; border: none; border-radius: 5px;
+ background: transparent; color: var(--text-dim);
+ font-size: 18px; line-height: 1; cursor: pointer;
+ transition: background .12s, color .12s;
+}
+.heap-low-close:hover { background: rgba(255,255,255,.08); color: var(--text); }
+.heap-low-table-wrap { max-height: 360px; overflow-y: auto; }
+.heap-low-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 10.5px; }
+.heap-low-section { margin-bottom: 6px; }
+.heap-low-section + .heap-low-section { margin-top: 8px; padding-top: 6px; border-top: 1px dashed rgba(255,255,255,.15); }
+.heap-low-section-title {
+ font-size: 11px; font-weight: 600; color: var(--text-dim);
+ padding: 4px 6px 2px; background: rgba(255,255,255,.03); border-radius: 3px 3px 0 0;
+}
+.heap-low-section-hist .heap-low-section-title { color: var(--gd32-hi); }
+.heap-low-section-hist .heap-low-table { opacity: .92; }
+.heap-low-table th, .heap-low-table td {
+ padding: 3px 6px; text-align: right; white-space: nowrap;
+ border-bottom: 1px solid rgba(255,255,255,.04);
+}
+.heap-low-table th { color: var(--text-dim); font-weight: 500; position: sticky; top: 0; background: var(--bg-elevated); text-align: right; }
+.ble-low-table td:nth-child(5), .ble-low-table td:nth-child(6),
+.ble-low-table td:nth-child(7), .ble-low-table td:nth-child(9) { text-align: left; }
+.ble-low-table td:first-child { color: var(--text-dim); }
+
+/* ================= BLE 连接按钮(launch 风格) =================
+ 圆形物理按键:未连接=暗淡待机;勾选=已连接,蓝光环绕 + 文字波浪动画。
+ checkbox 隐藏,由 JS setConnectionState 驱动 checked,避免异步连接失败导致状态错位。 */
+.ble-launch-wrap {
+ display: flex; flex-direction: column; align-items: center;
+ gap: 12px; padding: 6px 0 2px;
+}
+.ble-launch-btn {
+ cursor: pointer; position: relative;
+ display: flex; flex-direction: column; align-items: center;
+ margin: 0;
+}
+.ble-launch-btn input {
+ opacity: 0; width: 0; height: 0; display: none;
+}
+/* —— 三态:idle 待机 / connecting 连接中转圈 / connected 已连接变亮 —— */
+/* 默认 text3 隐藏 */
+.ble-launch-btn .back .mid .ble-dial .center .start .text3 {
+ opacity: 0;
+}
+
+/* connecting:仅光晕转圈(loading),按钮整体保持暗淡 */
+.ble-launch-btn[data-ble-state="connecting"] .back .spot-shadow,
+.ble-launch-btn[data-ble-state="connecting"] .back .mid .spot-main,
+.ble-launch-btn[data-ble-state="connecting"] .back .mid .ble-dial .spot-light {
+ opacity: 0.7;
+ animation: ble-spot-rot 1.4s linear infinite;
+}
+.ble-launch-btn[data-ble-state="connecting"] .back .mid .ble-dial .center .start .text1 {
+ opacity: 0;
+}
+.ble-launch-btn[data-ble-state="connecting"] .back .mid .ble-dial .center .start .text3 {
+ opacity: 1;
+ color: #9bb8d8;
+ animation: ble-text-pulse 1.2s ease-in-out infinite;
+}
+
+/* connected:整个按钮变亮 + 静态整圈蓝光晕(不转圈)+ 文字波浪 */
+/* spot 元素默认是横向条状(为转圈动画设计),connected 时改成整圈圆环 */
+.ble-launch-btn[data-ble-state="connected"] .back .spot-shadow {
+ transition: opacity 1.2s ease;
+ opacity: 0.9;
+ width: 106px; height: 106px;
+ border-radius: 50%;
+ filter: blur(12px) brightness(1.2);
+}
+.ble-launch-btn[data-ble-state="connected"] .back .mid .spot-main {
+ transition: opacity 1.2s ease;
+ opacity: 0.95;
+ width: 100px; height: 100px;
+ border-radius: 50%;
+ filter: blur(9px) brightness(1.6);
+}
+.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .spot-light {
+ transition: opacity 1.2s ease;
+ opacity: 0.9;
+ width: 92px; height: 92px;
+ border-radius: 50%;
+ filter: blur(8px) brightness(1.2);
+}
+.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .center .icon svg {
+ filter: brightness(1.15);
+}
+.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .center .launch {
+ color: #e9f0fa;
+}
+.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .center .launch > div {
+ animation: ble-wave-text 1.8s calc(var(--i) * -0.13s) infinite;
+}
+.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .center .start .text1 {
+ transition: transform 0.5s ease, opacity 0.5s ease;
+ transform: translateX(10px);
+ opacity: 0;
+}
+.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .center .start .text2 {
+ opacity: 1;
+ animation: ble-text2 0.5s forwards;
+}
+.ble-launch-btn:active .back .mid .ble-dial {
+ transform: scale(0.94);
+ box-shadow:
+ 0 -1px 2px rgba(255,255,255,0.3),
+ inset 0 0 10px rgba(0,0,0,0.24),
+ inset 0 -13px 26px rgba(0,0,0,0.8);
+}
+
+/* —— 外圈金属环 —— */
+.ble-launch-btn .back {
+ position: relative; z-index: 200;
+ width: 128px; height: 128px;
+ display: flex; justify-content: center; align-items: center;
+ border-radius: 50%;
+ background-image: linear-gradient(0, #5e5e61 20%, #212123);
+ box-shadow:
+ inset 0 1px 1px rgba(255,255,255,0.3),
+ inset 0 3px 5px rgba(255,255,255,0.6),
+ inset 0 -4px 2px rgba(255,255,255,0.05),
+ inset 0 -2px 0 rgba(0,0,0,0.15),
+ inset -2px 4px 0 rgba(0,0,0,0.3),
+ inset 2px 4px 0 rgba(0,0,0,0.3),
+ 0 -7px 16px 2px rgba(255,255,255,0.18),
+ 0 4px 16px 2px rgba(0,0,0,0.5),
+ 0 2px 5px #7c7c7c;
+ cursor: pointer;
+}
+.ble-launch-btn .back .spot-shadow {
+ position: absolute;
+ width: 96px; height: 27px;
+ background: rgba(95,168,255,0.9);
+ opacity: 0;
+ filter: blur(14px) brightness(1.1);
+}
+.ble-launch-btn .back .mid {
+ overflow: hidden; position: relative;
+ width: 106px; height: 106px;
+ display: flex; justify-content: center; align-items: center;
+ border-radius: 50%;
+ background: #000;
+ box-shadow: 0 0 4px #000, 0 1px 2px rgba(255,255,255,0.8);
+}
+.ble-launch-btn .back .mid::before {
+ content: ''; position: absolute; z-index: 500;
+ width: 100%; height: 100%; border-radius: 50%;
+ background: transparent;
+ box-shadow: inset 0 -12px 18px rgba(0,0,0,0.8);
+ pointer-events: none;
+}
+.ble-launch-btn .back .mid .spot-main {
+ position: absolute;
+ width: 110px; height: 20px;
+ background: #5fa8ff;
+ opacity: 0;
+ filter: blur(11px) brightness(1.5);
+}
+.ble-launch-btn .back .mid .ble-dial {
+ position: relative; z-index: 90;
+ display: flex; justify-content: center; align-items: center;
+ width: 100px; height: 100px; border-radius: 50%;
+ background: #424242;
+ box-shadow:
+ 0 -1px 2px rgba(255,255,255,0.5),
+ inset 0 -9px 18px rgba(0,0,0,0.8);
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+.ble-launch-btn .back .mid .ble-dial .spot-light {
+ position: absolute;
+ width: 110px; height: 20px;
+ opacity: 0;
+ background: rgba(95,168,255,0.6);
+ filter: blur(14px) brightness(1.1);
+}
+.ble-launch-btn .back .mid .ble-dial .center {
+ position: relative; z-index: 100;
+ width: 86px; height: 86px;
+ display: flex; flex-direction: column;
+ align-items: center; justify-content: center; gap: 2px;
+ border-radius: 50%;
+ background: #232323;
+ cursor: pointer;
+}
+.ble-launch-btn .back .mid .ble-dial .center .icon svg {
+ margin-top: -3px;
+ width: 18px;
+ filter: brightness(1) grayscale(1);
+ transition: filter 1.5s ease;
+}
+.ble-launch-btn .back .mid .ble-dial .center .launch {
+ display: flex; margin-top: -3px;
+ font-size: 13px; color: #595959;
+ font-family: var(--font-display);
+ letter-spacing: 0.04em;
+}
+.ble-launch-btn .back .mid .ble-dial .center .start {
+ position: relative;
+ width: 100%; height: 11px;
+ font-size: 9px; color: #737373;
+ text-align: center;
+ font-family: var(--font-display);
+}
+.ble-launch-btn .back .mid .ble-dial .center .start > div {
+ position: absolute; left: 50%; top: 0;
+ transform: translateX(-50%);
+ white-space: nowrap;
+}
+.ble-launch-btn .back .mid .ble-dial .center .start .text2 {
+ opacity: 0;
+ display: inline-flex;
+}
+.ble-launch-btn .back .mid .ble-dial .center .start .text2 > span {
+ animation: ble-wave-text 1.8s calc(var(--n) * -0.13s) infinite;
+}
+.ble-launch-hint {
+ font-size: 11px; color: var(--text-dim);
+ letter-spacing: 0.04em;
+}
+
+@keyframes ble-wave-text {
+ 0% { opacity: 0.7; }
+ 50% { color: #c6e0ff; opacity: 1; }
+ 100% { opacity: 0.7; }
+}
+@keyframes ble-spot-rot {
+ 0% { transform: rotate(0); }
+ 100% { transform: rotate(360deg); }
+}
+@keyframes ble-text2 {
+ 0% { transform: translateX(-80%); }
+ 100% { transform: translateX(-50%); }
+}
+@keyframes ble-text-pulse {
+ 0%, 100% { opacity: 0.5; }
+ 50% { opacity: 1; }
+}
+.heap-low-table tbody tr:hover { background: rgba(255,255,255,.03); }
+.heap-low-table td.has-suspect { color: var(--red); font-weight: 500; }
+.heap-low-table td.idle-suspect { color: var(--gd32-hi); }
+.heap-low-detail-meta { display: flex; gap: 18px; font-size: 10px; color: var(--text-dim); margin-bottom: 6px; }
+.heap-low-detail-meta b { color: var(--text); font-family: var(--font-mono); }
+.heap-low-hook-meta {
+ margin: 8px 0 6px; padding: 6px 9px;
+ background: rgba(255,107,107,.08); border-left: 2px solid var(--red);
+ display: flex; flex-wrap: wrap; gap: 12px;
+ font-size: 10px; color: var(--text-dim);
+}
+.heap-low-hook-title { width: 100%; color: var(--red); font-weight: 600; margin-bottom: 2px; }
+.heap-low-hook-meta b { color: var(--text); font-family: var(--font-mono); }
+.heap-low-caller { color: var(--red); }
+.heap-low-caller-hint { color: var(--text-dim); font-size: 9px; }
+.heap-low-main-row { cursor: pointer; }
+.heap-low-expand-hint { font-size: 9px; color: var(--text-dim); margin-left: 4px; }
+.heap-low-stack-detail > td { padding: 6px 8px 8px; background: rgba(0,0,0,.2); }
+.heap-low-stack-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 10px; }
+.heap-low-stack-table th, .heap-low-stack-table td { padding: 2px 6px; text-align: left; border-bottom: 1px solid rgba(255,255,255,.03); }
+.heap-low-stack-table th { color: var(--text-dim); font-weight: 500; }
+.heap-low-stack-table td:last-child { text-align: right; }
+.heap-low-stack-table tr.crit td { color: var(--red); }
+.heap-low-stack-table tr.warn td { color: var(--gd32-hi); }
+.heap-low-empty { padding: 14px 6px; color: var(--text-dim); text-align: center; }
+.heap-low-tip { margin-top: 6px; font-size: 10px; color: var(--text-dim); }
diff --git a/css/layout.css b/css/layout.css
new file mode 100644
index 0000000..5fe2922
--- /dev/null
+++ b/css/layout.css
@@ -0,0 +1,337 @@
+/* ============================================================
+ layout.css — 命令栏头部 / 主布局 / 卡片 / 表单 / 按钮 / 侧滑抽屉
+ ============================================================ */
+
+/* ================= HEADER — 命令栏 ================= */
+.header-bar {
+ position: relative;
+ z-index: 20; /* 高于下方卡片,保证 pill tooltip 不被遮挡 */
+ display: flex; align-items: center; gap: 16px;
+ padding: 16px 22px; margin-bottom: 26px;
+ background:
+ radial-gradient(620px 240px at 88% -40%, rgba(47,216,193,.12), transparent 60%),
+ linear-gradient(160deg, var(--bg-panel) 0%, var(--bg-card) 70%);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-card);
+ flex-wrap: wrap;
+}
+/* 顶部三色霓虹描边:ESP32 青绿 → BLE 蓝 → GD32 琥珀,一条细线点出子系统谱系 */
+.header-bar::before {
+ content: "";
+ position: absolute; top: 0; left: 0; right: 0; height: 2px;
+ background: linear-gradient(90deg,
+ var(--esp32) 0%, var(--ble) 38%, var(--gd32) 78%, transparent 100%);
+ opacity: .85;
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
+}
+
+.header-brand {
+ display: flex; align-items: center; gap: 12px;
+ padding-right: 20px; border-right: 1px solid var(--border);
+ white-space: nowrap;
+}
+.brand-logo {
+ width: 40px; height: 40px; flex-shrink: 0;
+ display: inline-flex; align-items: center; justify-content: center;
+ border-radius: 11px;
+ background: linear-gradient(145deg, rgba(47,216,193,.16), rgba(95,168,255,.10));
+ border: 1px solid rgba(47,216,193,.3);
+ box-shadow: 0 0 18px rgba(47,216,193,.18), inset 0 0 12px rgba(47,216,193,.08);
+ color: var(--esp32);
+}
+.brand-logo svg { width: 24px; height: 24px; display: block; }
+.brand-text { display: flex; flex-direction: column; line-height: 1.15; }
+.brand-text strong {
+ font-family: var(--font-display);
+ font-size: 17px; font-weight: 700; letter-spacing: .02em;
+ background: linear-gradient(92deg, var(--text) 30%, var(--esp32-hi) 100%);
+ -webkit-background-clip: text; background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+.brand-sub {
+ font-family: var(--font-mono);
+ font-size: 10px; letter-spacing: .22em; text-transform: uppercase;
+ color: var(--text-label); margin-top: 2px;
+}
+/* 兼容旧结构:独立 dot-mark(无 brand-logo 时)仍显示为一颗呼吸 LED */
+.header-brand .dot-mark {
+ width: 10px; height: 10px; border-radius: 50%;
+ background: var(--esp32);
+ box-shadow: 0 0 10px var(--esp32-dim);
+ flex-shrink: 0;
+}
+.header-brand strong { font-size: 16px; font-weight: 700; }
+
+.header-status {
+ display: flex; align-items: center; gap: 8px;
+ flex-wrap: wrap;
+}
+.header-sub {
+ display: flex; gap: 10px; margin-left: auto;
+ flex-wrap: wrap;
+}
+
+/* ---- 状态胶囊 ---- */
+.pill {
+ display: inline-flex; align-items: center; gap: 7px;
+ padding: 6px 13px; border-radius: 999px;
+ font-size: 12px; font-weight: 500;
+ font-family: var(--font-mono);
+ background: var(--bg-elevated); border: 1px solid var(--border);
+ color: var(--text-dim);
+ white-space: nowrap;
+ transition: border-color .2s, background .2s, transform .2s;
+}
+.pill .indicator {
+ width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
+ background: var(--text-label);
+}
+.pill.online .indicator { background: var(--green); box-shadow: 0 0 8px rgba(61,220,142,.6); }
+.pill.offline .indicator { background: var(--red); box-shadow: 0 0 8px rgba(255,107,107,.5); }
+.pill.processing .indicator,
+.pill.live .indicator { background: var(--amber); animation: blink 1s infinite; }
+.pill.idle .indicator { background: var(--text-dim); }
+.pill.online { color: var(--can-hi); border-color: rgba(61,220,142,.35); }
+.pill.offline { color: #ffb3b3; border-color: rgba(255,107,107,.3); }
+.pill.processing,
+.pill.live { color: var(--gd32-hi); border-color: rgba(252,191,63,.35); }
+@keyframes blink { 50% { opacity: .3; } }
+
+.pill-val {
+ font-weight: 700; color: var(--text);
+ font-family: var(--font-mono);
+}
+
+/* ================= 主布局 ================= */
+.main-layout {
+ display: grid;
+ grid-template-columns: 400px 1fr;
+ gap: var(--gap);
+ align-items: start;
+}
+
+/* ================= 卡片 ================= */
+.card {
+ position: relative;
+ background: linear-gradient(170deg, var(--bg-card) 0%, rgba(15,23,38,.92) 100%);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+ box-shadow: var(--shadow-card);
+}
+.card + .card { margin-top: var(--gap); }
+
+/* 子系统色带:卡片顶部 2px 渐变,由修饰类决定颜色 */
+.card::before {
+ content: "";
+ position: absolute; top: 0; left: 0; right: 0; height: 2px;
+ background: linear-gradient(90deg, var(--card-accent, var(--esp32)), transparent 85%);
+ opacity: .7; pointer-events: none;
+}
+.card--esp32 { --card-accent: var(--esp32); --card-accent-dim: var(--esp32-dim); }
+.card--gd32 { --card-accent: var(--gd32); --card-accent-dim: var(--gd32-dim); }
+.card--ble { --card-accent: var(--ble); --card-accent-dim: var(--ble-dim); }
+.card--can { --card-accent: var(--can); --card-accent-dim: var(--can-dim); }
+.card--motor { --card-accent: var(--motor); --card-accent-dim: var(--motor-dim); }
+.card--mem { --card-accent: var(--mem); --card-accent-dim: var(--mem-dim); }
+.card--status { --card-accent: var(--green); --card-accent-dim: var(--can-dim); }
+
+.card-head {
+ display: flex; align-items: center; gap: 10px;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--border-subtle);
+ font-family: var(--font-display);
+ font-size: 13px; font-weight: 600;
+ color: var(--text);
+ letter-spacing: .05em;
+ cursor: pointer;
+ user-select: none;
+}
+/* 图标芯片:圆角方块底色取自卡片子系统色 */
+.card-head .ico {
+ width: 28px; height: 28px; flex-shrink: 0;
+ display: inline-flex; align-items: center; justify-content: center;
+ font-size: 15px;
+ border-radius: 8px;
+ background: var(--card-accent-dim, var(--esp32-dim));
+ border: 1px solid color-mix(in srgb, var(--card-accent, var(--esp32)) 30%, transparent);
+}
+.card-head-btn { margin-left: auto; padding: 4px 10px; position: relative; z-index: 2; }
+.card-body { padding: 17px 18px; }
+.card.collapsed .card-body { display: none; }
+.card-head .collapse-arrow {
+ margin-left: auto; font-size: 10px; color: var(--text-label);
+ transition: transform .2s;
+}
+.card.collapsed .collapse-arrow { transform: rotate(-90deg); }
+
+/* ================= 表单 ================= */
+.form-row { margin-bottom: 14px; }
+.form-row:last-child { margin-bottom: 0; }
+.form-label {
+ display: block;
+ font-family: var(--font-display);
+ font-size: 11px; color: var(--text-label);
+ text-transform: uppercase; letter-spacing: .12em;
+ margin-bottom: 7px;
+}
+.input, select, textarea {
+ width: 100%; padding: 10px 13px;
+ font-size: 13px; font-family: var(--font-mono);
+ background: var(--bg-input); color: var(--text);
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
+ outline: none;
+ transition: border-color .18s, box-shadow .18s, background .18s;
+}
+.input::placeholder { color: var(--text-label); }
+.input:focus, select:focus, textarea:focus {
+ border-color: var(--esp32);
+ background: color-mix(in srgb, var(--esp32) 5%, var(--bg-input));
+ box-shadow: 0 0 0 3px var(--esp32-dim);
+}
+select { cursor: pointer; }
+select option { background: var(--bg-panel); color: var(--text); }
+.input-group { display: flex; gap: 8px; }
+.input-group .input { flex: 1; }
+
+/* ================= 按钮 ================= */
+.btn {
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
+ padding: 9px 17px; border: none; border-radius: var(--radius-sm);
+ font-family: var(--font-display);
+ font-size: 13px; font-weight: 600; letter-spacing: .03em;
+ cursor: pointer; white-space: nowrap;
+ transition: transform .16s, box-shadow .2s, background .2s, border-color .2s, color .2s, opacity .2s;
+}
+.btn:hover:not(:disabled) { transform: translateY(-1px); }
+.btn:active:not(:disabled) { transform: translateY(0) scale(.97); }
+.btn:disabled { opacity: .38; cursor: not-allowed; transform: none; }
+
+/* 主操作 = ESP32 青绿 */
+.btn-primary {
+ background: linear-gradient(135deg, var(--esp32) 0%, #14b8a6 100%);
+ color: #04231e;
+ box-shadow: 0 4px 16px rgba(47,216,193,.28), inset 0 1px 0 rgba(255,255,255,.25);
+}
+.btn-primary:hover:not(:disabled) {
+ box-shadow: 0 6px 22px rgba(47,216,193,.4), inset 0 1px 0 rgba(255,255,255,.3);
+}
+.btn-success {
+ background: linear-gradient(135deg, var(--green) 0%, #16a374 100%);
+ color: #03231a;
+ box-shadow: 0 4px 16px rgba(61,220,142,.25), inset 0 1px 0 rgba(255,255,255,.22);
+}
+.btn-danger {
+ background: linear-gradient(135deg, var(--red) 0%, #dc4c4c 100%);
+ color: #fff;
+ box-shadow: 0 4px 16px rgba(255,107,107,.25), inset 0 1px 0 rgba(255,255,255,.2);
+}
+.btn-outline {
+ background: rgba(255,255,255,.02); color: var(--text-dim);
+ border: 1px solid var(--border-strong);
+}
+.btn-outline:hover:not(:disabled) {
+ background: var(--bg-hover); color: var(--text);
+ border-color: #3d5a84;
+}
+.btn-ghost { background: transparent; color: var(--text-dim); }
+.btn-ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
+.btn-block { width: 100%; justify-content: center; }
+.btn-sm { padding: 6px 12px; font-size: 12px; }
+
+/* ---- 多色子系统页签(关键)----
+ console-mode.js 用 btn-primary / btn-outline 表达"选中/未选中",
+ 这里按按钮 id 把"选中态"染成各自子系统色:
+ WiFi=青绿 · BLE=蓝 · ESP32=青绿 · GD32=琥珀。 */
+#modeBleBtn.btn-primary {
+ background: linear-gradient(135deg, var(--ble) 0%, #3b82f6 100%);
+ color: #04142b;
+ box-shadow: 0 4px 16px rgba(95,168,255,.3), inset 0 1px 0 rgba(255,255,255,.25);
+}
+#targetGd32Tab.btn-primary {
+ background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%);
+ color: #2b1d02;
+ box-shadow: 0 4px 16px rgba(252,191,63,.3), inset 0 1px 0 rgba(255,255,255,.3);
+}
+#targetEsp32Tab.btn-primary {
+ background: linear-gradient(135deg, var(--esp32) 0%, #14b8a6 100%);
+ color: #04231e;
+}
+/* GD32 OTA 大按钮 = 琥珀(区别于 ESP32 青绿) */
+#uploadGd32Btn {
+ background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%);
+ color: #2b1d02;
+ box-shadow: 0 6px 20px rgba(252,191,63,.3), inset 0 1px 0 rgba(255,255,255,.3);
+}
+#uploadEsp32Btn {
+ box-shadow: 0 6px 20px rgba(47,216,193,.32), inset 0 1px 0 rgba(255,255,255,.28);
+}
+
+/* ================= 连接状态条 / 提示 ================= */
+.conn-indicator {
+ display: flex; align-items: center; gap: 10px;
+ padding: 12px 16px; border-radius: var(--radius-sm);
+ background: var(--bg-elevated); border: 1px solid var(--border);
+ font-size: 13px; font-weight: 500;
+}
+.conn-indicator .light { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
+.conn-indicator.ok .light { background: var(--green); box-shadow: 0 0 8px rgba(61,220,142,.6); }
+.conn-indicator.err .light { background: var(--red); box-shadow: 0 0 8px rgba(255,107,107,.5); }
+.conn-indicator.warn .light { background: var(--amber); }
+
+.helper-note {
+ margin-top: 10px; padding: 10px 13px;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--border);
+ background: var(--bg-elevated);
+ font-size: 12px; line-height: 1.65; color: var(--text-dim);
+}
+.helper-note.ok { border-color: rgba(61,220,142,.3); background: rgba(61,220,142,.07); color: #a9f0c6; }
+.helper-note.warn { border-color: rgba(252,191,63,.3); background: rgba(252,191,63,.07); color: #fbe3ae; }
+.helper-note.err { border-color: rgba(255,107,107,.3); background: rgba(255,107,107,.07); color: #ffc4c4; }
+.helper-note code { color: var(--esp32-hi); font-family: var(--font-mono); }
+
+/* ================= 右侧滑出抽屉:堆与任务栈 ================= */
+.sys-drawer {
+ --sys-drawer-panel-w: min(390px, calc(100vw - 48px));
+ --sys-drawer-peek: 38px;
+ position: fixed; top: 76px; right: 0; z-index: 80;
+ width: calc(var(--sys-drawer-panel-w) + var(--sys-drawer-peek));
+ height: calc(100vh - 92px); max-height: calc(100vh - 92px);
+ display: flex; flex-direction: row; align-items: stretch;
+ transform: translateX(var(--sys-drawer-panel-w));
+ transition: transform .3s cubic-bezier(.22, 1, .36, 1);
+ filter: drop-shadow(-8px 0 24px rgba(2,5,12,.5));
+}
+.sys-drawer:hover, .sys-drawer:focus-within { transform: translateX(0); }
+.sys-drawer-rail {
+ flex: 0 0 var(--sys-drawer-peek); width: var(--sys-drawer-peek);
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
+ gap: 12px; padding: 14px 4px;
+ background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-card) 100%);
+ border: 1px solid var(--border); border-right: none;
+ border-radius: var(--radius) 0 0 var(--radius);
+ cursor: default;
+}
+.sys-drawer-rail-ico { font-size: 16px; line-height: 1; }
+.sys-drawer-rail-text {
+ writing-mode: vertical-rl; text-orientation: mixed;
+ font-family: var(--font-display);
+ font-size: 11px; font-weight: 600; letter-spacing: .18em;
+ color: var(--text-dim);
+}
+.sys-drawer:hover .sys-drawer-rail-text,
+.sys-drawer:focus-within .sys-drawer-rail-text { color: var(--mem-hi); }
+.sys-drawer-panel {
+ flex: 1 1 var(--sys-drawer-panel-w);
+ width: var(--sys-drawer-panel-w); min-width: 0; height: 100%;
+ margin: 0 !important;
+ display: flex; flex-direction: column;
+ border-radius: 0; overflow: hidden;
+ background: var(--bg-card);
+ border: 1px solid var(--border); border-left: none; border-right: none;
+}
+.sys-drawer-head { cursor: default; flex-shrink: 0; }
+.sys-drawer-head .card-head-btn { margin-left: auto; }
+.sys-drawer-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
diff --git a/css/motor.css b/css/motor.css
new file mode 100644
index 0000000..ba4b0ed
--- /dev/null
+++ b/css/motor.css
@@ -0,0 +1,797 @@
+/* motor.css - 电机控制台页面样式
+ * 由 motor.html 内联
+↻
+⌂
+
+"""
+_RELOAD_SNIPPET = _RELOAD_SNIPPET.replace("__HOME__", "/tabs.html")
+_RELOAD_MARK = "
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🔗 设备连接
+
+
+
+
+
+
+
+
+
+
+
点击按钮连接 / 断开蓝牙设备
+
+
+
+
+
+
+
+
+
🎯 升级目标、固件文件与执行操作
+
+
+
+
+
+
+
+
+
+
+
+ 📄 -
+
+
+
+
+ SHA-256
+ -
+
+
+
+
+
+
+
📤
+
点击或拖拽 GD32 固件
+
支持 .bin 和 .hex(自动转换)
+
+
+
+
+ 📄 -
+
+
+
+
+ SHA-256
+ -
+
+
+
+
+
+
+
📤
+
点击或拖拽文件到此处
+
支持 .bin 和 .hex 格式
+
+
+
+
+ 📄 -
+
+
+
+
类型-
+
大小0 B
+
总包数0
+
已写包0
+
+
+ SHA-256
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
⚡ 刷写进度与流程阶段
+
+
+
+
📦ESP32 OTA
+
📤GD32 OTA
+
+
+
+
+
+
+
+
+
+
+
+
+ 待机
+
+
+
前端 SHA-256-
+
设备 SHA-256-
+
校验结果-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📡 设备状态
+
+
+
+
+
+ 启动自检
+ --
+
+
失败/跳过:--
+
+
+
+
+
+
+
+
+
+
+ 🧾 系统状态 (/system/status)
+
+
+
+
+
OTA 状态--
+
Relay 状态--
+
刷新时间--
+
+
+
+
+
+
+
+ 🖥️ 调试日志
+ UDP :6666
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ✓
+ 操作成功
+
+
+
+
+
+
+
+"
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+
+g_httpd = None
+g_child = None
+g_port = None
+g_win = None
+g_job = None # Job Object 句柄: 父进程一死内核连坐杀服务子进程
+
+
+class _GuiApi:
+ """暴露给窗口内 JS (window.pywebview.api) 的 GUI 控制接口。"""
+
+ def page_loaded(self):
+ """页面每次加载完成后由注入脚本调用: 把窗口带到前台。"""
+ win = g_win
+ if win is not None:
+ try:
+ win.show()
+ except Exception:
+ pass
+ h = _native_handle(win) if win is not None else None
+ if h is not None:
+ _win32_foreground(h)
+
+ def reload_page(self):
+ win = g_win
+ if win is not None:
+ try:
+ win.load_url(win.url) # pywebview 无 reload, 用重载当前 URL 代替
+ except Exception:
+ pass
+
+
+def _focus_existing(port):
+ """让已有实例的窗口到前台。返回 True 表示确实存在旧实例。"""
+ try:
+ urllib.request.urlopen(urllib.request.Request(
+ f"http://127.0.0.1:{port}/api/focus", data=b"{}",
+ headers={"Content-Type": "application/json"}, method="POST"),
+ timeout=2)
+ return True
+ except Exception:
+ return False
+
+
+class Handler(SimpleHTTPRequestHandler):
+ server_version = "Esp32Tools/1.0"
+
+ def __init__(self, *a, **kw):
+ super().__init__(*a, directory=BASE_DIR, **kw)
+
+ def log_message(self, fmt, *args):
+ pass # 静默访问日志
+
+ def end_headers(self):
+ # 本地调试: 禁缓存, 避免改了 css/js 还得手工 bump ?v= 才生效
+ self.send_header("Cache-Control", "no-store, must-revalidate")
+ super().end_headers()
+
+ def do_GET(self):
+ path = self.path.split("?")[0].lower()
+ if path.endswith(".html") or path == "/" or not os.path.splitext(path)[1]:
+ # HTML(含无扩展名的 SPA 路由): 注入刷新悬浮按钮/快捷键后返回
+ fp = self.translate_path(self.path)
+ if os.path.isdir(fp):
+ fp = os.path.join(fp, "index.html")
+ try:
+ with open(fp, "rb") as f:
+ html = f.read().decode("utf-8", "replace")
+ except OSError:
+ self.send_error(404, "File not found")
+ return
+ if _RELOAD_MARK in html:
+ html = html.replace(_RELOAD_MARK, _RELOAD_SNIPPET + _RELOAD_MARK, 1)
+ body = html.encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ else:
+ super().do_GET()
+
+ def do_POST(self):
+ path = self.path.split("?")[0]
+ if path == "/api/focus":
+ # 已有实例响应: 新实例应当退场
+ body = b'{"ok":true,"focused":true}'
+ self._post_json(body)
+ threading.Thread(target=lambda: print("FOCUS=1", flush=True), daemon=True).start()
+ return
+ if path == "/api/shutdown":
+ body = b'{"ok":true}'
+ self._post_json(body)
+ threading.Thread(target=_shutdown_self, daemon=True).start()
+ return
+ self.send_error(404)
+
+ def _post_json(self, body):
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+
+def _shutdown_self():
+ if g_httpd is not None:
+ try:
+ g_httpd.shutdown()
+ except Exception:
+ pass
+
+
+def _win32_foreground(native_handle):
+ """SetForegroundWindow: pywebview Window 没有 focus(), 要走 Win32。"""
+ try:
+ hwnd = int(native_handle)
+ ctypes.windll.user32.ShowWindow(hwnd, 9) # SW_RESTORE
+ ctypes.windll.user32.SetForegroundWindow(hwnd)
+ except Exception:
+ pass
+
+
+def _native_handle(win):
+ try:
+ return win.native.Handle # WinForms Form
+ except Exception:
+ try:
+ return win.native
+ except Exception:
+ return None
+
+
+def _port_taken(port):
+ """Windows 的 SO_REUSEADDR 允许同端口重复 bind(实测会双监听、连接随机落),
+ 故先用一次真实连接探测占用, 不能只靠 bind 是否抛错。"""
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.settimeout(0.3)
+ return s.connect_ex(("127.0.0.1", port)) == 0
+
+
+class QuietServer(ThreadingHTTPServer):
+ # 客户端中途断开(刷新/关窗)会触发 handle_error 打 traceback 到 stderr;
+ # 窗口模式下 stderr 是父进程的 PIPE, 4KB 缓冲一满子进程写日志即永久阻塞
+ # → 整个服务冻死(实测"窗口经常卡死"的根因)。此处静音, 父侧另有排空线程双保险。
+ def handle_error(self, request, client_address):
+ pass
+
+
+def _bind_server(port):
+ for _ in range(6):
+ if _port_taken(port):
+ print(f"端口 {port} 已被占用, 尝试 {port + 1}")
+ port += 1
+ continue
+ try:
+ return QuietServer(("127.0.0.1", port), Handler), port
+ except OSError:
+ print(f"端口 {port} 绑定失败, 尝试 {port + 1}")
+ port += 1
+ return None, port
+
+
+def _watch_parent(ppid):
+ """兜底看门狗(Job Object 万一不可用时): 父 GUI 进程消失就自我了断,
+ 避免窗口关了服务还在后台占端口。"""
+ def _loop():
+ k32 = ctypes.windll.kernel32
+ h = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE
+ if not h:
+ return
+ k32.WaitForSingleObject(h, 0xFFFFFFFF) # 父进程退出即返回
+ os._exit(0)
+ threading.Thread(target=_loop, daemon=True).start()
+
+
+def _run_browser(args):
+ """浏览器模式: 服务跑在本进程主线程。"""
+ global g_httpd
+ if args.parent_pid:
+ _watch_parent(args.parent_pid)
+ httpd, port = _bind_server(args.port)
+ if httpd is None:
+ raise SystemExit("无法绑定端口")
+ g_httpd = httpd
+ url = f"http://127.0.0.1:{port}/tabs.html"
+ print(f"PORT={port}", flush=True) # 窗口模式父进程据此得知端口
+ print(f"ESP32 调试控制台: {url} (Ctrl+C 退出)")
+ print("页面里的设备 IP 决定 API 目标(默认 192.168.31.127), 本服务只提供页面")
+ if not args.no_browser:
+ threading.Timer(0.5, lambda: webbrowser.open(url)).start()
+ try:
+ httpd.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ httpd.server_close()
+ print("\n已退出")
+
+
+def _bind_child_lifetime(child):
+ """把服务子进程放进 Job Object, 父进程句柄一关(正常退出/被任务管理器强杀/崩溃)
+ 由内核连坐结束子进程, 杜绝"窗口关了端口还占着"的孤儿服务。
+ 句柄故意不关闭: 生命周期就是靠父进程存活维系的。"""
+ k32 = ctypes.windll.kernel32
+
+ class _JOBLIM(ctypes.Structure):
+ _fields_ = [("PerProcessUserTimeLimit", ctypes.c_int64),
+ ("PerJobUserTimeLimit", ctypes.c_int64),
+ ("LimitFlags", ctypes.c_uint32),
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
+ ("ActiveProcessLimit", ctypes.c_uint32),
+ ("Affinity", ctypes.POINTER(ctypes.c_ulong)),
+ ("PriorityClass", ctypes.c_uint32),
+ ("SchedulingClass", ctypes.c_uint32)]
+
+ class _IOC(ctypes.Structure):
+ _fields_ = [("ReadOperationCount", ctypes.c_uint64),
+ ("WriteOperationCount", ctypes.c_uint64),
+ ("OtherOperationCount", ctypes.c_uint64),
+ ("ReadTransferCount", ctypes.c_uint64),
+ ("WriteTransferCount", ctypes.c_uint64),
+ ("OtherTransferCount", ctypes.c_uint64)]
+
+ class _EXLIM(ctypes.Structure):
+ _fields_ = [("BasicLimitInformation", _JOBLIM),
+ ("IoInfo", _IOC),
+ ("ProcessMemoryLimit", ctypes.c_size_t),
+ ("JobMemoryLimit", ctypes.c_size_t),
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
+ ("PeakJobMemoryUsed", ctypes.c_size_t)]
+
+ try:
+ job = k32.CreateJobObjectW(None, None)
+ if not job:
+ return None
+ info = _EXLIM()
+ info.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE
+ if not k32.SetInformationJobObject(job, 9, ctypes.byref(info),
+ ctypes.sizeof(info)): # ExtendedLimitInformation
+ return None
+ h = k32.OpenProcess(0x1F0FFF, False, child.pid) # PROCESS_ALL_ACCESS
+ if not h:
+ return None
+ k32.AssignProcessToJobObject(job, h)
+ k32.CloseHandle(h)
+ return job
+ except Exception:
+ return None
+
+
+def _stop_child(child, port):
+ """通知服务子进程优雅退出, 超时则强杀。"""
+ if child is None or child.poll() is not None:
+ return
+ try:
+ urllib.request.urlopen(urllib.request.Request(
+ f"http://127.0.0.1:{port}/api/shutdown", data=b"{}",
+ headers={"Content-Type": "application/json"}, method="POST"),
+ timeout=3)
+ except Exception:
+ pass
+ try:
+ child.wait(timeout=3)
+ except Exception:
+ pass
+ if child.poll() is None:
+ child.kill()
+ try:
+ child.wait(timeout=3)
+ except Exception:
+ pass
+
+
+def _fail_box(title, msg):
+ """GUI 进程启动失败时弹原生对话框, 避免无声无息。"""
+ try:
+ ctypes.windll.user32.MessageBoxW(None, msg, title, 0x10) # MB_ICONERROR
+ except Exception:
+ pass
+
+
+def _acquire_single_instance():
+ """命名 mutex 单实例守卫。返回 False=已有实例在跑。"""
+ global g_mutex
+ try:
+ g_mutex = ctypes.windll.kernel32.CreateMutexW(None, False, "DCU_Esp32Tools_SingleInstance")
+ return ctypes.windll.kernel32.GetLastError() != 183 # ERROR_ALREADY_EXISTS
+ except Exception:
+ return True # mutex 不可用时退化为允许多开
+
+
+def _spawn_service_child(extra_args, fail_title):
+ """启动服务子进程并持续排空其 stdout。
+ 必须一直读: PIPE 缓冲 ~4KB, 读停则子进程任何 print/traceback 写满即永久阻塞。"""
+ script = os.path.abspath(__file__)
+ child = subprocess.Popen(
+ [sys.executable, script] + extra_args,
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=BASE_DIR)
+ global g_job
+ g_job = _bind_child_lifetime(child) # 父进程消失 → 内核连坐杀子进程
+ holder = {"port": None, "focus": False}
+ tail = collections.deque(maxlen=40)
+ focus_evt = threading.Event()
+
+ def _drain():
+ for raw in child.stdout:
+ s = raw.decode("utf-8", "replace").rstrip()
+ if s:
+ tail.append(s)
+ if s.startswith("PORT=") and holder["port"] is None:
+ try:
+ holder["port"] = int(s[5:])
+ except ValueError:
+ pass
+ elif s.startswith("FOCUS="):
+ holder["focus"] = True
+ focus_evt.set()
+
+ threading.Thread(target=_drain, daemon=True).start()
+
+ deadline = time.time() + 30
+ while time.time() < deadline:
+ if holder["port"] is not None or child.poll() is not None:
+ break
+ time.sleep(0.1)
+
+ port = holder["port"]
+ if port is None:
+ child.terminate()
+ _fail_box(fail_title,
+ "\n".join(tail) or "服务子进程未在 30 秒内报告端口(无输出)")
+ os._exit(1)
+ return child, port, focus_evt
+
+
+def _run_window(webview, args):
+ """桌面窗口模式: 本进程只做 GUI, HTTP 服务跑在子进程。"""
+ global g_child, g_port, g_win
+ if not _acquire_single_instance():
+ # 已有实例: 尝试把它的窗口顶到前台后退出。旧实例是修过 focus 钩子的
+ # 新版本才能响应; 旧版无响应时提示用户用旧窗口。
+ probed = False
+ for p in (args.port, args.port + 1, args.port + 2):
+ if _focus_existing(p):
+ probed = True
+ break
+ if probed:
+ os._exit(0)
+ _fail_box("ESP32 调试控制台",
+ "已有一个实例在运行, 但无法唤醒其窗口。\n"
+ "请到任务管理器结束所有 pythonw.exe 后重新打开。")
+ os._exit(0)
+
+ child, port, focus_evt = _spawn_service_child(
+ ["--no-browser", "--port", str(args.port),
+ "--parent-pid", str(os.getpid())], "ESP32 调试控制台启动失败")
+ g_child = child
+ g_port = port
+ url = f"http://127.0.0.1:{port}/tabs.html"
+ for _ in range(50): # 等 HTTP 就绪(最多 ~10s)
+ try:
+ urllib.request.urlopen(url, timeout=1)
+ break
+ except Exception:
+ if g_child.poll() is not None:
+ _fail_box("ESP32 调试控制台启动失败", "服务子进程意外退出")
+ os._exit(1)
+ time.sleep(0.2)
+
+ def _monitor():
+ while True:
+ if focus_evt.is_set():
+ _gui_bringToFront() # 新双击 → 旧窗口到前台
+ focus_evt.clear()
+ if g_child.poll() is not None:
+ os._exit(0) # 子进程没了 → GUI 一起退
+ time.sleep(0.5)
+
+ threading.Thread(target=_monitor, daemon=True).start()
+ g_win = webview.create_window("ESP32 调试控制台 — DCU", url,
+ width=1600, height=1000, min_size=(1100, 720),
+ js_api=_GuiApi(), hidden=True, text_select=True)
+ print(f"桌面窗口模式: {url} (关窗即退出)")
+
+ def _show_watchdog():
+ # 页面加载后由 JS 调 page_loaded → show; 若 20s 仍未显示则强制 show
+ deadline = time.time() + 20
+ while time.time() < deadline:
+ if g_win.events.loaded.is_set():
+ time.sleep(1)
+ break
+ time.sleep(0.5)
+ try:
+ g_win.show()
+ except Exception:
+ pass
+ h = _native_handle(g_win)
+ if h is not None:
+ _win32_foreground(h)
+
+ threading.Thread(target=_show_watchdog, daemon=True).start()
+ webview.start()
+ _stop_child(g_child, g_port)
+ os._exit(0)
+
+
+def _gui_bringToFront():
+ """把 GUI 窗口恢复/置顶(从子进程 monitor 线程调用)。"""
+ win = g_win
+ if win is None:
+ return
+ try:
+ win.show()
+ except Exception:
+ pass
+ h = _native_handle(win)
+ if h is not None:
+ _win32_foreground(h)
+
+
+def main():
+ ap = argparse.ArgumentParser(description="ESP32 调试控制台页面服务器")
+ ap.add_argument("--port", type=int, default=8080)
+ ap.add_argument("--no-browser", action="store_true")
+ ap.add_argument("--window", action="store_true",
+ help="pywebview 桌面窗口模式")
+ ap.add_argument("--parent-pid", type=int, default=0,
+ help="内部用: GUI 父进程 pid, 父进程消失时本服务自动退出")
+ args = ap.parse_args()
+
+ if args.window:
+ try:
+ import webview
+ except ImportError:
+ print("pywebview 未安装: pip install pywebview —— 回退浏览器模式")
+ webview = None
+ if webview is not None:
+ _run_window(webview, args)
+ return
+ _run_browser(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/esp32_web.vbs b/esp32_web.vbs
new file mode 100644
index 0000000..79e8975
--- /dev/null
+++ b/esp32_web.vbs
@@ -0,0 +1,10 @@
+' DCU ESP32 debug console - hidden launcher, opens in default browser (no console).
+' Desktop-window launcher: esp32_app.vbs Console-mode debug launcher: serve.bat
+Set sh = CreateObject("WScript.Shell")
+Set fso = CreateObject("Scripting.FileSystemObject")
+base = fso.GetParentFolderName(WScript.ScriptFullName)
+sh.CurrentDirectory = base
+sh.Environment("PROCESS")("PYTHONUTF8") = "1"
+pyw = sh.ExpandEnvironmentStrings("%LOCALAPPDATA%\Programs\Python\Python312\pythonw.exe")
+If Not fso.FileExists(pyw) Then pyw = "pythonw.exe"
+sh.Run """" & pyw & """ """ & base & "\esp32_server.py""", 0, False
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..6ec1868
--- /dev/null
+++ b/index.html
@@ -0,0 +1,521 @@
+
+
+
+
+
+
+
ESP32 OTA 中继控制台
+
+
+
+
+
+
+
+
+
+