diff --git a/web/shared/pro-header.js b/web/shared/pro-header.js index b4be8d3..c138ede 100644 --- a/web/shared/pro-header.js +++ b/web/shared/pro-header.js @@ -2,31 +2,32 @@ * * 在页面 末尾引入即可自动注入 Ant Design Pro 经典管理布局: * · 左侧深色 Sider(#001529,AntD Pro 默认):logo + 纵向菜单 - * (菜单 = 门户 + IAOP_SESSION.MODULES 按当前登录角色过滤, - * 当前页菜单项高亮 #1677ff,支持底部折叠按钮收成 48px 图标栏); + * (菜单 = 门户 + IAOP_SESSION.MODULES 按当前登录角色过滤; + * 带 children 的模块渲染为可展开 SubMenu——如「管理控制台」下挂 + * 模型管理/知识库管理/告警确认/审计查询,当前页菜单项高亮 #1677ff, + * 支持底部折叠按钮收成 48px 图标栏); * · 顶部 48px 白色细栏:当前页标题 + 用户徽章(角色 Tag)+ 退出登录; * · 页面原内容整体右移(body.has-pro-sider 的 padding-left,见 pro.css), * 窄屏(≤720px)自动隐藏 Sider 退回纯顶栏。 * + * 子菜单 ↔ 页内 Tab 联动(studio/admin): + * 页面里原有的 #tabs .tab[data-page] 页内 Tab 条会被隐藏,改由 Sider + * 子菜单通过 URL hash 驱动(admin/#models、studio/#import …): + * 首次加载按 hash 激活对应 Tab,hashchange 时同步切换,页内 Tab 点击 + * 也会回写 hash,URL 可分享直达子页。 + * * 用法(各模块页相同,无需改动): * * * * * - * data-pro-title:当前页标题(顶部细栏左侧 + 菜单高亮匹配)。 - * data-pro-home :门户相对路径,缺省 ../index.html。 - * data-pro-login:登录页相对路径(退出后跳转),缺省 ../auth/login.html。 - * * 本组件不强制登录(守卫由 IAOP_SESSION.requireLoginElseRedirect 负责); * 未登录时菜单仅显示「门户」,用户区显示「未登录」。 */ "use strict"; (function () { - var SIDER_WIDTH = 208; // AntD Pro Sider 默认宽度 - var COLLAPSED_WIDTH = 48; // AntD 折叠后宽度 - function el(tag, cls, text) { var n = document.createElement(tag); if (cls) n.className = cls; @@ -41,8 +42,8 @@ return depth > 0 ? "../" : ""; } - /* 当前页面命中的模块 key(用于菜单高亮) */ - function activeKey(prefix) { + /* 当前页面命中的模块 key(用于菜单高亮/展开) */ + function activeModuleKey() { var path = location.pathname; var mods = IAOP_SESSION.MODULES; for (var i = 0; i < mods.length; i++) { @@ -54,6 +55,42 @@ return path === "/" || /\/index\.html$/.test(path) ? "portal" : ""; } + /* 当前 hash 命中的子菜单 key(# 前缀归一化) */ + function activeChildKey() { + return location.hash.replace(/^#/, ""); + } + + /* ---- 页内 Tab ↔ URL hash 联动(studio/admin 的 #tabs .tab[data-page]) --- */ + function bindPageTabs() { + var tabsBar = document.getElementById("tabs"); + if (!tabsBar) return; + var tabs = tabsBar.querySelectorAll(".tab[data-page]"); + if (!tabs.length) return; + + // 子菜单已上收到 Sider:隐藏页内 Tab 条 + tabsBar.classList.add("pro-tabs-hidden"); + + function activate(page) { + var btn = tabsBar.querySelector('.tab[data-page="' + page + '"]'); + if (btn) btn.click(); + } + window.addEventListener("hashchange", function () { + activate(activeChildKey()); + }); + // 等页面自身脚本(studio.js/admin.js)绑好点击事件后再做初始激活 + window.addEventListener("load", function () { + var h = activeChildKey(); + if (h) activate(h); + }); + // 页内 Tab 点击回写 hash(保持 URL 可分享) + tabsBar.addEventListener("click", function (e) { + var t = e.target.closest(".tab[data-page]"); + if (t && ("#" + t.getAttribute("data-page")) !== location.hash) { + history.replaceState(null, "", "#" + t.getAttribute("data-page")); + } + }); + } + function build() { var body = document.body; if (!body || document.querySelector(".pro-sider")) return; @@ -62,7 +99,7 @@ var home = body.getAttribute("data-pro-home") || "../index.html"; var loginUrl = body.getAttribute("data-pro-login") || "../auth/login.html"; var prefix = rootPrefix(); - var active = activeKey(); + var active = activeModuleKey(); /* ---- 左侧 Sider ----------------------------------------------------- */ var sider = el("aside", "pro-sider"); @@ -108,22 +145,68 @@ body.classList.add("has-pro-sider"); /* ---- 菜单渲染(需要登录角色,异步) ----------------------------------- */ + function addLeafItem(key, icon, text, href) { + var a = el("a", "pro-menu-item" + (key === active ? " active" : "")); + a.href = href; + a.title = text; + a.appendChild(el("span", "pro-menu-icon", icon)); + a.appendChild(el("span", "pro-menu-text", text)); + menu.appendChild(a); + } + + function addSubMenu(m) { + var isActiveModule = (m.key === active); + var wrap = el("div", "pro-submenu" + + (isActiveModule ? " open" : "")); + + var head = el("a", "pro-menu-item pro-submenu-title" + + (isActiveModule ? " active" : "")); + head.href = prefix + m.url; + head.title = m.title; + head.appendChild(el("span", "pro-menu-icon", m.icon)); + head.appendChild(el("span", "pro-menu-text", m.title)); + var arrow = el("span", "pro-submenu-arrow", "▸"); + head.appendChild(arrow); + // 箭头区域点击只展开/收起,不跳转;点标题文字跳转 + arrow.addEventListener("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + wrap.classList.toggle("open"); + }); + wrap.appendChild(head); + + var childBox = el("div", "pro-submenu-items"); + var currentChild = activeChildKey(); + m.children.forEach(function (c) { + var a = el("a", "pro-subitem" + + (isActiveModule && c.key === currentChild ? " active" : "")); + a.href = prefix + m.url.replace(/\/$/, "/") + c.hash; + a.textContent = c.title; + a.title = c.title; + // 同页子菜单:只改 hash,由 bindPageTabs 的 hashchange 驱动切换 + if (isActiveModule) { + a.addEventListener("click", function (e) { + e.preventDefault(); + childBox.querySelectorAll(".pro-subitem").forEach(function (x) { + x.classList.remove("active"); + }); + a.classList.add("active"); + location.hash = c.hash; // 触发 hashchange → 激活对应 Tab + }); + } + childBox.appendChild(a); + }); + wrap.appendChild(childBox); + menu.appendChild(wrap); + } + function renderMenu(user) { menu.innerHTML = ""; - - function addItem(key, icon, text, href, tag) { - var a = el("a", "pro-menu-item" + (key === active ? " active" : "")); - a.href = href; - a.title = text; - a.appendChild(el("span", "pro-menu-icon", icon)); - a.appendChild(el("span", "pro-menu-text", text)); - menu.appendChild(a); - } - - addItem("portal", "🏠", "门户", prefix + "index.html"); + addLeafItem("portal", "🏠", "门户", prefix + "index.html"); var mods = user ? IAOP_SESSION.modulesFor(user.role) : []; mods.forEach(function (m) { - addItem(m.key, m.icon, m.title, prefix + m.url, m.tag); + if (m.children && m.children.length) addSubMenu(m); + else addLeafItem(m.key, m.icon, m.title, prefix + m.url); }); } @@ -143,6 +226,8 @@ var label = IAOP_SESSION.ROLE_LABELS[u.role] || u.role; userBox.appendChild(el("span", "pro-tag pro-tag-" + color, label)); }); + + bindPageTabs(); } if (document.readyState === "loading") { diff --git a/web/shared/pro.css b/web/shared/pro.css index 80ab5c9..0ede9be 100644 --- a/web/shared/pro.css +++ b/web/shared/pro.css @@ -143,7 +143,48 @@ body { color: #fff; } .pro-menu-icon { font-size: 16px; flex-shrink: 0; width: 20px; text-align: center; } -.pro-menu-text { overflow: hidden; text-overflow: ellipsis; } +.pro-menu-text { overflow: hidden; text-overflow: ellipsis; flex: 1; } + +/* ---- SubMenu(模块子菜单,AntD Menu.SubMenu 风格) ----------------------- */ +.pro-submenu-arrow { + font-size: 12px; + color: rgba(255, 255, 255, 0.45); + transition: transform 0.2s; + padding: 4px; + flex-shrink: 0; +} +.pro-submenu.open .pro-submenu-arrow { transform: rotate(90deg); } +.pro-submenu-items { + display: none; + background: #000c17; /* AntD 深色菜单子层底色 */ + border-radius: var(--ant-radius); + margin: 0 8px 4px; + padding: 4px 0; +} +.pro-submenu.open .pro-submenu-items { display: block; } +.pro-subitem { + display: block; + height: 34px; + line-height: 34px; + padding: 0 12px 0 42px; /* 对齐父级文字(icon 20 + gap 10 + padding 12) */ + margin: 2px 4px; + border-radius: 4px; + color: rgba(255, 255, 255, 0.65); + text-decoration: none; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: background 0.2s, color 0.2s; +} +.pro-subitem:hover { color: #fff; } +.pro-subitem.active { + background: var(--ant-primary); + color: #fff; +} +/* 父项在自身页面但未选子项时:不高亮整行,仅文字变白提示(AntD 语义) */ +.pro-submenu-title.active { background: none; color: #fff; } +.pro-tabs-hidden { display: none !important; } .pro-sider-trigger { flex-shrink: 0; height: 40px; diff --git a/web/shared/session.js b/web/shared/session.js index 3c6d269..aa1a64b 100644 --- a/web/shared/session.js +++ b/web/shared/session.js @@ -33,10 +33,22 @@ var IAOP_SESSION = (function () { roles: ["readonly", "engineer", "admin"], tag: "AI 助手", tagColor: "blue" }, { key: "studio", title: "模板配置台", icon: "🧩", url: "studio/", desc: "点位字典导入 / 模型超参 / RAG 知识库 / 布局编排 / 版本发布(PRD 5.7)", - roles: ["engineer", "admin"], tag: "可配置", tagColor: "green" }, + roles: ["engineer", "admin"], tag: "可配置", tagColor: "green", + children: [ + { key: "import", title: "点位导入", hash: "#import" }, + { key: "hyper", title: "模型超参", hash: "#hyper" }, + { key: "layout", title: "驾驶舱编排", hash: "#layout" }, + { key: "version", title: "版本发布", hash: "#version" } + ] }, { key: "admin", title: "管理控制台", icon: "⚙️", url: "admin/", desc: "模型管理(版本/阶段/回滚)、知识库管理、告警确认、审计查询", - roles: ["admin"], tag: "管理员", tagColor: "gold" }, + roles: ["admin"], tag: "管理员", tagColor: "gold", + children: [ + { key: "models", title: "模型管理", hash: "#models" }, + { key: "kb", title: "知识库管理", hash: "#kb" }, + { key: "alerts", title: "告警确认", hash: "#alerts" }, + { key: "audit", title: "审计查询", hash: "#audit" } + ] }, { key: "users", title: "用户与角色", icon: "👤", url: "auth/users.html", desc: "用户管理、角色分配(readonly / engineer / admin)、审计(PRD 8.2)", roles: ["admin"], tag: "管理员", tagColor: "gold" },