From cfa976cac30489941fecb0ab2e8b45039b818dcc Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 22:43:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SPA=20=E5=A4=96=E5=A3=B3=E2=80=94?= =?UTF-8?q?=E2=80=94=E8=8F=9C=E5=8D=95=E5=88=87=E6=8D=A2=E9=9B=B6=E5=88=B7?= =?UTF-8?q?=E6=96=B0=EF=BC=8C=E5=AF=B9=E9=BD=90=20Ant=20Design=20Pro=20?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=EF=BC=88issue=20#131=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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→顶层跳登录、登录后回外壳默认驾驶舱。 --- .gitignore | 1 + web/admin/index.html | 2 + web/auth/login.html | 4 +- web/auth/users.html | 2 + web/chat/assistant.html | 2 + web/cockpit/index.html | 2 + web/index.html | 41 +++++++--- web/shared/pro-header.js | 157 ++++++++++++++++++++++++++++----------- web/shared/session.js | 15 +++- web/shared/shell.js | 126 +++++++++++++++++++++++++++++++ web/studio/index.html | 2 + 11 files changed, 293 insertions(+), 61 deletions(-) create mode 100644 web/shared/shell.js diff --git a/.gitignore b/.gitignore index 4925a4f..e8cea09 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ # Python 字节码缓存 __pycache__/ *.pyc +web/server.log diff --git a/web/admin/index.html b/web/admin/index.html index 1cc1e9d..6d57adb 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -7,6 +7,8 @@ 先用与最终布局一致的底色铺满画布(左 Sider 深色 + 内容区灰), 消除深色 Sider 区域的白闪(FOUC)。 --> + + iAOP 管理控制台 diff --git a/web/auth/login.html b/web/auth/login.html index 02532df..107f0a0 100644 --- a/web/auth/login.html +++ b/web/auth/login.html @@ -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 () { diff --git a/web/auth/users.html b/web/auth/users.html index 735a602..c82d1e7 100644 --- a/web/auth/users.html +++ b/web/auth/users.html @@ -7,6 +7,8 @@ 先用与最终布局一致的底色铺满画布(左 Sider 深色 + 内容区灰), 消除深色 Sider 区域的白闪(FOUC)。 --> + + iAOP 用户/角色管理 + + iAOP 对话助手(增强版) diff --git a/web/cockpit/index.html b/web/cockpit/index.html index 7d9a2dd..6d9a2d9 100644 --- a/web/cockpit/index.html +++ b/web/cockpit/index.html @@ -7,6 +7,8 @@ 先用与最终布局一致的底色铺满画布(左 Sider 深色 + 内容区灰), 消除深色 Sider 区域的白闪(FOUC)。 --> + + iAOP 配置化驾驶舱 diff --git a/web/index.html b/web/index.html index e333bb5..97974d5 100644 --- a/web/index.html +++ b/web/index.html @@ -4,20 +4,37 @@ iAOP 云美工业AI优化平台 - + - - -

正在进入驾驶舱… 立即进入

- + + + + + + + diff --git a/web/shared/pro-header.js b/web/shared/pro-header.js index eb65096..3878413 100644 --- a/web/shared/pro-header.js +++ b/web/shared/pro-header.js @@ -1,29 +1,27 @@ /* iAOP 全站统一布局(Ant Design Pro 左侧边栏风格,2026-08)。 * - * 在页面 末尾引入即可自动注入 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 可分享直达。 * * 用法(各模块页相同,无需改动): - * + * * * * * - * 本组件不强制登录(守卫由 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"; + // 外壳已提供全局导航与登录用户展示:隐藏页内顶栏里的跨页跳转链接 + // (驾驶舱/配置台等 ,避免 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") { diff --git a/web/shared/session.js b/web/shared/session.js index 9e7657f..01ea68d 100644 --- a/web/shared/session.js +++ b/web/shared/session.js @@ -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; diff --git a/web/shared/shell.js b/web/shared/shell.js new file mode 100644 index 0000000..8500f95 --- /dev/null +++ b/web/shared/shell.js @@ -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/"); + }); +})(); diff --git a/web/studio/index.html b/web/studio/index.html index a784d6e..f621e14 100644 --- a/web/studio/index.html +++ b/web/studio/index.html @@ -7,6 +7,8 @@ 先用与最终布局一致的底色铺满画布(左 Sider 深色 + 内容区灰), 消除深色 Sider 区域的白闪(FOUC)。 --> + + iAOP 模板配置台