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→顶层跳登录、登录后回外壳默认驾驶舱。
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
/* 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/");
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user