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
+2 -2
View File
@@ -37,8 +37,8 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
## 页面
`tabs.html` 是单窗口多标签外壳,用 iframe 承载下列功能页,激活标签记忆在
`localStorage` 的 `dcu_tab`(按文件名匹配,与目录层级无关):
`tabs.html` 是单窗口多标签外壳,用**多 iframe 常驻保活**承载下列功能页:每个标签一个懒创建 iframe,切换只改可见性、不销毁文档(否则页内 WebSocket/轮询会随导航全灭)。激活标签记忆在
`localStorage` 的 `dcu_tab`(按文件名匹配,与目录层级无关;启动只预载该标签):
| 页面 | 用途 | 主要脚本 |
|------|------|----------|
+76
View File
@@ -0,0 +1,76 @@
---
feature: tab-keepalive
status: designed
updated: 2026-09-16
branch: feat/tab-keepalive
commits:
---
# Tab Keep-Alive(多 iframe 常驻)
## Report
## [S1] Problem
`tabs.html` 用**单 iframe** 承载功能页,切换标签时执行 `view.src = path`。
这会销毁上一页的整个文档:
- WebSocket(`app-lease`、调试通道)断开
- `setInterval` / `setTimeout` 轮询停止
- 电机页 EtherCAT/WiFi 传输状态、`transport-ecat` 的 `started` 标志丢失
- OTA 页 `ecat_load` 轮询、SPI 状态轮询冷启动
- 用户「调完一切、切走再切回」后连接全灭,表现为界面假死/全 `--`
外壳自身的 `app-lease.js` 仍在,但功能页内的连接与状态无法幸存。
这是结构性缺陷,不是某个页面的 bug。
## [S2] Design
### 外壳:一标签一 iframe,只 show/hide
- `TABS` 每项对应一个懒创建 iframe(`class="page-frame"`,`data-page`)。
- 首次进入某标签才 `src=page`;之后切换只改可见性,**不改 src、不 remove**。
- 宿主容器 `id="viewHost"`(保留 `data-shell` 语义:注入脚本用 `#viewHost` 判定外壳)。
- 激活帧:`visibility:visible; pointer-events:auto`;非激活:`visibility:hidden; pointer-events:none`。
**不用 `display:none`**——部分引擎对 display:none iframe 的定时器/绘制更狠;`visibility` + 绝对定位铺满即可。
- 切换时同步 `localStorage.dcu_tab`(沿用现有键与 `pageToTab` 迁移)。
### 状态保全契约
| 状态 | 保活后 |
|------|--------|
| 功能页内 WS / 轮询 / Module 全局 | 留在各自 iframe 文档内,切走不断 |
| 外壳 `app-lease` | 仍由 `tabs.html` 自持(覆盖整个窗口) |
| `ecat_native` 后台 PDO 循环 | 服务端 + 租约决定,与是否隐藏无关 |
| F5 / ⟳ | **只 reload 激活 iframe**,不重载外壳(否则全部保活帧被销毁) |
### 注入刷新脚本(`esp32_server.py` `_RELOAD_SNIPPET`)
- `reloadCur()`:优先 `document.querySelector('.page-frame.active')`;无则退化 `location.reload()`。
- 外壳判定:`#viewHost` 存在(替代原 `#view` iframe 的语义)。
- 功能页内(非 TOP)行为不变:去掉悬浮钮,覆写 `_blank`。
### 导航与高亮
- 点击标签 → `go(page)`:确保帧存在 → 激活目标 → 其它帧隐藏。
- iframe `load`:若 pathname 映射到某 TAB,更新外壳高亮与 `dcu_tab`;
不因非激活帧的延迟 load 误抢激活(仅当该帧已是 active 才写 `dcu_tab`)。
- 未知页(页内跳到未登记 html):仍留在当前 iframe 内显示,外壳不高亮新标签。
### 内存
用户裁定「全部标签常驻」。6 个功能页同时活着的代价可接受;
不引入 LRU 卸载(卸载即回到现在的病)。
## [S3] Out of Scope
- 不合并成 SPA、不把连接迁到外壳进程
- 不改各页内部轮询/传输实现
- 不改服务端租约协议
- 不做「后台页降频」策略(浏览器 Page Visibility 自带节流即可)
## Tasks
- [ ] T1: `tabs.html` 多 iframe 宿主 + 懒创建 + show/hide 切换 — acceptance: 切走再切回,OTA/电机页的轮询与 WS 仍在,无需重新连接 (covers: S2)
- [ ] T2: `esp32_server.py` 注入的 F5/⟳ 只刷新激活帧 — acceptance: 外壳按 F5 仅重载当前标签文档,其它标签 iframe 仍在 DOM 且未 reload (covers: S2)
- [ ] T3: 语法/手工验收路径 — acceptance: `node --check` 若适用;人工路径:开 OTA→电机→切回 OTA,指标不全灭 (covers: S2)
+13 -5
View File
@@ -61,7 +61,8 @@ g_mutex = None
# 注入脚本对三种上下文自适应:
# 1) iframe 内的功能页(标签外壳承载): 移除悬浮按钮, 但仍覆写 window.open/_blank
# 让页面里的"新窗口打开"在 iframe 内跳转, 不弹系统浏览器;
# 2) 顶层标签外壳(tabs.html, 含 #view iframe): ⟳ 刷新当前 iframe, 不显示 ⌂;
# 2) 顶层标签外壳(tabs.html, 含 #viewHost 多 iframe 宿主): ⟳ 只刷新激活的
# .page-frame, 其它标签保活帧不销毁、不 reload;
# 3) 顶层独立页(浏览器直开某 html): ⟳ 刷新自身, 非主页时显示 ⌂ 回到标签外壳。
_RELOAD_SNIPPET = """
<style>#__rl,#__home{position:fixed;right:14px;width:34px;height:34px;z-index:99999;
@@ -82,8 +83,15 @@ user-select:none;opacity:.55;transition:opacity .15s}
if(a&&a.href){e.preventDefault();location.href=a.href;}
},true);
function reloadCur(){
if(TOP){var f=document.getElementById('view');
if(f){try{f.contentWindow.location.reload();}catch(_){f.src=f.src;} return;}}
if(TOP){
// 多 iframe 保活外壳:只 reload 激活帧。重载外壳会销毁全部保活文档。
var f=document.querySelector('.page-frame.active');
if(!f) f=document.getElementById('view');
if(f&&f.tagName==='IFRAME'){
try{f.contentWindow.location.reload();}catch(_){f.src=f.src;}
return;
}
}
location.reload();
}
window.addEventListener('keydown',function(e){
@@ -93,9 +101,9 @@ user-select:none;opacity:.55;transition:opacity .15s}
document.getElementById('__rl').addEventListener('click',reloadCur);
var home=document.getElementById('__home');
var p=location.pathname;
var isShell=!!document.getElementById('view');
var isShell=!!document.getElementById('viewHost')||!!document.getElementById('view');
// 顶层且非标签外壳(浏览器直开 pages/xxx.html) → 给个 ⌂ 回到外壳。
// 判定基准是外壳自身(有 #view / 路径为 tabs.html 或 /), 不能再看 index.html:
// 判定基准是外壳自身(有 #viewHost / 路径为 tabs.html 或 /), 不能再看 index.html:
// 重排后 pages/index.html 只是普通功能页, 直开它同样该显示 ⌂。
if(!isShell&&!/\\/$/.test(p)&&!/\\/tabs\\.html$/.test(p)){
home.style.display='block';
+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';