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:
2026-08-05 22:43:47 +08:00
parent 65d3b0952f
commit cfa976cac3
11 changed files with 293 additions and 61 deletions
+113 -44
View File
@@ -1,29 +1,27 @@
/* iAOP 全站统一布局(Ant Design Pro 左侧边栏风格,2026-08)。
*
* 在页面 <body> 末尾引入即可自动注入 Ant Design Pro 经典管理布局:
* · 左侧深色 Sider(#001529,AntD Pro 默认):logo + 纵向菜单
* (菜单 = 门户 + IAOP_SESSION.MODULES 按当前登录角色过滤;
* 带 children 的模块渲染为可展开 SubMenu——如「管理控制台」下挂
* 模型管理/知识库管理/告警确认/审计查询,当前页菜单项高亮 #1677ff,
* 支持底部折叠按钮收成 48px 图标栏);
* · 顶部 48px 白色细栏:当前页标题 + 用户徽章(角色 Tag)+ 退出登录;
* · 页面原内容整体右移(body.has-pro-sider 的 padding-left,见 pro.css),
* 窄屏(≤720px)自动隐藏 Sider 退回纯顶栏。
* 三种运行模式(自动判别):
* 1. SPA 外壳模式(web/index.html,body[data-pro-shell]):
* Sider/顶栏常驻;菜单点击不整页跳转,改调 IAOP_SHELL.navigate(url)
* 换 iframe 内容(对齐 Ant Design Pro 的无刷新体验);
* 导出 IAOP_PRO.setActive(moduleKey, childHash, title) 供 shell.js
* 在 iframe 加载/子页切换后同步菜单高亮与顶栏页题。
* 2. 被嵌模式(模块页在 iframe 内,window.self !== window.top):
* 不注入 Sider/顶栏、不铺 Sider 底色,保留页内 Tab ↔ hash 联动;
* 隐藏页内顶栏的跨页链接与重复用户标识(外壳已提供全局导航)。
* 3. 独立模式(模块页直接打开):注入 Sider/顶栏(老行为),
* 菜单按角色过滤,整页跳转。
*
* 子菜单 ↔ 页内 Tab 联动(studio/admin):
* 页面里原有的 #tabs .tab[data-page] 页内 Tab 条会被隐藏,改由 Sider
* 子菜单通过 URL hash 驱动(admin/#models、studio/#import …):
* 首次加载按 hash 激活对应 Tab,hashchange 时同步切换,页内 Tab 点击
* 也会回写 hash,URL 可分享直达子页。
* 页内 Tab ↔ URL hash 联动(studio/admin)在三种模式下都生效:
* #tabs .tab[data-page] 自动隐藏,改由 URL hash 驱动切换,URL 可分享直达。
*
* 用法(各模块页相同,无需改动):
* <body data-pro-title="配置化驾驶舱" data-pro-home="../index.html">
* <body data-pro-title="配置化驾驶舱">
* <script src="../auth/user_store.js"></script>
* <script src="../shared/session.js"></script>
* <script src="../shared/pro-header.js"></script>
*
* 本组件不强制登录(守卫由 IAOP_SESSION.requireLoginElseRedirect 负责);
* 未登录时菜单仅显示「门户」,用户区显示「未登录」。
* 本组件不强制登录(守卫由 IAOP_SESSION 负责)。
*/
"use strict";
@@ -42,7 +40,7 @@
return depth > 0 ? "../" : "";
}
/* 当前页面命中的模块 key(用于菜单高亮/展开) */
/* 当前页面命中的模块 key(独立模式菜单高亮/展开) */
function activeModuleKey() {
var path = location.pathname;
var mods = IAOP_SESSION.MODULES;
@@ -52,7 +50,7 @@
return mods[i].key;
}
}
return ""; // 根路径已重定向驾驶舱,无独立「门户」页
return "";
}
/* 当前 hash 命中的子菜单 key(# 前缀归一化) */
@@ -93,14 +91,32 @@
function build() {
var body = document.body;
if (!body || document.querySelector(".pro-sider")) return;
if (!body) return;
/* ---- 被嵌模式:模块页在 SPA 外壳的 iframe 内,只做 Tab↔hash 联动 ------ */
if (window.self !== window.top) {
// 覆盖模块页 head 内联的「左深右灰」防闪底色(iframe 内容区无 Sider)
document.documentElement.style.background = "#f0f2f5";
// 外壳已提供全局导航与登录用户展示:隐藏页内顶栏里的跨页跳转链接
// (驾驶舱/配置台等 <a>,避免 iframe 内自行跳走)与重复的用户标识 #who。
// 保留功能性控件(模板/角色下拉、状态点等)。
var whoEl = document.getElementById("who");
if (whoEl) whoEl.style.display = "none";
Array.prototype.forEach.call(
document.querySelectorAll("#topbar-right a, #topbar a"),
function (a) { a.style.display = "none"; }
);
bindPageTabs();
return;
}
if (document.querySelector(".pro-sider")) return;
var SHELL = body.hasAttribute("data-pro-shell");
var title = body.getAttribute("data-pro-title") || document.title || "";
// 无门户页(2026-08):logo 与默认首页均指向驾驶舱
var home = body.getAttribute("data-pro-home") || "../cockpit/";
var loginUrl = body.getAttribute("data-pro-login") || "../auth/login.html";
var prefix = rootPrefix();
var active = activeModuleKey();
var active = SHELL ? "" : activeModuleKey();
/* ---- 左侧 Sider ----------------------------------------------------- */
var sider = el("aside", "pro-sider");
@@ -127,7 +143,8 @@
/* ---- 顶部细栏 -------------------------------------------------------- */
var bar = el("div", "pro-header");
if (title) bar.appendChild(el("span", "pro-header-page-title", title));
var titleEl = el("span", "pro-header-page-title", title);
if (title) bar.appendChild(titleEl);
var right = el("div", "pro-header-right");
var userBox = el("span", "pro-user");
@@ -145,13 +162,29 @@
body.classList.add("has-pro-sider");
/* ---- 菜单项导航:外壳模式走 IAOP_SHELL(无刷新),独立模式整页跳转 ----- */
function go(url) {
if (SHELL && window.IAOP_SHELL) {
IAOP_SHELL.navigate(url);
} else {
location.href = url;
}
}
/* ---- 菜单渲染(需要登录角色,异步) ----------------------------------- */
function addLeafItem(key, icon, text, href) {
var a = el("a", "pro-menu-item" + (key === active ? " active" : ""));
a.href = href;
a.title = text;
a.setAttribute("data-module", key);
a.appendChild(el("span", "pro-menu-icon", icon));
a.appendChild(el("span", "pro-menu-text", text));
if (SHELL) {
a.addEventListener("click", function (e) {
e.preventDefault();
go(href);
});
}
menu.appendChild(a);
}
@@ -159,6 +192,7 @@
var isActiveModule = (m.key === active);
var wrap = el("div", "pro-submenu" +
(isActiveModule ? " open" : ""));
wrap.setAttribute("data-module", m.key);
var head = el("a", "pro-menu-item pro-submenu-title" +
(isActiveModule ? " active" : ""));
@@ -168,8 +202,7 @@
head.appendChild(el("span", "pro-menu-text", m.title));
var arrow = el("span", "pro-submenu-arrow", "▸");
head.appendChild(arrow);
// AntD Pro inline 菜单交互:整行点击 = 就地展开/收起(不跳转),
// 只有子项才导航(跨页跳转或同页 hash 切换)
// AntD Pro inline 菜单交互:整行点击 = 就地展开/收起(不跳转)
head.addEventListener("click", function (e) {
e.preventDefault();
wrap.classList.toggle("open");
@@ -181,19 +214,41 @@
m.children.forEach(function (c) {
var a = el("a", "pro-subitem" +
(isActiveModule && c.key === currentChild ? " active" : ""));
a.href = prefix + m.url.replace(/\/$/, "/") + c.hash;
var href = prefix + m.url.replace(/\/$/, "/") + c.hash;
a.href = href;
a.textContent = c.title;
a.title = c.title;
// 子项统一走默认导航(同页 = 改 hash,跨页 = 整页加载);
// 高亮与 Tab 切换全部由 hashchange 统一驱动(见 syncSubHighlight /
// bindPageTabs),不再依赖子项点击处理器。
a.setAttribute("data-child", c.key);
if (SHELL) {
a.addEventListener("click", function (e) {
e.preventDefault();
go(href);
});
}
childBox.appendChild(a);
});
wrap.appendChild(childBox);
menu.appendChild(wrap);
}
/* hash → 子菜单高亮同步(同页切换与分享直达都经过这里) */
function renderMenu(user) {
menu.innerHTML = "";
// AntD Menu.ItemGroup 风格分组标题
var group = el("div", "pro-menu-group-title", "功能模块");
menu.appendChild(group);
// 菜单即模块清单,按角色过滤
var mods = user ? IAOP_SESSION.modulesFor(user.role) : [];
mods.forEach(function (m) {
if (m.children && m.children.length) addSubMenu(m);
else addLeafItem(m.key, m.icon, m.title, prefix + m.url);
});
if (!SHELL) syncSubHighlight();
// 菜单(重)渲染完成事件:shell.js 据此重放当前高亮,
// 消除「导航先于菜单渲染」的竞态(直达链接/刷新场景)。
window.dispatchEvent(new CustomEvent("pro:menu-rendered"));
}
/* hash → 子菜单高亮同步(独立模式:同页切换与分享直达都经过这里) */
function syncSubHighlight() {
var h = activeChildKey();
menu.querySelectorAll(".pro-subitem").forEach(function (x) {
@@ -203,18 +258,32 @@
});
}
function renderMenu(user) {
menu.innerHTML = "";
// AntD Menu.ItemGroup 风格分组标题
var group = el("div", "pro-menu-group-title", "功能模块");
menu.appendChild(group);
// 无门户页(2026-08):菜单即模块清单,按角色过滤
var mods = user ? IAOP_SESSION.modulesFor(user.role) : [];
mods.forEach(function (m) {
if (m.children && m.children.length) addSubMenu(m);
else addLeafItem(m.key, m.icon, m.title, prefix + m.url);
});
syncSubHighlight();
/* ---- 外壳模式:供 shell.js 同步菜单高亮与顶栏页题 ---------------------- */
if (SHELL) {
window.IAOP_PRO = {
setActive: function (moduleKey, childHash, moduleTitle) {
menu.querySelectorAll(".pro-menu-item").forEach(function (x) {
x.classList.remove("active");
});
menu.querySelectorAll(".pro-subitem").forEach(function (x) {
x.classList.remove("active");
});
if (!moduleKey) return;
var wrap = menu.querySelector('.pro-submenu[data-module="' + moduleKey + '"]');
if (wrap) {
wrap.classList.add("open");
wrap.querySelector(".pro-submenu-title").classList.add("active");
if (childHash) {
var sub = wrap.querySelector('.pro-subitem[data-child="' + childHash + '"]');
if (sub) sub.classList.add("active");
}
} else {
var leaf = menu.querySelector('.pro-menu-item[data-module="' + moduleKey + '"]');
if (leaf) leaf.classList.add("active");
}
if (moduleTitle) titleEl.textContent = moduleTitle;
}
};
}
/* ---- 用户区(后端优先、本地降级由 session.js 内部处理) --------------- */
@@ -235,8 +304,8 @@
});
bindPageTabs();
// 子菜单高亮全局同步(同页 hash 切换 / 分享直达 / 跨页返回)
window.addEventListener("hashchange", syncSubHighlight);
// 子菜单高亮全局同步(独立模式:同页 hash 切换 / 分享直达 / 跨页返回)
if (!SHELL) window.addEventListener("hashchange", syncSubHighlight);
}
if (document.readyState === "loading") {