Files
iAOP/web/shared/shell.js
T
bot_dev1 cfa976cac3 feat: SPA 外壳——菜单切换零刷新,对齐 Ant Design Pro 体验(issue #131)
- web/index.html 重写为 SPA 外壳:Sider/顶栏常驻,模块页在
  #module-frame iframe 内容区加载,点菜单只换 iframe 地址,
  彻底消灭整页刷新与白闪(此前只是铺底色缓解)
- 新增 shared/shell.js:IAOP_SHELL.navigate() 导航、外壳 hash
  (#/admin/#models) 与 iframe 地址双向同步、登录守卫、移动端
  模块新标签打开;iframe 地址用 location.replace 避免污染
  joint history;250ms 轮询回同步(replace 不触发 load 事件);
  导航时乐观高亮(后台标签轮询被节流不能作为唯一途径);
  前进/后退若恢复了 iframe 旧地址则以外壳 hash 为准强制对齐
- pro-header.js 三模式:外壳(菜单点击走 IAOP_SHELL、导出
  IAOP_PRO.setActive)/被嵌(iframe 内,隐藏页内跨页链接与重复
  用户标识、保留 Tab-hash 联动)/独立(原行为);菜单渲染后派发
  pro:menu-rendered 事件消除导航先于渲染的高亮竞态
- session.js:logout/守卫重定向作用于顶层窗口(iframe 内不再
  只跳 iframe 自己)
- 修复 iframe 替换元素宽高 bug:fixed 时左右定位不拉伸(回退
  固有 300x150),改显式 calc 宽高
- 登录页 next 默认回外壳 ../index.html

实测(本地 http.server + WebBridge 真实浏览器):菜单切换/
子菜单切换/前进/后退/直达链接(#/admin/#models) URL、iframe、
菜单高亮、子菜单高亮全链路一致,Sider 节点全程不重建;
登录守卫 logout→顶层跳登录、登录后回外壳默认驾驶舱。
2026-08-05 22:43:47 +08:00

127 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* iAOP SPA 外壳导航(2026-08,对齐 Ant Design Pro 体验)。
*
* 仅在 web/index.html(data-pro-shell)使用:
* · 登录守卫(未登录跳登录页,?next 回跳本壳);
* · 菜单点击 → IAOP_SHELL.navigate(url) → 换 iframe src,
* 外壳(Sider/顶栏)常驻不刷新,消灭整页白闪;
* · URL 同步:外壳 hash `#/admin/#models` ↔ iframe 地址,
* 支持浏览器前进/后退与分享直达子页;
* · iframe 地址经 250ms 轮询同步菜单高亮与顶栏页题(经 pro-header
* 的 IAOP_PRO.setActive);iframe 内的子页切换同样回同步外壳
* URL(replaceState,不产生额外历史);
* · 移动端模块不在 iframe 内打开(移动 UI 不适合桌面框),新标签打开。
*/
"use strict";
(function () {
var frame = document.getElementById("module-frame");
if (!frame) return;
var currentUrl = null; // 当前 iframe 模块地址(相对,如 "admin/#models")
/* 外壳 hash `#/admin/#models` → 模块地址 "admin/#models" */
function parseShellHash() {
var h = location.hash || "";
if (h.indexOf("#/") !== 0) return null;
return h.slice(2) || null; // "#/admin/#models".slice(2) = "admin/#models"
}
/* 模块地址 → 模块 key + 子 hash */
function resolveModule(url) {
var parts = url.split("#");
var path = parts[0];
var mods = IAOP_SESSION.MODULES;
for (var i = 0; i < mods.length; i++) {
var mu = mods[i].url.replace(/\/$/, "");
if (path === mods[i].url || path === mu ||
path.indexOf(mu + "/") === 0) {
return { key: mods[i].key, title: mods[i].title,
child: parts[1] || "" };
}
}
return { key: "", title: "", child: parts[1] || "" };
}
function syncChrome(url) {
var m = resolveModule(url);
if (window.IAOP_PRO && IAOP_PRO.setActive) {
IAOP_PRO.setActive(m.key, m.child, m.title);
}
}
function navigate(url) {
// 移动端模块新标签打开(移动 UI 不嵌入桌面框)
if (url.indexOf("mobile/") === 0) {
window.open(url, "_blank");
return;
}
if (url === currentUrl) return;
currentUrl = url;
// 用 location.replace 而非直接赋 frame.src:避免 iframe 产生自己的
// 会话历史条目污染 joint history(否则浏览器后退会绕过外壳直接
// 恢复 iframe 旧地址,造成外壳 URL / 菜单高亮与内容脱节)。
// 注意相对地址必须先按外壳页面解析成绝对地址——iframe 处于
// about:blank 时 location.replace 的相对解析不可靠。
var abs = new URL(url, location.href).href;
try {
frame.contentWindow.location.replace(abs);
} catch (e) {
frame.src = abs; // 兜底
}
var shellHash = "#/" + url;
if (location.hash !== shellHash) location.hash = shellHash; // 产生历史记录
// 乐观高亮:目标模块已知,立即同步菜单/顶栏页题,不等 iframe 加载
// (后台标签下 setInterval 轮询会被浏览器节流,不能作为唯一途径)。
lastSeen = url;
syncChrome(url);
}
/* iframe 地址同步:轮询而非 load/hashchange 事件——
* contentWindow.location.replace() 导航不触发 iframe 元素 load 事件,
* 轮询同时覆盖「模块切换」与「iframe 内 Tab 切子页」两种场景。 */
var lastSeen = null;
function pollFrame() {
try {
var w = frame.contentWindow;
if (!w || w.location.href === "about:blank") return;
var url = w.location.pathname.replace(/^\//, "") + w.location.hash;
if (url === lastSeen) return;
lastSeen = url;
// 浏览器前进/后退可能直接恢复了 iframe 旧地址(joint history 里
// 的历史条目):此时以外壳 hash 为准,强制重新对齐。
var expected = parseShellHash();
if (expected && expected !== url) {
currentUrl = null; // 绕过 navigate 的同值短路
navigate(expected);
return;
}
currentUrl = url;
// iframe 内 Tab 切子页:外壳 URL 跟随但不新增历史(replaceState)
if (location.hash !== "#/" + url) {
history.replaceState(null, "", "#/" + url);
}
syncChrome(url);
} catch (e) { /* 跨域保护兜底 */ }
}
setInterval(pollFrame, 250);
/* 外壳 hash(前进/后退/分享链接)→ 导航 */
window.addEventListener("hashchange", function () {
var url = parseShellHash();
if (url && url !== currentUrl) navigate(url);
});
/* 菜单渲染晚于首次导航的竞态:菜单就绪后重放当前高亮 */
window.addEventListener("pro:menu-rendered", function () {
if (currentUrl) syncChrome(currentUrl);
});
window.IAOP_SHELL = { navigate: navigate };
/* 启动:登录守卫 → 加载初始模块(hash 指定或默认驾驶舱) */
IAOP_SESSION.requireLoginElseRedirect("auth/login.html").then(function (user) {
if (!user) return; // 正在跳转登录页
navigate(parseShellHash() || "cockpit/");
});
})();