diff --git a/deploy/fba/README.md b/deploy/fba/README.md index d250ee7..8dea2a2 100644 --- a/deploy/fba/README.md +++ b/deploy/fba/README.md @@ -76,7 +76,16 @@ docker exec -it fba_postgres psql -U postgres -d fba # 进数据库 - **Phase 1**:iAOP 前端 `web/shared/session.js` 改为调用 `/fba/api/v1/auth/login`(JSON 登录)与 `/fba/api/v1/auth/codes` (授权码)做登录与鉴权,现有 `core/auth` 退役。 -- **Phase 2**:Sider 菜单由 FBA `sys_menu` 接口驱动,替代前端硬编码菜单。 +- **Phase 2**:Sider 菜单由 FBA `sys_menu` 接口驱动(`GET /fba/api/v1/sys/menus/sidebar`, + 服务端按角色授权过滤),替代前端硬编码角色过滤。**需先执行菜单播种**: + ```bash + python3 deploy/fba/seed_iaop_menus.py \ + --base http://127.0.0.1:8001/api/v1 \ + --username admin --password '' + ``` + 脚本幂等:注册 iAOP 6 模块菜单(含子菜单)、创建 `engineer`/`viewer` + 角色并按 PRD 8.2 分配菜单。之后在 FBA 后台给用户分配角色即可。 + 未播种时前端自动降级为本地角色过滤,菜单不会空白。 - **Phase 3**:FBA UI(Vue3 + AntDV)构建时设置 `VITE_GLOB_API_URL=http://<域名>/fba`,产物由 nginx 挂在 `/fba-admin/`, 作为用户/角色/菜单管理界面。 diff --git a/deploy/fba/seed_iaop_menus.py b/deploy/fba/seed_iaop_menus.py new file mode 100644 index 0000000..4286156 --- /dev/null +++ b/deploy/fba/seed_iaop_menus.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""iAOP 菜单/角色播种到 FBA(Epic #159 · Phase 2)。 + +把 iAOP 的 6 个功能模块注册为 FBA sys_menu,并创建 engineer / viewer 角色、 +按 PRD 8.2 三级权限分配菜单,使 Sider 菜单可由 FBA 服务端按角色授权驱动 +(前端 web/shared/session.js 的 menuTree() 消费 /sys/menus/sidebar)。 + +命名约定(与 session.js 的匹配逻辑对应,改动需双侧同步): + · 模块菜单 name = 模块 key(cockpit/chat/studio/admin/users/mobile) + · 子菜单 name = 子页 key(import/hyper/layout/version/models/kb/alerts/audit) + · studio/admin 为目录(type=0)挂子菜单(type=1),其余为叶子菜单(type=1) + +幂等:按 name 查重,已存在的菜单/角色跳过创建,只补齐缺失项; + 角色菜单关联每次按目标集合全量刷新(PUT /sys/roles/{id}/menus)。 + +用法(服务器上执行,仅需 Python 3.8+ 标准库): + python3 seed_iaop_menus.py \ + --base http://127.0.0.1:8001/api/v1 \ + --username admin --password '' + +说明:登录走 /auth/login/swagger(HTTP Basic,免图形验证码), +专为运维/调试通道;不依赖 LOGIN_CAPTCHA_ENABLED 开关。 +""" + +import argparse +import base64 +import getpass +import json +import sys +import urllib.error +import urllib.request + +# ---- iAOP 模块注册表(镜像 web/shared/session.js 的 MODULES,顺序即菜单顺序) ---- +MODULES = [ + {"key": "cockpit", "title": "配置化驾驶舱", "icon": "🚀", "path": "/cockpit/"}, + {"key": "chat", "title": "对话助手", "icon": "💬", "path": "/chat/assistant.html"}, + {"key": "studio", "title": "模板配置台", "icon": "🧩", "path": "/studio/", "children": [ + {"key": "import", "title": "点位导入"}, + {"key": "hyper", "title": "模型超参"}, + {"key": "layout", "title": "驾驶舱编排"}, + {"key": "version", "title": "版本发布"}, + ]}, + {"key": "admin", "title": "管理控制台", "icon": "⚙️", "path": "/admin/", "children": [ + {"key": "models", "title": "模型管理"}, + {"key": "kb", "title": "知识库管理"}, + {"key": "alerts", "title": "告警确认"}, + {"key": "audit", "title": "审计查询"}, + ]}, + {"key": "users", "title": "用户与角色", "icon": "👤", "path": "/auth/users.html"}, + {"key": "mobile", "title": "移动端驾驶舱", "icon": "📱", "path": "/mobile/"}, +] + +# 角色 → 可见模块(PRD 8.2;admin 通常为 is_superuser,无需分配,看到全部) +ROLE_MENUS = { + "viewer": ["cockpit", "chat", "mobile"], + "engineer": ["cockpit", "chat", "mobile", "studio"], +} + + +class FbaClient: + def __init__(self, base: str, username: str, password: str): + self.base = base.rstrip("/") + cred = base64.b64encode(f"{username}:{password}".encode()).decode() + d = self._req("POST", "/auth/login/swagger", + headers={"Authorization": f"Basic {cred}"}) + self.token = d["data"]["access_token"] + print(f"[ok] 登录成功:{username}({d['data']['user']['username']})") + + def _req(self, method: str, path: str, body=None, headers=None): + h = {"Content-Type": "application/json"} + if getattr(self, "token", None): + h["Authorization"] = f"Bearer {self.token}" + if headers: + h.update(headers) + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(self.base + path, data=data, + headers=h, method=method) + try: + with urllib.request.urlopen(req, timeout=15) as r: + d = json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + detail = e.read().decode(errors="replace")[:300] + raise SystemExit(f"[fail] {method} {path} → HTTP {e.code}: {detail}") + if d.get("code") != 200: + raise SystemExit(f"[fail] {method} {path} → {d.get('msg')}") + return d + + def get(self, path): + return self._req("GET", path)["data"] + + def post(self, path, body): + return self._req("POST", path, body) + + def put(self, path, body): + return self._req("PUT", path, body) + + +def flatten_menu_names(tree, out=None): + """菜单树 → {name: id}(含子级)""" + out = out if out is not None else {} + for node in tree or []: + if node.get("name"): + out[node["name"]] = node["id"] + flatten_menu_names(node.get("children"), out) + return out + + +def main(): + ap = argparse.ArgumentParser(description="iAOP 菜单/角色播种到 FBA") + ap.add_argument("--base", default="http://127.0.0.1:8001/api/v1", + help="FBA API 根(直连容器端口用 8001;走 nginx 用 http:///fba/api/v1)") + ap.add_argument("--username", default="admin") + ap.add_argument("--password", default=None, help="缺省时交互输入") + args = ap.parse_args() + password = args.password or getpass.getpass("FBA 管理员密码: ") + + cli = FbaClient(args.base, args.username, password) + + # ---- 1. 播种菜单(幂等:按 name 查重) -------------------------------- + existing = flatten_menu_names(cli.get("/sys/menus")) + created, skipped = 0, 0 + + def ensure_menu(name, title, path, mtype, sort, parent_id=None): + nonlocal created, skipped + if name in existing: + skipped += 1 + return existing[name] + body = { + "title": title, "name": name, "path": path, + "parent_id": parent_id, "sort": sort, "icon": None, + "type": mtype, # 0目录 1菜单 + "component": None, "perms": f"iaop:{name}", + "status": 1, "display": 1, "cache": 0, + "link": None, "remark": "iAOP 模块(seed_iaop_menus.py)", + } + cli.post("/sys/menus", body) + created += 1 + # 创建后重新拉取以拿到 id(接口不回传 id) + nonlocal_existing = flatten_menu_names(cli.get("/sys/menus")) + existing.update(nonlocal_existing) + print(f"[ok] 创建菜单 {name}({title})") + return existing[name] + + for i, m in enumerate(MODULES): + children = m.get("children") + if children: + pid = ensure_menu(m["key"], m["title"], m["path"], 0, i) + for j, c in enumerate(children): + ensure_menu(c["key"], c["title"], f"{m['path']}#{c['key']}", + 1, j, parent_id=pid) + else: + ensure_menu(m["key"], m["title"], m["path"], 1, i) + print(f"[ok] 菜单:新建 {created},已存在跳过 {skipped}") + + # ---- 2. 播种角色并分配菜单 ------------------------------------------- + menu_ids = flatten_menu_names(cli.get("/sys/menus")) + + def module_menu_ids(mod_key): + """模块目录 id + 其子菜单 id(叶子模块仅自身 id)""" + ids = [menu_ids[mod_key]] + mod = next(m for m in MODULES if m["key"] == mod_key) + for c in mod.get("children", []): + ids.append(menu_ids[c["key"]]) + return ids + + roles = {r["name"]: r["id"] for r in cli.get("/sys/roles/all")} + for role_name, mod_keys in ROLE_MENUS.items(): + if role_name in roles: + rid = roles[role_name] + print(f"[ok] 角色已存在:{role_name}(id={rid}),刷新菜单关联") + else: + cli.post("/sys/roles", { + "name": role_name, "status": 1, + "is_filter_scopes": True, + "remark": "iAOP 角色(seed_iaop_menus.py)", + }) + roles = {r["name"]: r["id"] for r in cli.get("/sys/roles/all")} + rid = roles[role_name] + print(f"[ok] 创建角色:{role_name}(id={rid})") + ids = [] + for k in mod_keys: + ids.extend(module_menu_ids(k)) + cli.put(f"/sys/roles/{rid}/menus", {"menus": ids}) + print(f"[ok] 角色 {role_name} 菜单:{', '.join(mod_keys)}({len(ids)} 项)") + + # ---- 3. 验证 sidebar --------------------------------------------------- + side = cli.get("/sys/menus/sidebar") + names = [n.get("name") for n in side or []] + print(f"[ok] 当前管理员 sidebar 可见菜单:{names}") + + print("\n完成。后续:在 FBA 后台创建用户并分配 engineer/viewer 角色," + "前端 Sider 即按服务端授权渲染(admin 为超管可见全部)。") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/web/shared/pro-header.js b/web/shared/pro-header.js index 34fd3dd..92f75db 100644 --- a/web/shared/pro-header.js +++ b/web/shared/pro-header.js @@ -241,14 +241,14 @@ menu.appendChild(wrap); } - function renderMenu(user) { + function renderMenu(mods) { 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) { + // 菜单即模块清单:Epic #159 Phase 2 起由 session.js menuTree() + // 输出(FBA 服务端按角色授权过滤,本地角色过滤兜底) + (mods || []).forEach(function (m) { if (m.children && m.children.length) addSubMenu(m); else addLeafItem(m.key, m.icon, m.title, prefix + m.url); }); @@ -296,12 +296,12 @@ }; } - /* ---- 用户区(后端优先、本地降级由 session.js 内部处理) --------------- */ + /* ---- 用户区 + 菜单(FBA 服务端授权优先,本地角色过滤兜底) ------------- */ IAOP_SESSION.currentUser().then(function (u) { - renderMenu(u); userBox.innerHTML = ""; if (!u) { userBox.appendChild(el("span", "pro-tag pro-tag-default", "未登录")); + renderMenu([]); return; } var avatar = el("span", "pro-avatar", @@ -311,6 +311,8 @@ var color = IAOP_SESSION.ROLE_TAG_COLORS[u.role] || "default"; var label = IAOP_SESSION.ROLE_LABELS[u.role] || u.role; userBox.appendChild(el("span", "pro-tag pro-tag-" + color, label)); + // 菜单异步:FBA token 存在时取服务端授权菜单树(Phase 2) + IAOP_SESSION.menuTree(u).then(renderMenu); }); bindPageTabs(); diff --git a/web/shared/session.js b/web/shared/session.js index c810b74..f042ef5 100644 --- a/web/shared/session.js +++ b/web/shared/session.js @@ -396,6 +396,57 @@ var IAOP_SESSION = (function () { return !!m && m.roles.indexOf(role) >= 0; } + /* ---- Epic #159 Phase 2:服务端驱动菜单 ---------------------------------- + * 有 FBA token 时:GET /fba/api/v1/sys/menus/sidebar 取当前用户在服务端 + * 被授权的菜单树(vben5 结构,按角色在服务端过滤),与本地 MODULES 注册表 + * 按 name == module.key 匹配,得到「服务端授权 ∩ 本地已知模块」的最终菜单; + * 子菜单同理按 child.key 匹配过滤。 + * FBA 不可用 / 无匹配(菜单未播种 deploy/fba/seed_iaop_menus.py)时, + * 降级为本地角色过滤,保证菜单不空白。 + * 返回 Promise。 */ + function menuTree(user) { + if (!user) return Promise.resolve([]); + if (!readFbaToken()) return Promise.resolve(modulesFor(user.role)); + return fbaFetch("/sys/menus/sidebar") + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (!d || d.code !== 200 || !Array.isArray(d.data)) { + return modulesFor(user.role); + } + var allowed = {}; // moduleKey -> true(无子菜单)| {childKey: true} + d.data.forEach(function (node) { + if (!node || !node.name) return; + var mod = null; + for (var i = 0; i < MODULES.length; i++) { + if (MODULES[i].key === node.name) { mod = MODULES[i]; break; } + } + if (!mod) return; // FBA 自有菜单(系统管理等)不匹配,跳过 + if (node.children && node.children.length) { + var kids = {}; + node.children.forEach(function (c) { + if (c && c.name) kids[c.name] = true; + }); + allowed[mod.key] = kids; + } else { + allowed[mod.key] = true; + } + }); + var mods = MODULES.filter(function (m) { return !!allowed[m.key]; }) + .map(function (m) { + if (!m.children || !m.children.length || allowed[m.key] === true) return m; + var kids = allowed[m.key]; + var copy = Object.assign({}, m, { + children: m.children.filter(function (c) { return !!kids[c.key]; }) + }); + return copy; + }); + // 服务端树里没有任何 iAOP 模块(未播种)→ 本地角色过滤兜底 + return mods.length ? mods : modulesFor(user.role); + }) + .catch(function () { return modulesFor(user.role); }); + } + + return { MODULES: MODULES, ROLE_LABELS: ROLE_LABELS, @@ -409,7 +460,8 @@ var IAOP_SESSION = (function () { /* FBA 新增 */ fbaStatus: fbaStatus, getCaptcha: getCaptcha, - getAuthHeaders: getAuthHeaders + getAuthHeaders: getAuthHeaders, + menuTree: menuTree }; })();