Files
DCU_DesktopController/tabs.html
T
cbb 670b833dc6 feat(link): DeviceLink 外壳单例统一 ESP32/ECAT/BLE 健康态
各页只共用 IP、各自 fetch 导致同屏「已连接/连接失败」分裂。
外壳 Owner 轮询 /system/status 与 EtherCAT 状态,BroadcastChannel
广播快照;连接灯只认共享层,SPI 失败单独文案不覆盖设备离线。
2026-09-16 02:50:12 +08:00

187 lines
7.9 KiB
HTML

<!--
tabs.html - ESP32 调试控制台「单窗口多标签」外壳
顶部一排标签, 下方 **多 iframe 宿主** 承载各功能页(pages/ 下功能页, lab/ 下实验页)。
保活约定(2026-09): 每个标签一个懒创建 iframe,切换只改可见性,**不改 src、不销毁**。
否则功能页内的 WebSocket/轮询/传输状态会随文档卸载全部丢失——「切走再切回连接全灭」。
激活标签存 localStorage(dcu_tab), 下次打开自动恢复(只预载该标签)。
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 调试控制台 — DCU</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/base.css?v=857f83a5">
<style>
html, body { height: 100%; margin: 0; }
body {
background: var(--bg-root); color: var(--text);
font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
display: flex; flex-direction: column; overflow: hidden;
}
.tabbar {
display: flex; align-items: stretch; gap: 2px;
background: var(--bg-panel); border-bottom: 1px solid var(--border);
padding: 0 8px; height: 42px; flex: 0 0 auto;
user-select: none; -webkit-user-select: none;
}
.tabbar .brand {
display: flex; align-items: center; gap: 8px;
padding: 0 12px 0 4px; margin-right: 6px;
border-right: 1px solid var(--border);
font-weight: 600; font-size: .86rem; color: var(--text-dim);
letter-spacing: .02em;
}
.tabbar .brand .dot { width: 9px; height: 9px; border-radius: 50%;
background: var(--esp32); box-shadow: 0 0 8px var(--esp32); }
.tab {
appearance: none; border: none; background: transparent; cursor: pointer;
color: var(--text-dim); font-size: .84rem; font-family: inherit;
padding: 0 16px; display: flex; align-items: center; gap: 7px;
border-bottom: 2px solid transparent; transition: color .12s, background .12s;
white-space: nowrap;
}
.tab:hover { color: var(--text); background: var(--bg-hover); }
.tab.active { color: var(--text); border-bottom-color: var(--esp32); background: var(--bg-card); }
.tab .ico { font-size: .95rem; opacity: .9; }
.tab .accent { width: 7px; height: 7px; border-radius: 50%; opacity: .55; }
.tab.active .accent { opacity: 1; }
.tabbar .spacer { flex: 1 1 auto; }
.tabbar .hint { display: flex; align-items: center; color: var(--text-label);
font-size: .72rem; padding: 0 8px; }
/* 多 iframe 宿主:占满剩余高度;子帧绝对定位铺满。
隐藏用 visibility 而非 display:none —— 少触发引擎对不可见文档的定时器/绘制压制,
WebSocket 与轮询更稳。 */
#viewHost {
flex: 1 1 auto; position: relative; width: 100%;
background: var(--bg-root); overflow: hidden;
}
.page-frame {
position: absolute; left: 0; top: 0; width: 100%; height: 100%;
border: none; background: var(--bg-root);
visibility: hidden; pointer-events: none;
}
.page-frame.active { visibility: visible; pointer-events: auto; }
</style>
</head>
<body>
<nav class="tabbar" id="tabbar">
<div class="brand"><span class="dot"></span>ESP32 调试控制台</div>
<!-- 标签由 TABS 数组渲染 -->
<div class="spacer"></div>
<div class="hint">⟳ 刷新当前页 · F5</div>
</nav>
<!-- 外壳标记:注入脚本用 #viewHost 判定「标签外壳」;各功能页是其子 iframe.page-frame -->
<div id="viewHost" data-shell="1"></div>
<script>
(function(){
var TABS = [
{ page: 'pages/index.html', label: 'OTA 中继', ico: '⬆', color: 'var(--esp32)' },
{ page: 'pages/can.html', label: 'CAN 通信', ico: '⇄', color: 'var(--can)' },
{ page: 'pages/can_monitor.html', label: 'CAN 监控', ico: '👁', color: 'var(--can)' },
{ page: 'pages/motor.html', label: '电机控制', ico: '⚙', color: 'var(--motor)' },
{ page: 'lab/lan9252_pm.html', label: 'LAN9252 电源', ico: '⏻', color: '#a88bfa' },
{ page: 'lab/spi_test.html', label: 'SPI/ECAT 通信测速', ico: '⚡', color: 'var(--ble)' }
];
var bar = document.getElementById('tabbar');
var host = document.getElementById('viewHost');
var spacer = bar.querySelector('.spacer');
var LS = 'dcu_tab';
/** page → HTMLIFrameElement;懒创建后常驻,切标签不销毁。 */
var frames = Object.create(null);
var activePage = null;
function baseName(p){ return (p || '').replace(/^.*\//, '').split('?')[0]; }
/* 按文件名匹配, 与目录层级无关: iframe 里读到的是绝对 pathname
* (/pages/can.html), 而旧版 localStorage 里存的是裸文件名(can.html),
* 两者都能落到同一个标签。返回 TABS 中登记的相对路径。 */
function pageToTab(path){
var b = baseName(path);
if (b === '') return 'pages/index.html';
for (var i=0;i<TABS.length;i++) if (baseName(TABS[i].page) === b) return TABS[i].page;
return null; // 未知页(如从页面内跳到别的 html)
}
function setActive(path){
var tabs = bar.querySelectorAll('.tab');
for (var i=0;i<tabs.length;i++){
tabs[i].classList.toggle('active', tabs[i].getAttribute('data-page') === path);
}
}
/** 懒创建:首次进入某标签才挂 iframe;已存在则原样返回(文档未卸载)。 */
function ensureFrame(page){
var f = frames[page];
if (f) return f;
f = document.createElement('iframe');
f.className = 'page-frame';
f.setAttribute('data-page', page);
f.title = page;
f.src = page;
host.appendChild(f);
frames[page] = f;
f.addEventListener('load', function(){
/* 非激活帧的延迟 load 不得抢高亮/记忆(懒创建时目标可能已切走) */
if (activePage !== page) return;
var p = null;
try { p = f.contentWindow.location.pathname; } catch(e){ return; }
var tab = pageToTab(p);
if (tab && tab === page) {
setActive(tab);
try { localStorage.setItem(LS, tab); } catch(e){}
}
});
return f;
}
/**
* 切换标签:只改可见性。**绝不改 src / remove**——那会销毁文档,
* 把功能页里的 WebSocket、轮询、传输状态一并清掉。
*/
function go(path, save){
var f = ensureFrame(path);
for (var p in frames){
if (!Object.prototype.hasOwnProperty.call(frames, p)) continue;
frames[p].classList.toggle('active', p === path);
}
/* 兜底:目标帧若因异常不在 frames(理论不会),仍标 active */
if (f && !f.classList.contains('active')) f.classList.add('active');
activePage = path;
setActive(path);
if (save !== false) { try { localStorage.setItem(LS, path); } catch(e){} }
}
// 渲染标签
TABS.forEach(function(t){
var b = document.createElement('button');
b.className = 'tab'; b.type = 'button';
b.setAttribute('data-page', t.page);
b.innerHTML = '<span class="accent" style="background:'+t.color+'"></span>'
+ '<span class="ico">'+t.ico+'</span>' + t.label;
b.onclick = function(){ go(t.page); };
bar.insertBefore(b, spacer);
});
// 初始标签: 上次记忆, 否则 OTA 主页。只预载该标签,其余首次点开再创建。
// 取 pageToTab 的返回值而非 localStorage 原值, 这样旧版存的裸文件名
// (can.html) 会被自动迁移成新的 pages/can.html。
var init = 'pages/index.html';
try { var s = localStorage.getItem(LS); var m = s && pageToTab(s); if (m) init = m; } catch(e){}
go(init, false);
})();
</script>
<!-- app 窗口租约: 本外壳窗口活着期间保持与本地服务的连接,
全部窗口关闭后服务端自动停 EtherCAT PDO 循环(见 js/common/app-lease.js) -->
<script src="js/common/app-lease.js"></script>
<!-- DeviceLink Owner:全链路健康轮询只在本外壳跑一次,各 iframe 订阅 -->
<script>window.__dcuDeviceLinkOwner = true;</script>
<script type="module" src="js/common/device-link.js?v=20260916a"></script>
</body>
</html>