feat: Sider 子菜单上收页内 Tab(管理控制台/模板配置台,issue #131)
- MODULES 注册表支持 children:管理控制台(模型管理/知识库管理/告警确认/ 审计查询)、模板配置台(点位导入/模型超参/驾驶舱编排/版本发布) - Sider 渲染 AntD Menu.SubMenu 风格可展开子菜单,当前页自动展开、 子项按 URL hash 高亮(如 admin/#models) - 页内 #tabs 自动隐藏,改由子菜单经 hash 驱动切换;页内 Tab 点击回写 hash,URL 可分享直达子页;各页面业务逻辑零改动
This commit is contained in:
+109
-24
@@ -2,31 +2,32 @@
|
||||
*
|
||||
* 在页面 <body> 末尾引入即可自动注入 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 可分享直达子页。
|
||||
*
|
||||
* 用法(各模块页相同,无需改动):
|
||||
* <body data-pro-title="配置化驾驶舱" data-pro-home="../index.html">
|
||||
* <script src="../auth/user_store.js"></script>
|
||||
* <script src="../shared/session.js"></script>
|
||||
* <script src="../shared/pro-header.js"></script>
|
||||
*
|
||||
* 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") {
|
||||
|
||||
+42
-1
@@ -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;
|
||||
|
||||
+14
-2
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user