feat(shell): tabs 多 iframe 常驻保活,切标签不再销毁功能页

单 iframe 换 src 会卸载文档,WebSocket/轮询/传输状态全灭。
改为一标签一懒创建 iframe,切换只改 visibility;F5/⟳ 只 reload 激活帧。
This commit is contained in:
cbb
2026-09-16 02:37:18 +08:00
parent 9bb804ec35
commit eaafee1312
4 changed files with 157 additions and 24 deletions
+66 -17
View File
@@ -1,9 +1,11 @@
<!--
tabs.html - ESP32 调试控制台「单窗口多标签」外壳
顶部一排标签, 下方 iframe 承载各功能页(pages/ 下功能页, lab/ 下实验页)。
各页面原样复用, 互不干扰; 页面内的"新窗口打开"/普通链接会在 iframe 内跳转,
外壳监听 iframe 的 load 事件同步高亮当前标签。
激活标签存 localStorage(dcu_tab), 下次打开自动恢复。
顶部一排标签, 下方 **多 iframe 宿主** 承载各功能页(pages/ 下功能页, lab/ 下实验页)。
保活约定(2026-09): 每个标签一个懒创建 iframe,切换只改可见性,**不改 src、不销毁**。
否则功能页内的 WebSocket/轮询/传输状态会随文档卸载全部丢失——「切走再切回连接全灭」。
激活标签存 localStorage(dcu_tab), 下次打开自动恢复(只预载该标签)。
-->
<!DOCTYPE html>
<html lang="zh-CN">
@@ -50,7 +52,20 @@
.tabbar .spacer { flex: 1 1 auto; }
.tabbar .hint { display: flex; align-items: center; color: var(--text-label);
font-size: .72rem; padding: 0 8px; }
#view { flex: 1 1 auto; width: 100%; border: none; background: var(--bg-root); display: block; }
/* 多 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>
@@ -60,7 +75,8 @@
<div class="spacer"></div>
<div class="hint">⟳ 刷新当前页 · F5</div>
</nav>
<iframe id="view" title="功能页" src="about:blank"></iframe>
<!-- 外壳标记:注入脚本用 #viewHost 判定「标签外壳」;各功能页是其子 iframe.page-frame -->
<div id="viewHost" data-shell="1"></div>
<script>
(function(){
@@ -73,9 +89,12 @@
{ page: 'lab/spi_test.html', label: 'SPI/ECAT 通信测速', ico: '⚡', color: 'var(--ble)' }
];
var bar = document.getElementById('tabbar');
var view = document.getElementById('view');
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]; }
@@ -88,14 +107,52 @@
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){
view.src = path;
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){} }
}
@@ -111,15 +168,7 @@
bar.insertBefore(b, spacer);
});
// iframe 内导航(页面里的链接/新窗口打开覆写)后, 同步高亮对应标签
view.addEventListener('load', function(){
var p = null;
try { p = view.contentWindow.location.pathname; } catch(e){ return; }
var tab = pageToTab(p);
if (tab) { setActive(tab); try { localStorage.setItem(LS, tab); } catch(e){} }
});
// 初始标签: 上次记忆, 否则 OTA 主页。
// 初始标签: 上次记忆, 否则 OTA 主页。只预载该标签,其余首次点开再创建。
// 取 pageToTab 的返回值而非 localStorage 原值, 这样旧版存的裸文件名
// (can.html) 会被自动迁移成新的 pages/can.html。
var init = 'pages/index.html';