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:
@@ -2,3 +2,4 @@
|
||||
# Python 字节码缓存
|
||||
__pycache__/
|
||||
*.pyc
|
||||
web/server.log
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
先用与最终布局一致的底色铺满画布(左 Sider 深色 + 内容区灰),
|
||||
消除深色 Sider 区域的白闪(FOUC)。 -->
|
||||
<style>html{background:#f0f2f5}@media (min-width:721px){html{background:linear-gradient(90deg,#001529 208px,#f0f2f5 208px)}}</style>
|
||||
<!-- 被 SPA 外壳 iframe 嵌入时内容区无 Sider,立即改回纯灰底(首帧前生效) -->
|
||||
<script>if(window!==window.top){document.documentElement.style.background="#f0f2f5"}</script>
|
||||
<title>iAOP 管理控制台</title>
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
<link rel="stylesheet" href="../shared/pro.css">
|
||||
|
||||
+2
-2
@@ -291,8 +291,8 @@
|
||||
function clearAlert() { $("alert").className = "alert"; }
|
||||
|
||||
function nextUrl() {
|
||||
// 无门户页(2026-08):登录后默认直达驾驶舱
|
||||
return new URLSearchParams(location.search).get("next") || "../cockpit/";
|
||||
// 登录后默认进 SPA 外壳(无门户页;外壳默认加载驾驶舱)
|
||||
return new URLSearchParams(location.search).get("next") || "../index.html";
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
先用与最终布局一致的底色铺满画布(左 Sider 深色 + 内容区灰),
|
||||
消除深色 Sider 区域的白闪(FOUC)。 -->
|
||||
<style>html{background:#f0f2f5}@media (min-width:721px){html{background:linear-gradient(90deg,#001529 208px,#f0f2f5 208px)}}</style>
|
||||
<!-- 被 SPA 外壳 iframe 嵌入时内容区无 Sider,立即改回纯灰底(首帧前生效) -->
|
||||
<script>if(window!==window.top){document.documentElement.style.background="#f0f2f5"}</script>
|
||||
<title>iAOP 用户/角色管理</title>
|
||||
<style>
|
||||
/* iAOP 用户/角色管理页(issue #134 / PRD 8.2):admin 专属 */
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
先用与最终布局一致的底色铺满画布(左 Sider 深色 + 内容区灰),
|
||||
消除深色 Sider 区域的白闪(FOUC)。 -->
|
||||
<style>html{background:#f0f2f5}@media (min-width:721px){html{background:linear-gradient(90deg,#001529 208px,#f0f2f5 208px)}}</style>
|
||||
<!-- 被 SPA 外壳 iframe 嵌入时内容区无 Sider,立即改回纯灰底(首帧前生效) -->
|
||||
<script>if(window!==window.top){document.documentElement.style.background="#f0f2f5"}</script>
|
||||
<title>iAOP 对话助手(增强版)</title>
|
||||
<link rel="stylesheet" href="assistant.css">
|
||||
<link rel="stylesheet" href="../shared/pro.css">
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
先用与最终布局一致的底色铺满画布(左 Sider 深色 + 内容区灰),
|
||||
消除深色 Sider 区域的白闪(FOUC)。 -->
|
||||
<style>html{background:#f0f2f5}@media (min-width:721px){html{background:linear-gradient(90deg,#001529 208px,#f0f2f5 208px)}}</style>
|
||||
<!-- 被 SPA 外壳 iframe 嵌入时内容区无 Sider,立即改回纯灰底(首帧前生效) -->
|
||||
<script>if(window!==window.top){document.documentElement.style.background="#f0f2f5"}</script>
|
||||
<title>iAOP 配置化驾驶舱</title>
|
||||
<link rel="stylesheet" href="cockpit.css">
|
||||
<link rel="stylesheet" href="../shared/pro.css">
|
||||
|
||||
+29
-12
@@ -4,20 +4,37 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>iAOP 云美工业AI优化平台</title>
|
||||
<meta http-equiv="refresh" content="0; url=cockpit/">
|
||||
<link rel="stylesheet" href="shared/pro.css">
|
||||
<style>
|
||||
/* 根路径仅做跳转(2026-08 起门户页移除,登录后默认进驾驶舱):
|
||||
* / → cockpit/(驾驶舱自带登录守卫,未登录会再跳登录页并回跳)。 */
|
||||
body { margin: 0; min-height: 100vh; display: flex; align-items: center;
|
||||
justify-content: center; background: #f0f2f5;
|
||||
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: rgba(0,0,0,.45); font-size: 14px; }
|
||||
a { color: #1677ff; text-decoration: none; }
|
||||
/* iAOP SPA 外壳(2026-08,对齐 Ant Design Pro 体验):
|
||||
* Sider/顶栏常驻不刷新,模块页在 iframe 内容区加载,
|
||||
* 左侧菜单切换 = 换 iframe src,无整页刷新、无白闪。
|
||||
* URL 形如 /index.html#/admin/#models,可分享直达子页。 */
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body.has-pro-sider { padding-left: 0 !important; } /* 外壳自控布局 */
|
||||
/* 注意:iframe 是替换元素,fixed 时左右同时定位不会拉伸(回退到
|
||||
* 固有尺寸 300x150),必须显式 calc 宽高。 */
|
||||
#module-frame {
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
left: 208px;
|
||||
width: calc(100% - 208px);
|
||||
height: calc(100% - 48px);
|
||||
border: 0;
|
||||
background: #f0f2f5;
|
||||
transition: left 0.2s, width 0.2s;
|
||||
}
|
||||
body.pro-sider-collapsed #module-frame { left: 48px; width: calc(100% - 48px); }
|
||||
@media (max-width: 720px) { #module-frame { left: 0; width: 100%; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- iAOP 平台入口(无门户页):直接重定向到配置化驾驶舱。 -->
|
||||
<p>正在进入驾驶舱… <a href="cockpit/">立即进入</a></p>
|
||||
<script>location.replace("cockpit/");</script>
|
||||
<body data-pro-shell data-pro-title="iAOP 平台">
|
||||
<!-- SPA 外壳:pro-header.js 注入 Sider/顶栏;shell.js 负责菜单导航、
|
||||
iframe 加载、URL hash 同步(#/模块/#子页)与登录守卫。 -->
|
||||
<iframe id="module-frame" title="模块内容"></iframe>
|
||||
<script src="auth/user_store.js"></script>
|
||||
<script src="shared/session.js"></script>
|
||||
<script src="shared/pro-header.js"></script>
|
||||
<script src="shared/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+112
-43
@@ -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);
|
||||
/* ---- 外壳模式:供 shell.js 同步菜单高亮与顶栏页题 ---------------------- */
|
||||
if (SHELL) {
|
||||
window.IAOP_PRO = {
|
||||
setActive: function (moduleKey, childHash, moduleTitle) {
|
||||
menu.querySelectorAll(".pro-menu-item").forEach(function (x) {
|
||||
x.classList.remove("active");
|
||||
});
|
||||
syncSubHighlight();
|
||||
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") {
|
||||
|
||||
+12
-3
@@ -151,6 +151,12 @@ var IAOP_SESSION = (function () {
|
||||
});
|
||||
}
|
||||
|
||||
/* 被 iframe 嵌入时(SPA 外壳)重定向要作用于顶层窗口 */
|
||||
function topWindow() {
|
||||
try { return window.self !== window.top ? window.top : window; }
|
||||
catch (e) { return window; }
|
||||
}
|
||||
|
||||
/* ---- 登出:双轨都清 ----------------------------------------------------- */
|
||||
function logout(loginUrl) {
|
||||
clearSession();
|
||||
@@ -159,15 +165,18 @@ var IAOP_SESSION = (function () {
|
||||
.catch(function () {});
|
||||
} catch (e) {}
|
||||
var next = encodeURIComponent(location.pathname + location.search);
|
||||
location.href = (loginUrl || "../auth/login.html") + "?next=" + next;
|
||||
topWindow().location.href = (loginUrl || "../auth/login.html") + "?next=" + next;
|
||||
}
|
||||
|
||||
/* ---- 路由守卫(与 #150 IAOP_AUTH 同语义) ------------------------------- */
|
||||
function requireLoginElseRedirect(loginUrl) {
|
||||
return currentUser().then(function (u) {
|
||||
if (!u) {
|
||||
var next = encodeURIComponent(location.pathname + location.search);
|
||||
location.href = (loginUrl || "../auth/login.html") + "?next=" + next;
|
||||
// 回跳地址取顶层窗口(SPA 外壳 hash 内含模块/子页路径)
|
||||
var top = topWindow();
|
||||
var next = encodeURIComponent(
|
||||
top.location.pathname + top.location.search + top.location.hash);
|
||||
top.location.href = (loginUrl || "../auth/login.html") + "?next=" + next;
|
||||
return false;
|
||||
}
|
||||
return u;
|
||||
|
||||
@@ -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/");
|
||||
});
|
||||
})();
|
||||
@@ -7,6 +7,8 @@
|
||||
先用与最终布局一致的底色铺满画布(左 Sider 深色 + 内容区灰),
|
||||
消除深色 Sider 区域的白闪(FOUC)。 -->
|
||||
<style>html{background:#f0f2f5}@media (min-width:721px){html{background:linear-gradient(90deg,#001529 208px,#f0f2f5 208px)}}</style>
|
||||
<!-- 被 SPA 外壳 iframe 嵌入时内容区无 Sider,立即改回纯灰底(首帧前生效) -->
|
||||
<script>if(window!==window.top){document.documentElement.style.background="#f0f2f5"}</script>
|
||||
<title>iAOP 模板配置台</title>
|
||||
<link rel="stylesheet" href="studio.css">
|
||||
<link rel="stylesheet" href="../shared/pro.css">
|
||||
|
||||
Reference in New Issue
Block a user