feat(shell): tabs 多 iframe 常驻保活,切标签不再销毁功能页
单 iframe 换 src 会卸载文档,WebSocket/轮询/传输状态全灭。 改为一标签一懒创建 iframe,切换只改 visibility;F5/⟳ 只 reload 激活帧。
This commit is contained in:
@@ -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`(按文件名匹配,与目录层级无关;启动只预载该标签):
|
||||
|
||||
| 页面 | 用途 | 主要脚本 |
|
||||
|------|------|----------|
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user