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 承载下列功能页,激活标签记忆在
|
`tabs.html` 是单窗口多标签外壳,用**多 iframe 常驻保活**承载下列功能页:每个标签一个懒创建 iframe,切换只改可见性、不销毁文档(否则页内 WebSocket/轮询会随导航全灭)。激活标签记忆在
|
||||||
`localStorage` 的 `dcu_tab`(按文件名匹配,与目录层级无关):
|
`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
|
# 1) iframe 内的功能页(标签外壳承载): 移除悬浮按钮, 但仍覆写 window.open/_blank
|
||||||
# 让页面里的"新窗口打开"在 iframe 内跳转, 不弹系统浏览器;
|
# 让页面里的"新窗口打开"在 iframe 内跳转, 不弹系统浏览器;
|
||||||
# 2) 顶层标签外壳(tabs.html, 含 #view iframe): ⟳ 刷新当前 iframe, 不显示 ⌂;
|
# 2) 顶层标签外壳(tabs.html, 含 #viewHost 多 iframe 宿主): ⟳ 只刷新激活的
|
||||||
|
# .page-frame, 其它标签保活帧不销毁、不 reload;
|
||||||
# 3) 顶层独立页(浏览器直开某 html): ⟳ 刷新自身, 非主页时显示 ⌂ 回到标签外壳。
|
# 3) 顶层独立页(浏览器直开某 html): ⟳ 刷新自身, 非主页时显示 ⌂ 回到标签外壳。
|
||||||
_RELOAD_SNIPPET = """
|
_RELOAD_SNIPPET = """
|
||||||
<style>#__rl,#__home{position:fixed;right:14px;width:34px;height:34px;z-index:99999;
|
<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;}
|
if(a&&a.href){e.preventDefault();location.href=a.href;}
|
||||||
},true);
|
},true);
|
||||||
function reloadCur(){
|
function reloadCur(){
|
||||||
if(TOP){var f=document.getElementById('view');
|
if(TOP){
|
||||||
if(f){try{f.contentWindow.location.reload();}catch(_){f.src=f.src;} return;}}
|
// 多 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();
|
location.reload();
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown',function(e){
|
window.addEventListener('keydown',function(e){
|
||||||
@@ -93,9 +101,9 @@ user-select:none;opacity:.55;transition:opacity .15s}
|
|||||||
document.getElementById('__rl').addEventListener('click',reloadCur);
|
document.getElementById('__rl').addEventListener('click',reloadCur);
|
||||||
var home=document.getElementById('__home');
|
var home=document.getElementById('__home');
|
||||||
var p=location.pathname;
|
var p=location.pathname;
|
||||||
var isShell=!!document.getElementById('view');
|
var isShell=!!document.getElementById('viewHost')||!!document.getElementById('view');
|
||||||
// 顶层且非标签外壳(浏览器直开 pages/xxx.html) → 给个 ⌂ 回到外壳。
|
// 顶层且非标签外壳(浏览器直开 pages/xxx.html) → 给个 ⌂ 回到外壳。
|
||||||
// 判定基准是外壳自身(有 #view / 路径为 tabs.html 或 /), 不能再看 index.html:
|
// 判定基准是外壳自身(有 #viewHost / 路径为 tabs.html 或 /), 不能再看 index.html:
|
||||||
// 重排后 pages/index.html 只是普通功能页, 直开它同样该显示 ⌂。
|
// 重排后 pages/index.html 只是普通功能页, 直开它同样该显示 ⌂。
|
||||||
if(!isShell&&!/\\/$/.test(p)&&!/\\/tabs\\.html$/.test(p)){
|
if(!isShell&&!/\\/$/.test(p)&&!/\\/tabs\\.html$/.test(p)){
|
||||||
home.style.display='block';
|
home.style.display='block';
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<!--
|
<!--
|
||||||
tabs.html - ESP32 调试控制台「单窗口多标签」外壳
|
tabs.html - ESP32 调试控制台「单窗口多标签」外壳
|
||||||
顶部一排标签, 下方 iframe 承载各功能页(pages/ 下功能页, lab/ 下实验页)。
|
顶部一排标签, 下方 **多 iframe 宿主** 承载各功能页(pages/ 下功能页, lab/ 下实验页)。
|
||||||
各页面原样复用, 互不干扰; 页面内的"新窗口打开"/普通链接会在 iframe 内跳转,
|
|
||||||
外壳监听 iframe 的 load 事件同步高亮当前标签。
|
保活约定(2026-09): 每个标签一个懒创建 iframe,切换只改可见性,**不改 src、不销毁**。
|
||||||
激活标签存 localStorage(dcu_tab), 下次打开自动恢复。
|
否则功能页内的 WebSocket/轮询/传输状态会随文档卸载全部丢失——「切走再切回连接全灭」。
|
||||||
|
|
||||||
|
激活标签存 localStorage(dcu_tab), 下次打开自动恢复(只预载该标签)。
|
||||||
-->
|
-->
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
@@ -50,7 +52,20 @@
|
|||||||
.tabbar .spacer { flex: 1 1 auto; }
|
.tabbar .spacer { flex: 1 1 auto; }
|
||||||
.tabbar .hint { display: flex; align-items: center; color: var(--text-label);
|
.tabbar .hint { display: flex; align-items: center; color: var(--text-label);
|
||||||
font-size: .72rem; padding: 0 8px; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -60,7 +75,8 @@
|
|||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<div class="hint">⟳ 刷新当前页 · F5</div>
|
<div class="hint">⟳ 刷新当前页 · F5</div>
|
||||||
</nav>
|
</nav>
|
||||||
<iframe id="view" title="功能页" src="about:blank"></iframe>
|
<!-- 外壳标记:注入脚本用 #viewHost 判定「标签外壳」;各功能页是其子 iframe.page-frame -->
|
||||||
|
<div id="viewHost" data-shell="1"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
@@ -73,9 +89,12 @@
|
|||||||
{ page: 'lab/spi_test.html', label: 'SPI/ECAT 通信测速', ico: '⚡', color: 'var(--ble)' }
|
{ page: 'lab/spi_test.html', label: 'SPI/ECAT 通信测速', ico: '⚡', color: 'var(--ble)' }
|
||||||
];
|
];
|
||||||
var bar = document.getElementById('tabbar');
|
var bar = document.getElementById('tabbar');
|
||||||
var view = document.getElementById('view');
|
var host = document.getElementById('viewHost');
|
||||||
var spacer = bar.querySelector('.spacer');
|
var spacer = bar.querySelector('.spacer');
|
||||||
var LS = 'dcu_tab';
|
var LS = 'dcu_tab';
|
||||||
|
/** page → HTMLIFrameElement;懒创建后常驻,切标签不销毁。 */
|
||||||
|
var frames = Object.create(null);
|
||||||
|
var activePage = null;
|
||||||
|
|
||||||
function baseName(p){ return (p || '').replace(/^.*\//, '').split('?')[0]; }
|
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;
|
for (var i=0;i<TABS.length;i++) if (baseName(TABS[i].page) === b) return TABS[i].page;
|
||||||
return null; // 未知页(如从页面内跳到别的 html)
|
return null; // 未知页(如从页面内跳到别的 html)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActive(path){
|
function setActive(path){
|
||||||
var tabs = bar.querySelectorAll('.tab');
|
var tabs = bar.querySelectorAll('.tab');
|
||||||
for (var i=0;i<tabs.length;i++){
|
for (var i=0;i<tabs.length;i++){
|
||||||
tabs[i].classList.toggle('active', tabs[i].getAttribute('data-page') === path);
|
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){
|
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);
|
setActive(path);
|
||||||
if (save !== false) { try { localStorage.setItem(LS, path); } catch(e){} }
|
if (save !== false) { try { localStorage.setItem(LS, path); } catch(e){} }
|
||||||
}
|
}
|
||||||
@@ -111,15 +168,7 @@
|
|||||||
bar.insertBefore(b, spacer);
|
bar.insertBefore(b, spacer);
|
||||||
});
|
});
|
||||||
|
|
||||||
// iframe 内导航(页面里的链接/新窗口打开覆写)后, 同步高亮对应标签
|
// 初始标签: 上次记忆, 否则 OTA 主页。只预载该标签,其余首次点开再创建。
|
||||||
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 主页。
|
|
||||||
// 取 pageToTab 的返回值而非 localStorage 原值, 这样旧版存的裸文件名
|
// 取 pageToTab 的返回值而非 localStorage 原值, 这样旧版存的裸文件名
|
||||||
// (can.html) 会被自动迁移成新的 pages/can.html。
|
// (can.html) 会被自动迁移成新的 pages/can.html。
|
||||||
var init = 'pages/index.html';
|
var init = 'pages/index.html';
|
||||||
|
|||||||
Reference in New Issue
Block a user