feat(esp32): 添加多电机OTA中继功能并优化调试日志系统
- 新增多电机状态、版本和OTA中继监控模块(monitor/motor_state.cpp, monitor/motor_version.cpp, monitor/motor_ota_relay.cpp) - 在CAN反馈任务中添加0xC5/0xC6/0xD2类型消息处理,支持多电机状态推送 - 重构调试日志系统,将一次性JSON分配改为512B栈缓冲流式发送,降低内存峰值 - 调试UART波特率从115200提升至30000 - 修改版本管理方式,从自动生version.h改为手动维护 - 更新VSCode设置以支持ESP-IDF开发环境配置 - 在OTA协调器中添加电机OTA互斥机制,防止并发冲突 - 完善中继状态机异常处理,添加远程取消通知机制
This commit is contained in:
@@ -1,167 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
bump_asset_versions.py — 按本地静态资源内容 hash 自动更新 HTML 里的 ?v= 版本号。
|
||||
|
||||
动机
|
||||
----
|
||||
ESP32_COMM/tools 下多个 HTML(can.html / can_monitor.html / index.html)手工维护
|
||||
<link>/<script> 引用的 `?v=...` query 版本号。components.css 修改后部分页面没 bump,
|
||||
浏览器继续加载旧 CSS(审计 finding WEB-001)。
|
||||
|
||||
本脚本扫描所有 HTML 中形如
|
||||
<link ... href="css/foo.css?v=OLD">
|
||||
<script ... src="js/bar.js?v=OLD">
|
||||
的本地资源引用,按文件内容计算短 hash(sha1 前 8 位十六进制),把 OLD 替换成 hash。
|
||||
内容不变就不改,内容变了就 bump,所有页面引用同一资源时自动收敛到同一版本号。
|
||||
|
||||
用法
|
||||
----
|
||||
python bump_asset_versions.py # 默认 dry-run,只打印将要改动的行
|
||||
python bump_asset_versions.py --write # 实际写回 HTML
|
||||
python bump_asset_versions.py --check # CI 模式,发现任何不一致即返回非 0
|
||||
python bump_asset_versions.py --root PATH # 指定 tools 目录(默认脚本所在目录)
|
||||
|
||||
退出码
|
||||
------
|
||||
0 全部一致(dry-run 无改动 或 --write 成功 或 --check 通过)
|
||||
1 发现需要 bump 的引用(dry-run/--check 时)
|
||||
2 参数错误或文件 IO 错误
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 匹配 <link href="...?v=..."> 与 <script src="...?v=...">,仅本地相对路径(无 scheme/host)。
|
||||
# 路径里允许字母/数字/_-./ 子目录。query 部分仅捕获 v= 的值,保留其它 query key(若有)。
|
||||
ASSET_REF_RE = re.compile(
|
||||
r"""
|
||||
(?P<tag>(?:href|src)\s*=\s*") # 属性名 + =" 开头
|
||||
(?P<path>[A-Za-z0-9_./-]+\.(?:css|js)) # 本地资源路径(.css 或 .js)
|
||||
\?v=(?P<old>[^"&\s]+) # ?v= 版本号
|
||||
(?P<rest>"?) # 收尾引号(保留)
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def short_hash(content: bytes) -> str:
|
||||
"""sha1 前 8 位十六进制;8 位足够区分日常 bump,且 URL 友好。"""
|
||||
return hashlib.sha1(content).hexdigest()[:8]
|
||||
|
||||
|
||||
def find_html_files(root: Path) -> list[Path]:
|
||||
return sorted(p for p in root.glob("*.html") if p.is_file())
|
||||
|
||||
|
||||
def process_html(html_path: Path) -> tuple[list[str], str]:
|
||||
"""
|
||||
返回 (new_lines, new_text)。
|
||||
new_lines 是"将被改动"的描述列表(仅 dry-run/日志用),空列表表示无改动。
|
||||
new_text 是替换后的完整文本(无论是否有改动都返回)。
|
||||
"""
|
||||
text = html_path.read_text(encoding="utf-8")
|
||||
lines = text.splitlines(keepends=True)
|
||||
changes: list[str] = []
|
||||
|
||||
def replace_in_line(line: str, line_no: int) -> str:
|
||||
def _sub(m: re.Match[str]) -> str:
|
||||
rel_path = m.group("path")
|
||||
old_ver = m.group("old")
|
||||
asset_path = html_path.parent / rel_path
|
||||
if not asset_path.is_file():
|
||||
# 资源缺失:不动,交给调用方在校验阶段报错
|
||||
return m.group(0)
|
||||
new_ver = short_hash(asset_path.read_bytes())
|
||||
if new_ver == old_ver:
|
||||
return m.group(0)
|
||||
changes.append(
|
||||
f" L{line_no}: {rel_path} {old_ver} -> {new_ver}"
|
||||
)
|
||||
return f'{m.group("tag")}{rel_path}?v={new_ver}{m.group("rest")}'
|
||||
|
||||
return ASSET_REF_RE.sub(_sub, line)
|
||||
|
||||
new_lines_text = [replace_in_line(line, i + 1) for i, line in enumerate(lines)]
|
||||
return changes, "".join(new_lines_text)
|
||||
|
||||
|
||||
def collect_missing_assets(html_files: list[Path]) -> list[str]:
|
||||
"""收集所有 HTML 引用但磁盘上不存在的本地资源,供 --check 报错。"""
|
||||
missing: set[str] = set()
|
||||
for html_path in html_files:
|
||||
text = html_path.read_text(encoding="utf-8")
|
||||
for m in ASSET_REF_RE.finditer(text):
|
||||
rel = m.group("path")
|
||||
if not (html_path.parent / rel).is_file():
|
||||
missing.add(f"{html_path.name}: {rel}")
|
||||
return sorted(missing)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="按本地静态资源内容 hash 自动更新 HTML 里的 ?v= 版本号。"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--root", type=Path, default=None,
|
||||
help="tools 目录;默认为脚本所在目录。",
|
||||
)
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument("--write", action="store_true", help="实际写回 HTML(默认 dry-run)。")
|
||||
mode.add_argument("--check", action="store_true", help="CI 模式:有改动或资源缺失即非 0 退出。")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
root = (args.root if args.root is not None else Path(__file__).resolve().parent)
|
||||
if not root.is_dir():
|
||||
print(f"ERROR: root 不是目录: {root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
html_files = find_html_files(root)
|
||||
if not html_files:
|
||||
print(f"[bump] 没有找到 HTML 文件: {root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
mode_label = "check" if args.check else ("write" if args.write else "dry-run")
|
||||
print(f"[bump] root={root} mode={mode_label} html={len(html_files)}")
|
||||
|
||||
missing = collect_missing_assets(html_files)
|
||||
if missing:
|
||||
print("[bump] 引用了不存在的本地资源:", file=sys.stderr)
|
||||
for item in missing:
|
||||
print(f" {item}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
total_changes = 0
|
||||
for html_path in html_files:
|
||||
changes, new_text = process_html(html_path)
|
||||
if not changes:
|
||||
continue
|
||||
total_changes += len(changes)
|
||||
rel = html_path.relative_to(root)
|
||||
print(f"[bump] {rel}({len(changes)} 处):")
|
||||
for line in changes:
|
||||
print(line)
|
||||
if args.write:
|
||||
html_path.write_text(new_text, encoding="utf-8")
|
||||
|
||||
if total_changes == 0:
|
||||
print("[bump] 全部一致,无需 bump。")
|
||||
return 0
|
||||
|
||||
if args.write:
|
||||
print(f"[bump] 已写回 {total_changes} 处版本号。")
|
||||
return 0
|
||||
if args.check:
|
||||
print(f"[bump] CHECK FAIL:{total_changes} 处版本号与内容不一致。", file=sys.stderr)
|
||||
return 1
|
||||
print(f"[bump] dry-run:{total_changes} 处需要 bump(加 --write 写回)。")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+16
-7
@@ -4,19 +4,28 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32 CAN 通信控制台</title>
|
||||
<link rel="stylesheet" href="css/base.css?v=7d5f4eef">
|
||||
<link rel="stylesheet" href="css/layout.css?v=a3e0b7e2">
|
||||
<link rel="stylesheet" href="css/components.css?v=475fa1ac">
|
||||
<link rel="stylesheet" href="css/can.css?v=8a9342b7">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=61b66182">
|
||||
<link rel="stylesheet" href="css/base.css?v=857f83a5">
|
||||
<link rel="stylesheet" href="css/layout.css?v=acd17001">
|
||||
<link rel="stylesheet" href="css/components.css?v=7bc7d5db">
|
||||
<link rel="stylesheet" href="css/can.css?v=c06f5688">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=46449483">
|
||||
</head>
|
||||
<body class="can-page">
|
||||
<div class="container can-container">
|
||||
|
||||
<header class="header-bar can-header">
|
||||
<div class="header-brand">
|
||||
<span class="dot-mark"></span>
|
||||
<strong>ESP32 CAN 通信控制台</strong>
|
||||
<span class="brand-logo" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2.5"/>
|
||||
<rect x="9.5" y="9.5" width="5" height="5" rx="1" fill="currentColor" stroke="none" opacity=".85"/>
|
||||
<path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="brand-text">
|
||||
<strong>ESP32 CAN 通信控制台</strong>
|
||||
<span class="brand-sub">DCU · Robot_v2 · CAN FD</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<span class="pill offline" id="canConnPill"><span class="indicator"></span><span id="canConnTxt">未连接</span></span>
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32 CAN 接收监控</title>
|
||||
<link rel="stylesheet" href="css/base.css?v=7d5f4eef">
|
||||
<link rel="stylesheet" href="css/layout.css?v=a3e0b7e2">
|
||||
<link rel="stylesheet" href="css/components.css?v=475fa1ac">
|
||||
<link rel="stylesheet" href="css/can.css?v=8a9342b7">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=61b66182">
|
||||
<link rel="stylesheet" href="css/base.css?v=857f83a5">
|
||||
<link rel="stylesheet" href="css/layout.css?v=acd17001">
|
||||
<link rel="stylesheet" href="css/components.css?v=7bc7d5db">
|
||||
<link rel="stylesheet" href="css/can.css?v=c06f5688">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=46449483">
|
||||
</head>
|
||||
<body class="can-page">
|
||||
<div class="container can-container">
|
||||
@@ -182,6 +182,6 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/can_monitor.js?v=ef00eb03"></script>
|
||||
<script type="module" src="js/can_monitor.js?v=9547d2a1"></script>
|
||||
</body>
|
||||
</html>
|
||||
+8
-6
@@ -36,6 +36,9 @@
|
||||
--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);
|
||||
@@ -57,13 +60,12 @@
|
||||
--radius-lg: 16px;
|
||||
|
||||
/* ---- 字体 ----
|
||||
display:Chakra Petch(棱角科技感,用于标题/大数字/按钮;
|
||||
无中文字形,中文自动回退苹方/雅黑)
|
||||
mono:JetBrains Mono(SHA / HEX / 日志 / 数值)
|
||||
body:系统中文字体优先,保证正文可读性 */
|
||||
--font-display: "Chakra Petch","PingFang SC","Microsoft YaHei","Segoe UI",sans-serif;
|
||||
沿用原始系统字体方案:中文优先苹方/雅黑,西文 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: "JetBrains Mono","SF Mono","Cascadia Code","Consolas",monospace;
|
||||
--font-mono: "SF Mono","Fira Code","Cascadia Code","Consolas",monospace;
|
||||
|
||||
/* ---- 间距 / 阴影 ---- */
|
||||
--gap: 18px;
|
||||
|
||||
+70
-16
@@ -5,9 +5,9 @@
|
||||
}
|
||||
.can-launch-card {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(34,211,238,.18), transparent 28%),
|
||||
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(34,211,238,.14);
|
||||
border: 1px solid rgba(67,217,123,.16);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
box-shadow:
|
||||
@@ -21,7 +21,7 @@
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(180deg, rgba(34,211,238,.9), rgba(34,211,238,.18));
|
||||
background: linear-gradient(180deg, rgba(67,217,123,.9), rgba(67,217,123,.18));
|
||||
}
|
||||
.can-launch-copy {
|
||||
position: relative;
|
||||
@@ -46,7 +46,7 @@
|
||||
max-width: 700px;
|
||||
}
|
||||
.can-launch-kicker {
|
||||
color: #88ecff;
|
||||
color: #8ef0b4;
|
||||
font-size: 10px;
|
||||
letter-spacing: .18em;
|
||||
text-transform: uppercase;
|
||||
@@ -57,9 +57,9 @@
|
||||
align-items: center;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(34,211,238,.08);
|
||||
border: 1px solid rgba(34,211,238,.18);
|
||||
color: #a9f4ff;
|
||||
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;
|
||||
@@ -88,8 +88,8 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px rgba(34,211,238,.35);
|
||||
background: var(--can);
|
||||
box-shadow: 0 0 10px rgba(67,217,123,.35);
|
||||
}
|
||||
.can-launch-actions {
|
||||
display: grid;
|
||||
@@ -104,7 +104,9 @@
|
||||
min-height: 42px;
|
||||
}
|
||||
.can-launch-primary {
|
||||
box-shadow: 0 10px 24px rgba(34,211,238,.18);
|
||||
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;
|
||||
@@ -113,7 +115,7 @@
|
||||
}
|
||||
.can-launch-secondary:hover:not(:disabled) {
|
||||
background: rgba(255,255,255,.05);
|
||||
border-color: rgba(34,211,238,.18);
|
||||
border-color: rgba(67,217,123,.25);
|
||||
}
|
||||
.can-launch-foot {
|
||||
display: flex;
|
||||
@@ -128,6 +130,35 @@
|
||||
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;
|
||||
@@ -160,7 +191,6 @@ body.can-page {
|
||||
.can-container {
|
||||
max-width: min(1700px, 100%);
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.can-header {
|
||||
@@ -170,6 +200,9 @@ body.can-page {
|
||||
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;
|
||||
}
|
||||
@@ -942,9 +975,9 @@ body.can-page {
|
||||
grid-template-columns:
|
||||
auto
|
||||
56px
|
||||
minmax(0, 0.9fr)
|
||||
minmax(0, 1.4fr)
|
||||
minmax(0, 0.8fr);
|
||||
minmax(0, 0.7fr)
|
||||
minmax(0, 0.8fr)
|
||||
minmax(0, 1.6fr);
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
min-width: 0;
|
||||
@@ -1008,6 +1041,24 @@ body.can-page {
|
||||
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; }
|
||||
@@ -1095,7 +1146,10 @@ body.can-page {
|
||||
}
|
||||
.feedback-row:last-child { border-bottom: none; }
|
||||
.fb-label { color: var(--muted, #8892a4); }
|
||||
.fb-val { font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.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; }
|
||||
|
||||
@@ -136,14 +136,15 @@
|
||||
box-shadow: 0 8px 20px rgba(3,7,16,.4);
|
||||
}
|
||||
.metric .k {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px; color: var(--text-label);
|
||||
text-transform: uppercase; letter-spacing: .13em; margin-bottom: 5px;
|
||||
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-display);
|
||||
font-size: 21px; font-weight: 700; word-break: break-word;
|
||||
font-variant-numeric: tabular-nums; letter-spacing: .01em;
|
||||
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); }
|
||||
|
||||
+6
-11
@@ -5,14 +5,16 @@
|
||||
/* ================= HEADER — 命令栏 ================= */
|
||||
.header-bar {
|
||||
position: relative;
|
||||
z-index: 20; /* 高于下方卡片,保证 pill tooltip 不被遮挡 */
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 16px 22px; margin-bottom: 26px;
|
||||
background: linear-gradient(160deg, var(--bg-panel) 0%, var(--bg-card) 70%);
|
||||
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;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 顶部三色霓虹描边:ESP32 青绿 → BLE 蓝 → GD32 琥珀,一条细线点出子系统谱系 */
|
||||
.header-bar::before {
|
||||
@@ -21,15 +23,7 @@
|
||||
background: linear-gradient(90deg,
|
||||
var(--esp32) 0%, var(--ble) 38%, var(--gd32) 78%, transparent 100%);
|
||||
opacity: .85;
|
||||
}
|
||||
/* 右上角极淡的芯片电路装饰 */
|
||||
.header-bar::after {
|
||||
content: "";
|
||||
position: absolute; right: -40px; top: -40px;
|
||||
width: 220px; height: 220px; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 30% 30%, rgba(47,216,193,.10), transparent 55%);
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
|
||||
.header-brand {
|
||||
@@ -139,6 +133,7 @@
|
||||
.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); }
|
||||
|
||||
|
||||
+139
-172
@@ -1,24 +1,19 @@
|
||||
/* === responsive.css — 响应式断点 + 增强动画 === */
|
||||
/* === RESPONSIVE === */
|
||||
/* ============================================================
|
||||
responsive.css — 响应式断点 + 动效(对齐新设计令牌)
|
||||
============================================================ */
|
||||
|
||||
/* ================= 断点 ================= */
|
||||
@media (max-width: 1100px) {
|
||||
.main-layout { grid-template-columns: 1fr; }
|
||||
.sys-drawer {
|
||||
--sys-drawer-panel-w: min(340px, calc(100vw - 40px));
|
||||
--sys-drawer-peek: 32px;
|
||||
top: 64px;
|
||||
height: calc(100vh - 72px);
|
||||
max-height: calc(100vh - 72px);
|
||||
}
|
||||
.can-monitor-drawer {
|
||||
--sys-drawer-panel-w: min(400px, calc(100vw - 40px));
|
||||
top: 64px; height: calc(100vh - 72px); max-height: calc(100vh - 72px);
|
||||
}
|
||||
.can-monitor-drawer { --sys-drawer-panel-w: min(400px, calc(100vw - 40px)); }
|
||||
.badge-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.can-toolbar-grid,
|
||||
.can-toolbar-foot,
|
||||
.can-editor-layout { grid-template-columns: 1fr; }
|
||||
.message-list {
|
||||
max-height: 420px;
|
||||
}
|
||||
.can-toolbar-grid, .can-toolbar-foot, .can-editor-layout { grid-template-columns: 1fr; }
|
||||
.message-list { max-height: 420px; }
|
||||
.can-meta-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
@@ -26,254 +21,226 @@
|
||||
.sys-drawer {
|
||||
--sys-drawer-panel-w: calc(100vw - 36px);
|
||||
--sys-drawer-peek: 28px;
|
||||
top: 56px;
|
||||
height: calc(100vh - 60px);
|
||||
max-height: calc(100vh - 60px);
|
||||
top: 56px; height: calc(100vh - 60px); max-height: calc(100vh - 60px);
|
||||
}
|
||||
.sys-drawer-rail-text { font-size: 11px; }
|
||||
.header-bar { flex-direction: column; align-items: flex-start; }
|
||||
.header-sub { margin-left: 0; }
|
||||
.badge-grid, .metric-grid { grid-template-columns: 1fr; }
|
||||
.status-kv-grid, .status-task-meta { grid-template-columns: 1fr; }
|
||||
.can-grid { grid-template-columns: 1fr 1fr; }
|
||||
.btn-row { flex-direction: column; }
|
||||
.can-meta-grid { grid-template-columns: 1fr; }
|
||||
.can-launch-top,
|
||||
.can-launch-actions,
|
||||
.can-launch-foot {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.can-selection-bar,
|
||||
.can-selection-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.can-msg-toolbar {
|
||||
flex-wrap: wrap;
|
||||
.can-launch-top, .can-launch-actions, .can-launch-foot {
|
||||
grid-template-columns: 1fr; flex-direction: column; align-items: flex-start;
|
||||
}
|
||||
.can-selection-bar, .can-selection-actions { flex-direction: column; align-items: flex-start; }
|
||||
.can-msg-toolbar { flex-wrap: wrap; }
|
||||
.history-item-row {
|
||||
grid-template-columns: auto 56px minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
.history-item-hex,
|
||||
.history-item-foot {
|
||||
grid-column: 1 / -1;
|
||||
text-align: left;
|
||||
}
|
||||
.can-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
.can-frame-hex {
|
||||
font-size: 15px;
|
||||
}
|
||||
/* 禁止再强制 980px 最小宽,避免报文/信号表横向滑动 */
|
||||
.message-table,
|
||||
.signal-table {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.history-item-hex, .history-item-foot { grid-column: 1 / -1; text-align: left; }
|
||||
.can-title { font-size: 22px; }
|
||||
.can-frame-hex { font-size: 15px; }
|
||||
.message-table, .signal-table { min-width: 0; max-width: 100%; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===== ENHANCED ANIMATIONS (v20260625b) ===== */
|
||||
|
||||
/* Global keyframes */
|
||||
/* ================= 动画关键帧 =================
|
||||
* 性能约定:常驻(infinite)动画只动 transform/opacity(合成器直通,
|
||||
* 不走主线程重绘);box-shadow/background-position 关键帧仅限短暂、
|
||||
* 临时态(如 dragover / OTA 阶段活跃)。 */
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes popIn {
|
||||
0% { opacity: 0; transform: scale(.85); }
|
||||
60% { opacity: 1; transform: scale(1.04); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
/* 光泽扫过:由 background-position 改为伪元素自身平移(合成器友好),
|
||||
* 扫动节奏与原版一致:扫过 + 停预周期。 */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
@keyframes ringPulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(34,211,238,.55); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(34,211,238,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(34,211,238,0); }
|
||||
0% { box-shadow: 0 0 0 0 rgba(47,216,193,.5); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(47,216,193,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(47,216,193,0); }
|
||||
}
|
||||
/* 常驻波纹/呼吸辉光的合成器友好版:叠加层只动 transform/opacity。 */
|
||||
@keyframes ringRipple {
|
||||
0% { transform: scale(1); opacity: .5; }
|
||||
70%,100% { transform: scale(3); opacity: 0; }
|
||||
}
|
||||
@keyframes glowBreathFade {
|
||||
0%,100% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-4px); }
|
||||
40% { transform: translateX(4px); }
|
||||
60% { transform: translateX(-3px); }
|
||||
80% { transform: translateX(3px); }
|
||||
0%,100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-4px); } 40% { transform: translateX(4px); }
|
||||
60% { transform: translateX(-3px); } 80% { transform: translateX(3px); }
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes floatY {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
@keyframes glowBreath {
|
||||
0%, 100% { box-shadow: 0 0 8px var(--accent-dim); }
|
||||
50% { box-shadow: 0 0 18px rgba(34,211,238,.45); }
|
||||
}
|
||||
@keyframes flowDash {
|
||||
to { background-position: 200% 0; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes floatY { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
|
||||
@keyframes flowDash { to { background-position: 200% 0; } }
|
||||
@keyframes logoPulse {
|
||||
0%,100% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Page entrance — header + cards staggered */
|
||||
.header-bar { animation: fadeInUp .45s ease both; }
|
||||
.main-layout > div > .card { animation: fadeInUp .5s ease both; }
|
||||
.main-layout > div:nth-child(1) > .card:nth-child(1) { animation-delay: .05s; }
|
||||
.main-layout > div:nth-child(1) > .card:nth-child(2) { animation-delay: .12s; }
|
||||
.main-layout > div:nth-child(1) > .card:nth-child(3) { animation-delay: .19s; }
|
||||
.main-layout > div:nth-child(1) > .card:nth-child(4) { animation-delay: .26s; }
|
||||
.main-layout > div:nth-child(2) > .card:nth-child(1) { animation-delay: .08s; }
|
||||
.main-layout > div:nth-child(2) > .card:nth-child(2) { animation-delay: .15s; }
|
||||
.main-layout > div:nth-child(2) > .card:nth-child(3) { animation-delay: .22s; }
|
||||
.main-layout > div:nth-child(2) > .card:nth-child(4) { animation-delay: .29s; }
|
||||
/* ================= 入场:头部 + 卡片错落浮现 ================= */
|
||||
.header-bar { animation: fadeInUp .5s ease both; }
|
||||
.main-layout > div > .card { animation: fadeInUp .55s ease both; }
|
||||
.main-layout > div:nth-child(1) > .card:nth-child(1) { animation-delay: .06s; }
|
||||
.main-layout > div:nth-child(1) > .card:nth-child(2) { animation-delay: .13s; }
|
||||
.main-layout > div:nth-child(1) > .card:nth-child(3) { animation-delay: .20s; }
|
||||
.main-layout > div:nth-child(1) > .card:nth-child(4) { animation-delay: .27s; }
|
||||
.main-layout > div:nth-child(2) > .card:nth-child(1) { animation-delay: .09s; }
|
||||
.main-layout > div:nth-child(2) > .card:nth-child(2) { animation-delay: .16s; }
|
||||
.main-layout > div:nth-child(2) > .card:nth-child(3) { animation-delay: .23s; }
|
||||
.main-layout > div:nth-child(2) > .card:nth-child(4) { animation-delay: .30s; }
|
||||
.main-layout > div:nth-child(2) > .card:nth-child(5) { animation-delay: .37s; }
|
||||
|
||||
/* Card hover — lift + glow */
|
||||
.card {
|
||||
transition: transform .25s ease, border-color .25s ease, box-shadow .25s ease;
|
||||
}
|
||||
/* ================= 卡片悬停:抬升 + 子系统色描边 ================= */
|
||||
.card { transition: transform .25s ease, border-color .25s ease, box-shadow .25s ease; }
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(34,211,238,.28);
|
||||
box-shadow: 0 10px 28px rgba(0,0,0,.35), 0 0 0 1px rgba(34,211,238,.08);
|
||||
border-color: color-mix(in srgb, var(--card-accent, var(--esp32)) 32%, var(--border));
|
||||
box-shadow: 0 14px 34px rgba(2,5,12,.5),
|
||||
0 0 0 1px color-mix(in srgb, var(--card-accent, var(--esp32)) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Brand dot — gentle ring pulse */
|
||||
.header-brand .dot-mark {
|
||||
animation: ringPulse 2.2s ease-out infinite, glowBreath 3.6s ease-in-out infinite;
|
||||
/* ================= 品牌 Logo:呼吸辉光 =================
|
||||
* box-shadow 关键帧每帧走主线程重绘;改为叠加静态辉光的伪元素
|
||||
* 只动 opacity(合成器直通),基础辉光由 layout.css 静态 box-shadow 提供,
|
||||
* 呼吸节奏与原 logoPulse/ringPulse/glowBreath 一致。 */
|
||||
.brand-logo { position: relative; }
|
||||
.brand-logo::before {
|
||||
content: "";
|
||||
position: absolute; inset: -1px; border-radius: inherit;
|
||||
box-shadow: 0 0 26px rgba(47,216,193,.4), inset 0 0 16px rgba(47,216,193,.14);
|
||||
opacity: 0; pointer-events: none;
|
||||
animation: logoPulse 3.2s ease-in-out infinite;
|
||||
will-change: opacity;
|
||||
}
|
||||
.header-brand .dot-mark { position: relative; }
|
||||
.header-brand .dot-mark::after {
|
||||
content: "";
|
||||
position: absolute; inset: 0; border-radius: 50%;
|
||||
background: rgba(47,216,193,.5); pointer-events: none;
|
||||
animation: ringRipple 2.4s ease-out infinite;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.header-brand .dot-mark::before {
|
||||
content: "";
|
||||
position: absolute; inset: 0; border-radius: 50%;
|
||||
box-shadow: 0 0 20px rgba(47,216,193,.45);
|
||||
opacity: 0; pointer-events: none;
|
||||
animation: glowBreathFade 3.6s ease-in-out infinite;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
/* Pills — subtle hover */
|
||||
.pill { transition: transform .2s ease, border-color .2s ease, background .2s ease; }
|
||||
.pill:hover { transform: translateY(-1px); border-color: rgba(34,211,238,.3); }
|
||||
/* ================= 胶囊 / 按钮微交互 ================= */
|
||||
.pill:hover { transform: translateY(-1px); border-color: var(--border-strong); }
|
||||
|
||||
/* Button — press + sheen on hover for primary */
|
||||
.btn { position: relative; overflow: hidden; }
|
||||
.btn:active:not(:disabled) { transform: translateY(0) scale(.97); transition: transform .08s ease; }
|
||||
.btn-primary::before,
|
||||
.btn-success::before,
|
||||
.btn-danger::before {
|
||||
.btn-primary::before, .btn-success::before, .btn-danger::before {
|
||||
content: "";
|
||||
position: absolute; top: 0; left: -120%;
|
||||
width: 60%; height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,.35), transparent);
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,.4), transparent);
|
||||
transform: skewX(-20deg);
|
||||
transition: left .55s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled)::before,
|
||||
.btn-success:hover:not(:disabled)::before,
|
||||
.btn-danger:hover:not(:disabled)::before {
|
||||
left: 130%;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled)::before { left: 130%; }
|
||||
|
||||
/* Upload zone — drag pulse + icon float */
|
||||
.upload-zone { transition: border-color .2s, background .2s, transform .2s; }
|
||||
.upload-zone:hover { transform: translateY(-1px); }
|
||||
.upload-zone .upload-ico {
|
||||
display: inline-block;
|
||||
transition: transform .25s ease;
|
||||
}
|
||||
/* ================= 上传区:拖拽脉冲 + 图标浮动 ================= */
|
||||
.upload-zone:hover .upload-ico { animation: floatY 1.6s ease-in-out infinite; }
|
||||
.upload-zone.dragover { animation: ringPulse 1.2s ease-out infinite; }
|
||||
|
||||
/* File info appearance */
|
||||
/* ================= 文件信息浮现 ================= */
|
||||
.file-info.show { animation: fadeInUp .3s ease both; }
|
||||
|
||||
/* Progress bar — striped flow on active */
|
||||
/* ================= 进度条:流动斜纹 ================= */
|
||||
.progress-fill {
|
||||
background-image:
|
||||
linear-gradient(90deg, #22d3ee, #06b6d4),
|
||||
repeating-linear-gradient(45deg,
|
||||
rgba(255,255,255,.18) 0 10px,
|
||||
rgba(255,255,255,0) 10px 20px);
|
||||
linear-gradient(90deg, var(--esp32), #14b8a6),
|
||||
repeating-linear-gradient(45deg, rgba(255,255,255,.2) 0 10px, rgba(255,255,255,0) 10px 20px);
|
||||
background-size: 100% 100%, 200% 100%;
|
||||
background-blend-mode: normal, overlay;
|
||||
animation: flowDash 1.4s linear infinite;
|
||||
}
|
||||
.progress-fill.done { animation: none; }
|
||||
|
||||
/* Phase step capsule — pop when becoming active/done */
|
||||
.pp-step.active,
|
||||
.pp-step.done { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; }
|
||||
/* ================= 阶段胶囊:激活弹入 ================= */
|
||||
.pp-step.active, .pp-step.done { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; }
|
||||
|
||||
/* Stage flow — connector grow + dot pop */
|
||||
/* ================= 阶段流:连接线生长 + 圆点动效 ================= */
|
||||
.stage-node:not(:last-child)::after {
|
||||
background: linear-gradient(90deg, var(--accent) 0%, var(--accent) 50%, var(--border) 50%, var(--border) 100%);
|
||||
background-size: 200% 100%;
|
||||
background-position: 100% 0;
|
||||
background: linear-gradient(90deg, var(--esp32) 0%, var(--esp32) 50%, var(--border) 50%, var(--border) 100%);
|
||||
background-size: 200% 100%; background-position: 100% 0;
|
||||
transition: background-position .5s ease;
|
||||
}
|
||||
.stage-node.done:not(:last-child)::after,
|
||||
.stage-node.live:not(:last-child)::after {
|
||||
background-position: 0% 0;
|
||||
}
|
||||
.stage-node.live:not(:last-child)::after { background-position: 0% 0; }
|
||||
.stage-dot { transition: transform .3s ease, background .3s, border-color .3s, color .3s, box-shadow .3s; }
|
||||
.stage-node.live .stage-dot {
|
||||
animation: ringPulse 1.6s ease-out infinite;
|
||||
}
|
||||
.stage-node.live .stage-dot { animation: ringPulse 1.6s ease-out infinite; }
|
||||
.stage-node.done .stage-dot { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; }
|
||||
.stage-node.err .stage-dot { animation: shake .4s ease both; }
|
||||
|
||||
/* Stage status bar — slide in when state changes */
|
||||
.stage-status-bar.active,
|
||||
.stage-status-bar.ok,
|
||||
.stage-status-bar.err { animation: fadeInUp .3s ease both; }
|
||||
|
||||
/* Stage result panel */
|
||||
/* ================= 状态条切换浮现 ================= */
|
||||
.stage-status-bar.active, .stage-status-bar.ok, .stage-status-bar.err { animation: fadeInUp .3s ease both; }
|
||||
.stage-result { animation: popIn .4s cubic-bezier(.4,1.4,.5,1) both; }
|
||||
|
||||
/* Metric / badge values — number tick highlight */
|
||||
.metric .v,
|
||||
.badge-stat .v {
|
||||
transition: color .3s ease, transform .3s ease;
|
||||
}
|
||||
.metric:hover,
|
||||
.badge-stat:hover {
|
||||
border-color: rgba(34,211,238,.25);
|
||||
transition: border-color .25s ease;
|
||||
}
|
||||
/* ================= 指标数值过渡 ================= */
|
||||
.metric .v, .badge-stat .v { transition: color .3s ease, transform .3s ease; }
|
||||
|
||||
/* Conn indicator — light ring pulse when warn */
|
||||
/* ================= 连接指示 ================= */
|
||||
.conn-indicator { transition: background .25s ease, border-color .25s ease; }
|
||||
.conn-indicator.warn .light { animation: ringPulse 1.4s ease-out infinite; }
|
||||
.conn-indicator.ok .light { animation: glowBreath 3s ease-in-out infinite; }
|
||||
/* 在线呼吸辉光:常驻动画改伪元素 opacity(合成器直通),节奏同原 glowBreath */
|
||||
.conn-indicator.ok .light { position: relative; }
|
||||
.conn-indicator.ok .light::after {
|
||||
content: "";
|
||||
position: absolute; inset: 0; border-radius: 50%;
|
||||
box-shadow: 0 0 20px rgba(47,216,193,.45);
|
||||
opacity: 0; pointer-events: none;
|
||||
animation: glowBreathFade 3s ease-in-out infinite;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
/* Heap / task bars — sheen */
|
||||
.heap-bar-track,
|
||||
.status-task-bar { position: relative; overflow: hidden; }
|
||||
.heap-bar-track::after,
|
||||
.status-task-bar::after {
|
||||
/* ================= 堆 / 任务条:光泽扫过 =================
|
||||
* shimmer 改动 transform(见上方关键帧注释):十几根条常驻扫光
|
||||
* 不再每帧触发主线程重绘,视觉效果不变。 */
|
||||
.heap-bar-track, .status-task-bar { position: relative; overflow: hidden; }
|
||||
.heap-bar-track::after, .status-task-bar::after {
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.12) 50%, transparent 100%);
|
||||
transform: translateX(-100%);
|
||||
animation: shimmer 2.6s linear infinite;
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2.8s linear infinite;
|
||||
will-change: transform;
|
||||
pointer-events: none;
|
||||
}
|
||||
.heap-bar-fill,
|
||||
.status-task-bar-fill { transition: width .6s cubic-bezier(.4,0,.2,1); }
|
||||
.heap-bar-fill, .status-task-bar-fill { transition: width .6s cubic-bezier(.4,0,.2,1); }
|
||||
|
||||
/* Log lines — fresh entry fade-in */
|
||||
/* ================= 日志新行淡入 ================= */
|
||||
.log-line { animation: fadeIn .25s ease both; }
|
||||
|
||||
/* Toast — pop and float */
|
||||
/* ================= Toast 弹出 ================= */
|
||||
.toast.show { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; }
|
||||
|
||||
/* Reduced motion — respect user preference */
|
||||
/* ================= 降低动态偏好 ================= */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .001ms !important;
|
||||
|
||||
+65
-20
@@ -1,7 +1,8 @@
|
||||
<!--
|
||||
index.html - ESP32 OTA 中继控制台主页面
|
||||
index.html - ESP32 OTA 中继控制台主页面(DCU Mission Control · 美化版)
|
||||
支持 WiFi OTA(ESP32 本机 + GD32 中继)和 BLE OTA 双通道固件升级。
|
||||
配套脚本:app.js (WiFi OTA), ble_ota.js (BLE OTA), style.css (UI 样式)
|
||||
配套脚本:app.js (WiFi OTA), ble_ota.js (BLE OTA)
|
||||
子系统配色:ESP32=青绿 · GD32=琥珀 · BLE=蓝 · CAN=绿 · 内存/调试=紫
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
@@ -9,20 +10,29 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32 OTA 中继控制台</title>
|
||||
<link rel="stylesheet" href="css/base.css?v=7d5f4eef">
|
||||
<link rel="stylesheet" href="css/layout.css?v=a3e0b7e2">
|
||||
<link rel="stylesheet" href="css/components.css?v=475fa1ac">
|
||||
<link rel="stylesheet" href="css/can.css?v=8a9342b7">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=61b66182">
|
||||
<link rel="stylesheet" href="css/base.css?v=857f83a5">
|
||||
<link rel="stylesheet" href="css/layout.css?v=acd17001">
|
||||
<link rel="stylesheet" href="css/components.css?v=7bc7d5db">
|
||||
<link rel="stylesheet" href="css/can.css?v=c06f5688">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=46449483">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<!-- ====== HEADER: compact status bar ====== -->
|
||||
<!-- ====== HEADER: 命令栏 ====== -->
|
||||
<header class="header-bar">
|
||||
<div class="header-brand">
|
||||
<span class="dot-mark"></span>
|
||||
<strong>ESP32 OTA 中继控制台</strong>
|
||||
<span class="brand-logo" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2.5"/>
|
||||
<rect x="9.5" y="9.5" width="5" height="5" rx="1" fill="currentColor" stroke="none" opacity=".85"/>
|
||||
<path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="brand-text">
|
||||
<strong>ESP32 OTA 中继控制台</strong>
|
||||
<span class="brand-sub">DCU · Robot_v2 · Relay</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<span class="pill offline" id="pillConn"><span class="indicator"></span><span id="pillConnTxt">未连接</span></span>
|
||||
@@ -44,7 +54,7 @@
|
||||
<div>
|
||||
|
||||
<!-- 1. Connection -->
|
||||
<div class="card">
|
||||
<div class="card card--ble">
|
||||
<div class="card-head"><span class="ico">🔗</span> 设备连接
|
||||
<span id="bleHeadActions" style="display:none;margin-left:auto;">
|
||||
<button id="connectBtn" class="btn btn-sm btn-primary">连接设备</button>
|
||||
@@ -70,7 +80,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ====== Shared Target + Firmware + Actions ====== -->
|
||||
<div class="card">
|
||||
<div class="card card--gd32">
|
||||
<div class="card-head"><span class="ico">🎯</span> 升级目标、固件文件与执行操作</div>
|
||||
<div class="card-body">
|
||||
<div style="display:flex;gap:6px;margin-bottom:14px;">
|
||||
@@ -172,7 +182,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 4. Flash Progress + Stage Flow -->
|
||||
<div class="card">
|
||||
<div class="card card--esp32">
|
||||
<div class="card-head"><span class="ico">⚡</span> 刷写进度与流程阶段</div>
|
||||
<div class="card-body">
|
||||
<!-- Phase step indicators -->
|
||||
@@ -237,7 +247,7 @@
|
||||
<div>
|
||||
|
||||
<!-- Primary status -->
|
||||
<div class="card">
|
||||
<div class="card card--status">
|
||||
<div class="card-head"><span class="ico">📡</span> 设备状态</div>
|
||||
<div class="card-body">
|
||||
<div class="badge-grid">
|
||||
@@ -273,7 +283,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Secondary metrics -->
|
||||
<div class="card">
|
||||
<div class="card card--mem">
|
||||
<div class="card-head"><span class="ico">📊</span> 运行指标</div>
|
||||
<div class="card-body">
|
||||
<div class="metric-grid">
|
||||
@@ -287,7 +297,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="display:none">
|
||||
<div class="card card--mem" style="display:none">
|
||||
<div class="card-head">
|
||||
<span class="ico">🧾</span> 系统状态 (/system/status)
|
||||
<button class="btn btn-ghost btn-sm card-head-btn" onclick="refreshSystemStatusNow(event)">刷新</button>
|
||||
@@ -302,7 +312,7 @@
|
||||
</div>
|
||||
|
||||
<!-- CAN workspace entry -->
|
||||
<div class="card collapsed">
|
||||
<div class="card card--can collapsed">
|
||||
<div class="card-head" onclick="toggleCollapse(this)">
|
||||
<span class="ico">📨</span> CAN 通信页
|
||||
<span class="collapse-arrow">▼</span>
|
||||
@@ -338,11 +348,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Motor workspace entry -->
|
||||
<div class="card card--motor collapsed">
|
||||
<div class="card-head" onclick="toggleCollapse(this)">
|
||||
<span class="ico">⚙️</span> 电机控制页
|
||||
<span class="collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="can-launch-card motor-launch-card">
|
||||
<div class="can-launch-copy">
|
||||
<div class="can-launch-top">
|
||||
<div>
|
||||
<div class="can-launch-kicker">Motor Workspace</div>
|
||||
<h3>电机 CAN FD 控制台</h3>
|
||||
</div>
|
||||
<span class="can-launch-badge">CMD / STATUS</span>
|
||||
</div>
|
||||
<p class="can-launch-desc">节点总览、CMD 控制、CONFIG/VERSION、电机 OTA。与主页共用设备 IP(<code>dcu_esp_ip</code>),本页不提供连接配置,进入后自动探测并轮询 <code>/motor/state</code>。</p>
|
||||
<div class="can-launch-points">
|
||||
<span class="can-launch-point">多节点在线状态</span>
|
||||
<span class="can-launch-point">周期/单次 CMD</span>
|
||||
<span class="can-launch-point">共用主页连接</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="can-launch-actions">
|
||||
<a class="btn btn-primary can-launch-primary motor-launch-primary" href="motor.html">进入电机控制</a>
|
||||
<button class="btn btn-outline can-launch-secondary" type="button" onclick="window.open('motor.html','_blank','noopener')">新窗口打开</button>
|
||||
</div>
|
||||
<div class="can-launch-foot">
|
||||
<span class="can-launch-foot-label">说明</span>
|
||||
<span>请先在上方「设备连接」配置并测试 ESP32 IP;电机页只消费同一连接,不单独建连。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log panel -->
|
||||
<div class="card log-panel">
|
||||
<div class="card card--mem log-panel">
|
||||
<div class="card-head" style="justify-content:space-between;">
|
||||
<span><span class="ico">🖥️</span> 调试日志</span>
|
||||
<span style="font-size:11px;font-weight:400;color:var(--text-label);text-transform:none;letter-spacing:0;">UDP :6666</span>
|
||||
<span style="font-size:11px;font-weight:400;color:var(--text-label);text-transform:none;letter-spacing:0;font-family:var(--font-mono);">UDP :6666</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="log-toolbar">
|
||||
@@ -374,7 +419,7 @@
|
||||
<span class="sys-drawer-rail-ico">🧠</span>
|
||||
<span class="sys-drawer-rail-text">堆与任务栈</span>
|
||||
</div>
|
||||
<div class="sys-drawer-panel card">
|
||||
<div class="sys-drawer-panel card card--mem">
|
||||
<div class="card-head sys-drawer-head">
|
||||
<span class="ico">🧠</span> 堆与任务栈
|
||||
<button class="btn btn-ghost btn-sm card-head-btn" type="button" onclick="refreshSystemStatusNow(event)">刷新</button>
|
||||
|
||||
+61
-7
@@ -31,6 +31,43 @@ function formatTime(date) {
|
||||
/** 简单延时 helper,用于批次间间隔 */
|
||||
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
|
||||
|
||||
// ==================== Click-to-Copy ====================
|
||||
/** 将文本写入剪贴板并在元素上闪烁 copied 提示 */
|
||||
function copyCell(text, target) {
|
||||
if (!text) return;
|
||||
function onDone() {
|
||||
target.classList.add('copied');
|
||||
setTimeout(function () { target.classList.remove('copied'); }, 1200);
|
||||
showToast('已复制', 'success');
|
||||
}
|
||||
function fallback() {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;left:-9999px;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); onDone(); } catch (_) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(onDone).catch(fallback);
|
||||
} else {
|
||||
fallback();
|
||||
}
|
||||
}
|
||||
|
||||
/** 在 historyList 上绑定一次事件代理 */
|
||||
var _historyCopyBound = false;
|
||||
function ensureHistoryCopyHandler() {
|
||||
if (_historyCopyBound || !el.historyList) return;
|
||||
_historyCopyBound = true;
|
||||
el.historyList.addEventListener('click', function (e) {
|
||||
var target = e.target.closest('[data-copy]');
|
||||
if (!target) return;
|
||||
copyCell(target.getAttribute('data-copy'), target);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 发送错误码描述 ====================
|
||||
var CAN_SEND_ERR_DESC = {
|
||||
'-1': '参数错误(通道/ID/DLC 无效)',
|
||||
@@ -67,6 +104,17 @@ function buildSendResponseText(result) {
|
||||
return '发送失败: ' + desc + ' (code=' + result.code + ')';
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 历史列表用的短文案:红色标签已表明失败,去掉「发送失败:」前缀、
|
||||
* gd32_err 细分与 (code=) 尾巴,只保留核心原因,避免挤占空间(完整原因见 hover/复制)。
|
||||
*/
|
||||
function buildSendResponseShort(result) {
|
||||
if (result.status === 'ok') {
|
||||
return result.recv ? ('0x' + result.recv) : 'OK';
|
||||
}
|
||||
return CAN_SEND_ERR_DESC[String(result.code)] || ('code=' + result.code);
|
||||
}
|
||||
|
||||
// ==================== HTTP 请求(唯一入口 /can/send_batch)====================
|
||||
/**
|
||||
* @description 同通道发送 1~N 帧。单帧也走 batch(frames 长度 1)。
|
||||
@@ -141,7 +189,9 @@ function makeHistoryEntry(opts) {
|
||||
idHex: opts.idHex,
|
||||
dlc: opts.dlc,
|
||||
dataHex: opts.dataHex || '',
|
||||
resultText: opts.resultText
|
||||
resultText: opts.resultText,
|
||||
/* 列表显示用短文案;缺省回退到完整文案,兼容旧持久化数据 */
|
||||
resultShort: opts.resultShort || opts.resultText
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,6 +211,7 @@ function addHistoryEntry(entry, opts) {
|
||||
/** @description 渲染发送历史列表(单行紧凑:状态|时间|CH·ID·DLC|HEX|结果)。 */
|
||||
export function renderHistory() {
|
||||
if (!el.historyList) return;
|
||||
ensureHistoryCopyHandler();
|
||||
if (!state.history.length) {
|
||||
el.historyList.innerHTML = '<div class="status-empty">尚未发送任何 CAN 帧。</div>';
|
||||
return;
|
||||
@@ -171,9 +222,9 @@ export function renderHistory() {
|
||||
'<div class="history-item history-item-row' + (item.ok ? '' : ' is-err') + '">' +
|
||||
'<span class="history-item-tag ' + (item.ok ? 'ok' : 'err') + '">' + escapeHtml(item.mode) + '</span>' +
|
||||
'<span class="history-item-time">' + escapeHtml(item.time) + '</span>' +
|
||||
'<span class="history-item-main" title="' + escapeHtml(meta) + '">' + escapeHtml(meta) + '</span>' +
|
||||
'<span class="history-item-hex" title="' + escapeHtml(item.dataHex || '') + '">' + escapeHtml(item.dataHex || '--') + '</span>' +
|
||||
'<span class="history-item-foot" title="' + escapeHtml(item.resultText || '') + '">' + escapeHtml(item.resultText) + '</span>' +
|
||||
'<span class="history-item-main copyable" data-copy="' + escapeHtml(meta) + '" title="点击复制">' + escapeHtml(meta) + '</span>' +
|
||||
'<span class="history-item-hex copyable" data-copy="' + escapeHtml(item.dataHex || '') + '" title="点击复制">' + escapeHtml(item.dataHex || '--') + '</span>' +
|
||||
'<span class="history-item-foot copyable" data-copy="' + escapeHtml(item.resultText || '') + '" title="' + escapeHtml(item.resultText || '点击复制') + '">' + escapeHtml(item.resultShort || item.resultText) + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
@@ -254,7 +305,8 @@ export async function sendCheckedMessages() {
|
||||
addHistoryEntry(makeHistoryEntry({
|
||||
mode: 'BATCH', ok: result.status === 'ok', channel: channel,
|
||||
idHex: item.idHex, dlc: item.dlc, dataHex: item.dataHex,
|
||||
resultText: item.msg.name + ' · ' + buildSendResponseText(result)
|
||||
resultText: item.msg.name + ' · ' + buildSendResponseText(result),
|
||||
resultShort: item.msg.name + ' · ' + buildSendResponseShort(result)
|
||||
}), silent);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -311,7 +363,8 @@ export async function sendSelectedMessage() {
|
||||
mode: 'DBC', ok: result.status === 'ok', channel: channel,
|
||||
idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3),
|
||||
dlc: msg.dlc, dataHex: dataHex,
|
||||
resultText: buildSendResponseText(result)
|
||||
resultText: buildSendResponseText(result),
|
||||
resultShort: buildSendResponseShort(result)
|
||||
}));
|
||||
showToast(result.status === 'ok' ? 'DBC 报文发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error');
|
||||
} catch (err) {
|
||||
@@ -391,7 +444,8 @@ export async function sendManualFrame() {
|
||||
mode: 'RAW', ok: result.status === 'ok', channel: channel,
|
||||
idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3),
|
||||
dlc: dlc, dataHex: dataHex,
|
||||
resultText: buildSendResponseText(result)
|
||||
resultText: buildSendResponseText(result),
|
||||
resultShort: buildSendResponseShort(result)
|
||||
}));
|
||||
showToast(result.status === 'ok' ? '原始帧发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error');
|
||||
} catch (err) {
|
||||
|
||||
@@ -8,6 +8,30 @@ var state = { fbTimer: null, fbInFlight: false };
|
||||
|
||||
var el = {};
|
||||
|
||||
// ── Click-to-Copy (feedback 值单元格) ──────────────────────
|
||||
function copyFbVal(text, target) {
|
||||
if (!text || text === '--') return;
|
||||
function onDone() {
|
||||
target.classList.add('copied');
|
||||
setTimeout(function () { target.classList.remove('copied'); }, 1200);
|
||||
showToast('已复制', 'success');
|
||||
}
|
||||
function fallback() {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;left:-9999px;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); onDone(); } catch (_) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(onDone).catch(fallback);
|
||||
} else {
|
||||
fallback();
|
||||
}
|
||||
}
|
||||
|
||||
var ids = [
|
||||
'monIp', 'monTestBtn', 'monConnPill', 'monConnTxt',
|
||||
'monConnIndicator', 'monConnIndicatorTxt', 'monHelper',
|
||||
@@ -293,6 +317,15 @@ export function initCanMonitor() {
|
||||
if (el.btnModeBridge) el.btnModeBridge.addEventListener('click', function () { setCanMode(2); });
|
||||
if (el.idsnapChannel) el.idsnapChannel.addEventListener('change', pollFeedback);
|
||||
|
||||
/* feedback 值 click-to-copy:在 grid 容器上事件代理 */
|
||||
document.querySelectorAll('.feedback-grid').forEach(function (grid) {
|
||||
grid.addEventListener('click', function (e) {
|
||||
var target = e.target.closest('.fb-val');
|
||||
if (!target) return;
|
||||
copyFbVal(target.textContent.trim(), target);
|
||||
});
|
||||
});
|
||||
|
||||
/* 通道卡:默认折叠详情,点标题展开/收起 */
|
||||
document.querySelectorAll('.feedback-channel .feedback-ch-head').forEach(function (head) {
|
||||
var toggle = function (e) {
|
||||
|
||||
+216
-73
@@ -19,21 +19,55 @@ import {
|
||||
appendHeapLowRecords, loadHeapLowHistory, clearHeapLowHistory, HEAP_HISTORY_MAX
|
||||
} from './heap-low-history.js';
|
||||
|
||||
// ==================== 渲染缓存(性能) ====================
|
||||
// /system/status 每 1s(中继期 400ms)刷一次 UI。此前每轮全量 innerHTML 重建
|
||||
// CPU/任务栈列表 + 无条件写几十个节点,引发周期性样式重算/重排/重绘掉帧。
|
||||
// 这里统一三条策略:静态元素只查一次、文本/宽度/类名写前比较、列表增量更新。
|
||||
var elCache = {}; // id -> element(页面静态节点,仅首次 getElementById)
|
||||
function getEl(id) {
|
||||
var el = elCache[id];
|
||||
if (el === undefined || el === null) {
|
||||
el = document.getElementById(id);
|
||||
if (el) elCache[id] = el; // 未找到不缓存,等节点出现后再查
|
||||
}
|
||||
return el || null;
|
||||
}
|
||||
/** 写前比较的 textContent 更新:值未变则不触碰 DOM,避免无谓样式失效。 */
|
||||
function setTextById(id, value) {
|
||||
var el = getEl(id);
|
||||
if (!el) return;
|
||||
var v = String(value);
|
||||
if (el.textContent !== v) el.textContent = v;
|
||||
}
|
||||
function setClassIfChanged(el, cls) {
|
||||
if (el && el.className !== cls) el.className = cls;
|
||||
}
|
||||
function setTextIfChanged(el, txt) {
|
||||
if (el && el.textContent !== txt) el.textContent = txt;
|
||||
}
|
||||
/** 写前比较的宽度更新:用元素上的 _lastW 缓存避免读回 style 字符串。 */
|
||||
function setWidthIfChanged(el, widthStr) {
|
||||
if (el && el._lastW !== widthStr) {
|
||||
el._lastW = widthStr;
|
||||
el.style.width = widthStr;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 连接管理 ====================
|
||||
export function updateConnectionUi(connected, text) {
|
||||
var label = text || (connected ? '已连接' : '未连接');
|
||||
// 仅顶栏连接胶囊(下方连接卡不再重复显示文案)
|
||||
var pc = document.getElementById('pillConn');
|
||||
var pct = document.getElementById('pillConnTxt');
|
||||
var pc = getEl('pillConn');
|
||||
var pct = getEl('pillConnTxt');
|
||||
if (pc) {
|
||||
pc.className = connected ? 'pill online' : 'pill offline';
|
||||
if (pct) pct.textContent = label;
|
||||
setClassIfChanged(pc, connected ? 'pill online' : 'pill offline');
|
||||
if (pct) setTextIfChanged(pct, label);
|
||||
}
|
||||
// 系统状态卡徽章
|
||||
var bc = document.getElementById('bConn');
|
||||
var bc = getEl('bConn');
|
||||
if (bc) {
|
||||
bc.textContent = connected ? '在线' : '离线';
|
||||
bc.className = 'v ' + (connected ? 'ok' : 'err');
|
||||
setTextIfChanged(bc, connected ? '在线' : '离线');
|
||||
setClassIfChanged(bc, 'v ' + (connected ? 'ok' : 'err'));
|
||||
}
|
||||
}
|
||||
export function updateHealthUi(healthCode, freeHeap) {
|
||||
@@ -41,13 +75,12 @@ export function updateHealthUi(healthCode, freeHeap) {
|
||||
var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知';
|
||||
/* 连接文案由 updateConnectionUi / 顶栏 pill 负责,不在此拼「设备在线 · 系统正常」 */
|
||||
if (freeHeap !== undefined) {
|
||||
var mHeapEl = document.getElementById('mHeap');
|
||||
if (mHeapEl) mHeapEl.textContent = formatBytes(freeHeap);
|
||||
setTextById('mHeap', formatBytes(freeHeap));
|
||||
}
|
||||
var mh = document.getElementById('mHealth');
|
||||
var mh = getEl('mHealth');
|
||||
if (mh) {
|
||||
mh.textContent = health;
|
||||
mh.className = 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err');
|
||||
setTextIfChanged(mh, health);
|
||||
setClassIfChanged(mh, 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err'));
|
||||
}
|
||||
ensureHealthBootToggleBound();
|
||||
}
|
||||
@@ -86,11 +119,22 @@ function ensureHealthBootToggleBound() {
|
||||
}
|
||||
|
||||
/** 启动自检内容(/system/status.boot);挂在健康状态展开区,非常驻。 */
|
||||
/** 启动自检渲染签名:boot 数据启动后不变,签名相同就跳过整段 DOM 重建。 */
|
||||
var bootUiSig = null;
|
||||
|
||||
function updateBootInitUi(data) {
|
||||
var boot = data && data.boot ? data.boot : null;
|
||||
var resultEl = document.getElementById('mBootResult');
|
||||
var failedEl = document.getElementById('mBootFailed');
|
||||
var listEl = document.getElementById('bootModuleList');
|
||||
/* 签名 = 影响渲染的全部字段;每秒轮询里 boot 几乎永远不变,直接短路 */
|
||||
var sig = boot
|
||||
? [boot.mask, boot.critical_ok, boot.all_ok, boot.summary, boot.failed,
|
||||
Array.isArray(boot.modules) ? boot.modules.length : 0].join('|')
|
||||
: 'none';
|
||||
if (sig === bootUiSig) return;
|
||||
bootUiSig = sig;
|
||||
|
||||
var resultEl = getEl('mBootResult');
|
||||
var failedEl = getEl('mBootFailed');
|
||||
var listEl = getEl('bootModuleList');
|
||||
if (!boot) {
|
||||
if (resultEl) {
|
||||
resultEl.textContent = '--';
|
||||
@@ -146,23 +190,128 @@ function updateBootInitUi(data) {
|
||||
ensureHealthBootToggleBound();
|
||||
}
|
||||
|
||||
/* ==================== 状态任务列表:增量渲染 ====================
|
||||
* innerHTML 全量重建会重解析 HTML、重建每条 .status-task-bar(其 ::after 微光
|
||||
* 动画随之重启、合成层反复创建销毁),是主页每秒掉帧的主要来源之一。
|
||||
* 这里按 key(任务名)缓存行节点:集合不变时只改数字/宽度,排序变了仅移动节点。
|
||||
* 行结构统一:head(名称/占比) + bar + 3 项 meta。
|
||||
* items: [{ key, rowCls, pctText, width, fillCls, metaLabels[3], metas[3] }]
|
||||
*/
|
||||
function buildStatusTaskRowHtml(it) {
|
||||
return '' +
|
||||
'<div class="' + it.rowCls + '">' +
|
||||
'<div class="status-task-head">' +
|
||||
'<div class="status-task-name">' + escapeHtml(it.key) + '</div>' +
|
||||
'<div class="status-task-ratio">' + escapeHtml(it.pctText) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="status-task-bar"><div class="' + it.fillCls + '" style="width:' + it.width + '"></div></div>' +
|
||||
'<div class="status-task-meta">' +
|
||||
'<div><span>' + it.metaLabels[0] + '</span><b>' + escapeHtml(it.metas[0]) + '</b></div>' +
|
||||
'<div><span>' + it.metaLabels[1] + '</span><b>' + escapeHtml(it.metas[1]) + '</b></div>' +
|
||||
'<div><span>' + it.metaLabels[2] + '</span><b>' + escapeHtml(it.metas[2]) + '</b></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
function updateStatusTaskRow(row, it) {
|
||||
var refs = row._refs;
|
||||
if (!refs) {
|
||||
refs = row._refs = {
|
||||
ratio: row.querySelector('.status-task-ratio'),
|
||||
fill: row.querySelector('.status-task-bar > div'),
|
||||
metas: row.querySelectorAll('.status-task-meta b')
|
||||
};
|
||||
}
|
||||
setTextIfChanged(refs.ratio, it.pctText);
|
||||
setClassIfChanged(refs.fill, it.fillCls);
|
||||
setWidthIfChanged(refs.fill, it.width);
|
||||
setTextIfChanged(refs.metas[0], it.metas[0]);
|
||||
setTextIfChanged(refs.metas[1], it.metas[1]);
|
||||
setTextIfChanged(refs.metas[2], it.metas[2]);
|
||||
}
|
||||
/** 空态提示:内容未变不重写,并清掉行缓存让下次有数据时重建。 */
|
||||
function setListEmpty(listEl, html) {
|
||||
var cache = listEl._rowCache;
|
||||
if (cache) { cache.rows = {}; cache.keys = null; }
|
||||
if (listEl._emptyHtml !== html) {
|
||||
listEl._emptyHtml = html;
|
||||
listEl.innerHTML = html;
|
||||
}
|
||||
}
|
||||
function renderStatusTaskList(listEl, items) {
|
||||
listEl._emptyHtml = null;
|
||||
var cache = listEl._rowCache || (listEl._rowCache = { keys: null, rows: {} });
|
||||
var keys = [];
|
||||
var seen = {};
|
||||
var hasDup = false;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var k = items[i].key;
|
||||
if (seen[k]) hasDup = true;
|
||||
seen[k] = true;
|
||||
keys.push(k);
|
||||
}
|
||||
var keyStr = keys.join('\u0001');
|
||||
var sameSet = !hasDup && keys.length === Object.keys(cache.rows).length &&
|
||||
keys.every(function (k2) { return !!cache.rows[k2]; });
|
||||
if (!sameSet) {
|
||||
/* 集合变化(或同名任务无法索引):一次性重建并缓存行引用 */
|
||||
listEl.innerHTML = items.map(buildStatusTaskRowHtml).join('');
|
||||
cache.rows = {};
|
||||
cache.keys = null;
|
||||
if (!hasDup) {
|
||||
for (var c = 0; c < listEl.children.length; c++) cache.rows[keys[c]] = listEl.children[c];
|
||||
cache.keys = keyStr;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (keyStr !== cache.keys) {
|
||||
/* 集合相同仅排序变化:按新顺序移动既有节点,不重建、不解析 HTML */
|
||||
for (var m = 0; m < keys.length; m++) listEl.appendChild(cache.rows[keys[m]]);
|
||||
cache.keys = keyStr;
|
||||
}
|
||||
for (var u = 0; u < items.length; u++) updateStatusTaskRow(cache.rows[keys[u]], items[u]);
|
||||
}
|
||||
|
||||
/* 原始 JSON 折叠面板:收起时跳过 JSON.stringify + 大段文本重排;
|
||||
* 展开瞬间用 window.latestSystemStatus 补渲染,行为与之前一致。 */
|
||||
var statusJsonBound = false;
|
||||
function renderStatusJsonIfVisible(data) {
|
||||
var statusJson = getEl('statusJson');
|
||||
if (!statusJson) return;
|
||||
var fold = getEl('statusJsonFold');
|
||||
if (!statusJsonBound && fold) {
|
||||
statusJsonBound = true;
|
||||
var head = fold.querySelector('.status-subfold-head');
|
||||
var renderLatest = function () {
|
||||
/* 内联 onclick/onkeydown 已先切换 collapsed,这里只补展开后的内容 */
|
||||
if (!fold.classList.contains('collapsed') && window.latestSystemStatus) {
|
||||
statusJson.textContent = JSON.stringify(window.latestSystemStatus, null, 2);
|
||||
}
|
||||
};
|
||||
if (head) {
|
||||
head.addEventListener('click', renderLatest);
|
||||
head.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') renderLatest();
|
||||
});
|
||||
}
|
||||
}
|
||||
if (fold && fold.classList.contains('collapsed')) return;
|
||||
statusJson.textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
export function updateHeapAndTaskStatusUi(data) {
|
||||
if (!data) return;
|
||||
var formatPercent = window.formatPercent;
|
||||
var ota = data.ota || {};
|
||||
var setText = function (id, value) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
};
|
||||
var setText = function (id, value) { setTextById(id, value); };
|
||||
|
||||
updateBootInitUi(data);
|
||||
|
||||
// ---- 堆概况:已用 / 总计 + 占用条 ----
|
||||
function setFill(id, pct) {
|
||||
var el = document.getElementById(id);
|
||||
var el = getEl(id);
|
||||
if (!el) return;
|
||||
el.style.width = Math.max(0, Math.min(100, pct)) + '%';
|
||||
el.className = 'heap-bar-fill' + (pct >= 85 ? ' danger' : pct >= 70 ? ' warn' : '');
|
||||
setWidthIfChanged(el, Math.max(0, Math.min(100, pct)) + '%');
|
||||
setClassIfChanged(el, 'heap-bar-fill' + (pct >= 85 ? ' danger' : pct >= 70 ? ' warn' : ''));
|
||||
}
|
||||
function setBar(usageId, fillId, pctId, free, total) {
|
||||
if (free === undefined || total === undefined) {
|
||||
@@ -176,15 +325,15 @@ export function updateHeapAndTaskStatusUi(data) {
|
||||
setText(pctId, pct.toFixed(1) + '%');
|
||||
}
|
||||
function setCpuFill(pct) {
|
||||
var el = document.getElementById('cpuUsageFill');
|
||||
var el = getEl('cpuUsageFill');
|
||||
if (!el) return;
|
||||
var safePct = Math.max(0, Math.min(100, Number(pct) || 0));
|
||||
el.style.width = safePct + '%';
|
||||
el.className = 'heap-bar-fill cpu-fill' + (safePct >= 85 ? ' danger' : safePct >= 70 ? ' warn' : '');
|
||||
setWidthIfChanged(el, safePct + '%');
|
||||
setClassIfChanged(el, 'heap-bar-fill cpu-fill' + (safePct >= 85 ? ' danger' : safePct >= 70 ? ' warn' : ''));
|
||||
}
|
||||
function updateCpuUsageUi(cpuData) {
|
||||
cpuData = cpuData || {};
|
||||
var taskList = document.getElementById('cpuTaskList');
|
||||
var taskList = getEl('cpuTaskList');
|
||||
if (!cpuData.available) {
|
||||
setText('cpuUsageText', '未启用 FreeRTOS 统计');
|
||||
setText('cpuUsagePct', '--');
|
||||
@@ -192,7 +341,7 @@ export function updateHeapAndTaskStatusUi(data) {
|
||||
setText('cpuSampleMs', '--');
|
||||
setText('cpuTaskCount', '--');
|
||||
setCpuFill(0);
|
||||
if (taskList) taskList.innerHTML = '<div class="status-empty">固件未启用 CPU runtime stats</div>';
|
||||
if (taskList) setListEmpty(taskList, '<div class="status-empty">固件未启用 CPU runtime stats</div>');
|
||||
return;
|
||||
}
|
||||
if (!cpuData.valid) {
|
||||
@@ -202,15 +351,19 @@ export function updateHeapAndTaskStatusUi(data) {
|
||||
setText('cpuSampleMs', '--');
|
||||
setText('cpuTaskCount', '--');
|
||||
setCpuFill(0);
|
||||
if (taskList) taskList.innerHTML = '<div class="status-empty">首次刷新只建立基线,下一次刷新后显示 CPU 占用</div>';
|
||||
if (taskList) setListEmpty(taskList, '<div class="status-empty">首次刷新只建立基线,下一次刷新后显示 CPU 占用</div>');
|
||||
return;
|
||||
}
|
||||
var usagePct = Number(cpuData.usage_pct) || 0;
|
||||
var idlePct = Number(cpuData.idle_pct) || 0;
|
||||
var sampleMs = Number(cpuData.sample_ms) || 0;
|
||||
var tasks = Array.isArray(cpuData.tasks) ? cpuData.tasks.slice() : [];
|
||||
/* 按占用率降序;占比相同再按任务号,保证排序稳定。
|
||||
* renderStatusTaskList 在集合不变时只 appendChild 调序,不重建 DOM。 */
|
||||
tasks.sort(function (a, b) {
|
||||
return (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0);
|
||||
var d = (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0);
|
||||
if (d !== 0) return d;
|
||||
return (Number(a.task_number) || 0) - (Number(b.task_number) || 0);
|
||||
});
|
||||
|
||||
setText('cpuUsageText', 'CPU 已用 ' + formatPercent(usagePct));
|
||||
@@ -221,28 +374,24 @@ export function updateHeapAndTaskStatusUi(data) {
|
||||
setCpuFill(usagePct);
|
||||
|
||||
if (taskList) {
|
||||
/* 按占用降序渲染全部任务;列表容器 CSS 默认露出约 3 条,其余滚动 */
|
||||
/* 按占用降序渲染全部任务;列表容器 CSS 默认露出约 3 条,其余滚动。
|
||||
* 增量更新:集合不变时只改数字/宽度,避免每秒 innerHTML 全量重建 */
|
||||
if (!tasks.length) {
|
||||
taskList.innerHTML = '<div class="status-empty">当前采样没有任务增量</div>';
|
||||
setListEmpty(taskList, '<div class="status-empty">当前采样没有任务增量</div>');
|
||||
} else {
|
||||
taskList.innerHTML = tasks.map(function (task) {
|
||||
renderStatusTaskList(taskList, tasks.map(function (task) {
|
||||
var pct = Number(task.usage_pct) || 0;
|
||||
var delta = Number(task.runtime_delta) || 0;
|
||||
var fillClass = pct >= 85 ? 'err' : pct >= 70 ? 'warn' : '';
|
||||
return '' +
|
||||
'<div class="status-task cpu-task">' +
|
||||
'<div class="status-task-head">' +
|
||||
'<div class="status-task-name">' + escapeHtml(task.name || 'task') + '</div>' +
|
||||
'<div class="status-task-ratio">' + escapeHtml(formatPercent(pct)) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, pct)) + '%"></div></div>' +
|
||||
'<div class="status-task-meta">' +
|
||||
'<div><span>任务号</span><b>' + escapeHtml(task.task_number) + '</b></div>' +
|
||||
'<div><span>增量</span><b>' + escapeHtml(delta) + '</b></div>' +
|
||||
'<div><span>占比</span><b>' + escapeHtml(formatPercent(pct)) + '</b></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
return {
|
||||
key: String(task.name || 'task'),
|
||||
rowCls: 'status-task cpu-task',
|
||||
pctText: formatPercent(pct),
|
||||
width: Math.max(0, Math.min(100, pct)) + '%',
|
||||
fillCls: 'status-task-bar-fill' + (pct >= 85 ? ' err' : pct >= 70 ? ' warn' : ''),
|
||||
metaLabels: ['任务号', '增量', '占比'],
|
||||
metas: [String(task.task_number), String(delta), formatPercent(pct)]
|
||||
};
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,8 +470,8 @@ export function updateHeapAndTaskStatusUi(data) {
|
||||
setText('mRtcFree', '--');
|
||||
setText('mAllocBlocks', '--');
|
||||
['memSegIram', 'memSegData', 'memSegBss', 'memSegUsed', 'memSegFree'].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.style.width = '0%';
|
||||
var el = getEl(id);
|
||||
if (el) setWidthIfChanged(el, '0%');
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -365,10 +514,10 @@ export function updateHeapAndTaskStatusUi(data) {
|
||||
? formatBytes(Number(mem.task_stack_total)) : '--');
|
||||
|
||||
function setSeg(id, bytes) {
|
||||
var el = document.getElementById(id);
|
||||
var el = getEl(id);
|
||||
if (!el) return;
|
||||
var pct = sramTotal > 0 ? (bytes * 100 / sramTotal) : 0;
|
||||
el.style.width = Math.max(0, Math.min(100, pct)) + '%';
|
||||
setWidthIfChanged(el, Math.max(0, Math.min(100, pct)) + '%');
|
||||
}
|
||||
setSeg('memSegIram', iram);
|
||||
setSeg('memSegData', dataSz);
|
||||
@@ -410,12 +559,12 @@ export function updateHeapAndTaskStatusUi(data) {
|
||||
if (Number.isFinite(ota.relay_state)) window.lastRelayState = ota.relay_state;
|
||||
setText('sStatusUpdated', formatTime(new Date()));
|
||||
|
||||
var taskList = document.getElementById('taskStackList');
|
||||
var taskList = getEl('taskStackList');
|
||||
if (taskList) {
|
||||
var taskStack = data.task_stack || {};
|
||||
var names = Object.keys(taskStack);
|
||||
if (!names.length) {
|
||||
taskList.innerHTML = '<div class="status-empty">当前状态未包含任务栈信息</div>';
|
||||
setListEmpty(taskList, '<div class="status-empty">当前状态未包含任务栈信息</div>');
|
||||
} else {
|
||||
/* 峰值占用比例降序;容器默认露出最高 3 条,其余滚动 */
|
||||
names.sort(function (a, b) {
|
||||
@@ -427,34 +576,28 @@ export function updateHeapAndTaskStatusUi(data) {
|
||||
var pb = sb > 0 ? ((Number(ib.peak_used_bytes) || 0) * 100 / sb) : 0;
|
||||
return pb - pa;
|
||||
});
|
||||
taskList.innerHTML = names.map(function (name) {
|
||||
/* 增量更新:任务集合稳定时仅改变化的数值,不重建 DOM */
|
||||
renderStatusTaskList(taskList, names.map(function (name) {
|
||||
var item = taskStack[name] || {};
|
||||
var stackSize = Number(item.stack_size_bytes) || 0;
|
||||
var watermark = Number(item.high_watermark_bytes) || 0;
|
||||
var peakUsed = Number(item.peak_used_bytes) || 0;
|
||||
var usagePct = stackSize > 0 ? (peakUsed * 100 / stackSize) : 0;
|
||||
var fillClass = usagePct >= 85 ? 'err' : usagePct >= 70 ? 'warn' : '';
|
||||
return '' +
|
||||
'<div class="status-task">' +
|
||||
'<div class="status-task-head">' +
|
||||
'<div class="status-task-name">' + escapeHtml(name) + '</div>' +
|
||||
'<div class="status-task-ratio">' + escapeHtml(formatPercent(usagePct)) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, usagePct)) + '%"></div></div>' +
|
||||
'<div class="status-task-meta">' +
|
||||
'<div><span>栈大小</span><b>' + escapeHtml(formatBytes(stackSize)) + '</b></div>' +
|
||||
'<div><span>剩余水位</span><b>' + escapeHtml(formatBytes(watermark)) + '</b></div>' +
|
||||
'<div><span>峰值占用</span><b>' + escapeHtml(formatBytes(peakUsed)) + '</b></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
return {
|
||||
key: name,
|
||||
rowCls: 'status-task',
|
||||
pctText: formatPercent(usagePct),
|
||||
width: Math.max(0, Math.min(100, usagePct)) + '%',
|
||||
fillCls: 'status-task-bar-fill' + (usagePct >= 85 ? ' err' : usagePct >= 70 ? ' warn' : ''),
|
||||
metaLabels: ['栈大小', '剩余水位', '峰值占用'],
|
||||
metas: [formatBytes(stackSize), formatBytes(watermark), formatBytes(peakUsed)]
|
||||
};
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
var statusJson = document.getElementById('statusJson');
|
||||
if (statusJson) {
|
||||
statusJson.textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
/* 原始 JSON:折叠时跳过序列化与大段文本写入(展开瞬间自动补渲染) */
|
||||
renderStatusJsonIfVisible(data);
|
||||
}
|
||||
/** 从设备 IP 推导网关 IP(取前三段 + .1) */
|
||||
function getGatewayIp() {
|
||||
|
||||
+14
-2
@@ -16,6 +16,18 @@ var autoScroll = true;
|
||||
// 缓存 DOM 引用,避免高频日志下重复 getElementById
|
||||
var scrollEl = document.getElementById('logScroll');
|
||||
var countEl = document.getElementById('logCount');
|
||||
var scrollPending = false;
|
||||
|
||||
/** rAF 批量自动滚动:/logs 一轮可能追加几十行,逐行读 scrollHeight 会每行
|
||||
* 触发一次强制布局;合并到下一帧只滚一次,行为不变。 */
|
||||
function scheduleAutoScroll() {
|
||||
if (!autoScroll || !scrollEl || scrollPending) return;
|
||||
scrollPending = true;
|
||||
requestAnimationFrame(function () {
|
||||
scrollPending = false;
|
||||
if (autoScroll && scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 日志系统 (带去重 + 自动滚动) ====================
|
||||
function makeLogHash(level, tag, message) {
|
||||
@@ -65,7 +77,7 @@ export function addLog(level, tag, message) {
|
||||
entry.div = div;
|
||||
if (scrollEl) {
|
||||
scrollEl.appendChild(div);
|
||||
if (autoScroll) scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
scheduleAutoScroll();
|
||||
}
|
||||
syncCount();
|
||||
}
|
||||
@@ -92,7 +104,7 @@ export function setLogFilter(level, btn) {
|
||||
});
|
||||
scrollEl.innerHTML = '';
|
||||
scrollEl.appendChild(frag);
|
||||
if (autoScroll) scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
scheduleAutoScroll();
|
||||
}
|
||||
export function toggleAutoScroll() {
|
||||
autoScroll = !autoScroll;
|
||||
|
||||
@@ -397,7 +397,14 @@ export function setupDropZone(zoneId, chip) {
|
||||
zone.addEventListener('drop', function (e) {
|
||||
e.preventDefault(); e.currentTarget.classList.remove('dragover');
|
||||
var file = e.dataTransfer.files[0];
|
||||
if (file) processFile(file, chip);
|
||||
if (!file) return;
|
||||
/* 拖拽拿不到 FSAPI 句柄:必须清掉旧句柄,否则热重载会把上一个文件读回来
|
||||
覆盖本次拖入的文件(表现为"刷错固件"),并同步删除 IDB 持久化句柄 */
|
||||
if (chip === 'esp32') window.esp32FileHandle = null;
|
||||
else window.gd32FileHandle = null;
|
||||
window._pendingFwHandles[chip] = null;
|
||||
persistFileHandle(chip, null);
|
||||
processFile(file, chip);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -171,12 +171,15 @@ export function cleanupAfterOtaFailure(reason) {
|
||||
setActivePhase(null);
|
||||
window.currentOtaTarget = null;
|
||||
|
||||
// 4. 恢复上传按钮(两枚都解禁并恢复原文案,避免 chip 判错或一侧残留"上传中")
|
||||
['uploadEsp32Btn', 'uploadGd32Btn'].forEach(function (btnId) {
|
||||
// 4. 恢复上传按钮(两枚都解禁并恢复各自原文案,避免 chip 判错或一侧残留"上传中")
|
||||
// ★ 按钮文案是固定的,绝不能用共享的 uploadBtnOrigText 回写两枚,否则最后
|
||||
// 刷 GD32 后 ESP32 按钮会被错写成 "GD32 OTA"(反之亦然)。
|
||||
var uploadBtnLabels = { uploadEsp32Btn: '上传并升级 ESP32', uploadGd32Btn: 'GD32 OTA' };
|
||||
Object.keys(uploadBtnLabels).forEach(function (btnId) {
|
||||
var btn = document.getElementById(btnId);
|
||||
if (!btn) return;
|
||||
btn.disabled = false;
|
||||
if (window.uploadBtnOrigText) btn.textContent = window.uploadBtnOrigText;
|
||||
btn.textContent = uploadBtnLabels[btnId];
|
||||
});
|
||||
window.activeUploadChip = null;
|
||||
window.otaSessionTarget = null;
|
||||
|
||||
+37
-24
@@ -106,6 +106,18 @@ function setDisplayedRelayState(rs) {
|
||||
updateStagesFromRelayState(rs);
|
||||
}
|
||||
|
||||
/* 写前比较小工具:每秒轮询里大部分仪表盘字段不变,
|
||||
* 无条件写 textContent/className/title 会白白触发样式失效。 */
|
||||
function setNodeText(el, txt) {
|
||||
if (el && el.textContent !== txt) el.textContent = txt;
|
||||
}
|
||||
function setNodeClass(el, cls) {
|
||||
if (el && el.className !== cls) el.className = cls;
|
||||
}
|
||||
function setNodeTitle(el, title) {
|
||||
if (el && el.title !== title) el.title = title;
|
||||
}
|
||||
|
||||
/**
|
||||
* 常驻仪表盘:堆、连接、健康、版本、温度、IP/RSSI。
|
||||
* @param {object} data /system/status JSON
|
||||
@@ -122,28 +134,29 @@ function applyDashboardUi(data, ota, wifi) {
|
||||
if (data.version) {
|
||||
var pillVerEl = document.getElementById('pillVer');
|
||||
if (pillVerEl) {
|
||||
pillVerEl.textContent = 'v' + data.version;
|
||||
pillVerEl.dataset.tooltip = data.build_time ? ('编译: ' + data.build_time) : '';
|
||||
setNodeText(pillVerEl, 'v' + data.version);
|
||||
var verTip = data.build_time ? ('编译: ' + data.build_time) : '';
|
||||
if (pillVerEl.dataset.tooltip !== verTip) pillVerEl.dataset.tooltip = verTip;
|
||||
}
|
||||
var mGd32App = document.getElementById('mGd32App');
|
||||
var mGd32Boot = document.getElementById('mGd32Boot');
|
||||
if (mGd32App) {
|
||||
if (ota.gd32_app && ota.gd32_app !== 'v0.0.0.0') {
|
||||
mGd32App.textContent = ota.gd32_app;
|
||||
mGd32App.className = 'v ok';
|
||||
setNodeText(mGd32App, ota.gd32_app);
|
||||
setNodeClass(mGd32App, 'v ok');
|
||||
} else {
|
||||
mGd32App.textContent = ota.gd32_app_alive ? '未知' : '离线';
|
||||
mGd32App.className = 'v';
|
||||
setNodeText(mGd32App, ota.gd32_app_alive ? '未知' : '离线');
|
||||
setNodeClass(mGd32App, 'v');
|
||||
}
|
||||
}
|
||||
if (mGd32Boot) {
|
||||
if (ota.gd32_boot && ota.gd32_boot !== 'v0.0.0.0') {
|
||||
mGd32Boot.textContent = ota.gd32_boot;
|
||||
mGd32Boot.className = 'v ok';
|
||||
setNodeText(mGd32Boot, ota.gd32_boot);
|
||||
setNodeClass(mGd32Boot, 'v ok');
|
||||
} else {
|
||||
/* BOOT 版本与 APP alive 无直接关系,无版本时统一「未知」 */
|
||||
mGd32Boot.textContent = '未知';
|
||||
mGd32Boot.className = 'v';
|
||||
setNodeText(mGd32Boot, '未知');
|
||||
setNodeClass(mGd32Boot, 'v');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,19 +166,19 @@ function applyDashboardUi(data, ota, wifi) {
|
||||
/* 与固件 app_config:TEMP_WARNING=80 / TEMP_CRITICAL=100 对齐 */
|
||||
if (Number.isFinite(data.temperature)) {
|
||||
var tC = data.temperature;
|
||||
mTempEl.textContent = tC.toFixed(1) + '\u00B0C';
|
||||
setNodeText(mTempEl, tC.toFixed(1) + '\u00B0C');
|
||||
if (tC >= 100) {
|
||||
mTempEl.className = 'v err';
|
||||
setNodeClass(mTempEl, 'v err');
|
||||
} else if (tC >= 80) {
|
||||
mTempEl.className = 'v warn';
|
||||
setNodeClass(mTempEl, 'v warn');
|
||||
} else {
|
||||
mTempEl.className = 'v ok';
|
||||
setNodeClass(mTempEl, 'v ok');
|
||||
}
|
||||
mTempEl.title = 'ESP32 片内温度传感器(警告≥80°C,危险≥100°C)';
|
||||
setNodeTitle(mTempEl, 'ESP32 片内温度传感器(警告≥80°C,危险≥100°C)');
|
||||
} else {
|
||||
mTempEl.textContent = '--';
|
||||
mTempEl.className = 'v';
|
||||
mTempEl.title = '温度无效或未采样';
|
||||
setNodeText(mTempEl, '--');
|
||||
setNodeClass(mTempEl, 'v');
|
||||
setNodeTitle(mTempEl, '温度无效或未采样');
|
||||
}
|
||||
}
|
||||
/* GD32 ADC:enabled 来自 HELLO_ACK 功能位(GD32 编译开关),ESP32 无本地开关 */
|
||||
@@ -177,9 +190,9 @@ function applyDashboardUi(data, ota, wifi) {
|
||||
|
||||
function setMetricUi(el, text, cls, title) {
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.className = cls || 'v';
|
||||
if (title) el.title = title;
|
||||
setNodeText(el, text);
|
||||
setNodeClass(el, cls || 'v');
|
||||
if (title) setNodeTitle(el, title);
|
||||
}
|
||||
|
||||
if (mGd32TempEl || mVccEl || mVddaEl) {
|
||||
@@ -233,12 +246,12 @@ function applyDashboardUi(data, ota, wifi) {
|
||||
}
|
||||
}
|
||||
var uptimeEl = document.getElementById('pillUptime');
|
||||
if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec);
|
||||
if (uptimeEl) setNodeText(uptimeEl, '\u23F1 ' + formatUptime(data.uptime_sec));
|
||||
var pillIPEl = document.getElementById('pillIP');
|
||||
if (pillIPEl) pillIPEl.textContent = wifi.ip || '--';
|
||||
if (pillIPEl) setNodeText(pillIPEl, wifi.ip || '--');
|
||||
var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
|
||||
var pillRSSIEl = document.getElementById('pillRSSI');
|
||||
if (pillRSSIEl) pillRSSIEl.textContent = rssi;
|
||||
if (pillRSSIEl) setNodeText(pillRSSIEl, rssi);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -164,11 +164,23 @@ export async function startUpload(chip) {
|
||||
if (chip === 'esp32') window.esp32File = freshFile;
|
||||
else window.gd32File = freshFile;
|
||||
file = freshFile;
|
||||
/* .bin 直读分支此前无任何日志,热重载是否生效不可观察,补一条 */
|
||||
addLog('I', '文件', '热重载: 已重新读取 ' + freshFile.name +
|
||||
' (' + formatBytes(freshFile.size) + ')');
|
||||
}
|
||||
} catch (e) {
|
||||
addLog('W', '文件', '重新读取文件失败: ' + e.message + ',将使用缓存版本');
|
||||
}
|
||||
} else {
|
||||
/* 无 FSAPI 句柄(拖拽/传统方式选择):无法读盘,只能用缓存。
|
||||
GD32 缓存是内存中转换出的 Blob,arrayBuffer 校验永远通过,
|
||||
若不显式告知,本地重新编译后点上传会静默刷入旧固件 */
|
||||
var cachedAt = file.lastModified
|
||||
? new Date(file.lastModified).toLocaleTimeString('zh-CN', { hour12: false })
|
||||
: '未知时间';
|
||||
addLog('W', '文件', '热重载不可用(无 FSAPI 句柄):本次上传缓存文件 ' + file.name +
|
||||
'(磁盘修改于 ' + cachedAt + ')。如需自动读取最新编译产物,请点击上传区重新选择');
|
||||
showToast('热重载不可用,上传的是缓存文件', 'warning');
|
||||
try {
|
||||
await file.arrayBuffer();
|
||||
} catch (e) {
|
||||
|
||||
+3691
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user