/** * # 路由 /admin/permissions 权限管理(仅管理员可编辑) * 按角色配置:菜单功能权限(树形勾选)+ MCP / API 访问开关。 * admin 角色恒为全量且不可编辑(防锁死);customer 未勾选任何菜单即无后台权限。 * 配置存 settings.permissions;服务端 requireCapability 叠加校验(域内至少一个菜单)。 */ import { createFileRoute, useRouter } from "@tanstack/react-router"; import { App, Alert, Button, Card, Space, Spin, Switch, Tag, Tree, Typography } from "antd"; import { Save } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { adminGetPermissions, adminSavePermissions } from "~/server/permissions"; import { Forbidden, PageHeader, guardAdmin } from "~/components/admin/common"; import { ADMIN_MENU_GROUPS } from "~/admin-menus"; import { useAdminT } from "~/admin-i18n"; export const Route = createFileRoute("/admin/permissions")({ loader: () => guardAdmin(() => adminGetPermissions()), component: PermissionsPage, }); /** # 类型 权限页数据(loader 返回:各角色有效授权 + 是否已显式配置) **/ type PermData = Record; /** # 成员变量 可编辑角色(admin 恒全量,不在此列) **/ const EDITABLE_ROLES = ["manager", "editor", "customer"] as const; /** # 成员变量 角色中文标签与说明 **/ const ROLE_META: Record = { manager: { label: "经理", desc: "默认:内容 + 商务全部菜单,MCP / API 开启" }, editor: { label: "编辑", desc: "默认:内容全部菜单,MCP / API 关闭" }, customer: { label: "客户", desc: "商城注册用户,默认无任何后台权限" }, }; /** # 成员变量 分组 key → 该组全部菜单 key(父子联动勾选换算用) **/ const GROUP_CHILDREN = new Map(ADMIN_MENU_GROUPS.map((g) => [`group:${g.label}`, g.children.map((m) => m.key)])); /** # 函数 菜单树数据(分组 → 菜单;标题经 t() 翻译,key 即菜单路由) */ function buildMenuTree(t: (s: string) => string) { return ADMIN_MENU_GROUPS.map((g) => ({ title: t(g.label), key: `group:${g.label}`, children: g.children.map((m) => ({ title: t(m.label), key: m.key })), })); } /** # 函数 权限管理页面组件 */ function PermissionsPage() { const t = useAdminT(); const data = Route.useLoaderData() as PermData | null; const router = useRouter(); const { message } = App.useApp(); /** # 成员变量 各角色草稿(勾选菜单 + MCP/API 开关) **/ const [draft, setDraft] = useState; mcp: boolean; api: boolean }>>({}); /** # 成员变量 保存中 **/ const [saving, setSaving] = useState(false); useEffect(() => { if (!data) return; setDraft( Object.fromEntries( EDITABLE_ROLES.map((role) => [ role, { menus: new Set(data[role]?.menus ?? []), mcp: data[role]?.mcp ?? false, api: data[role]?.api ?? false }, ]), ), ); }, [data]); /** # 成员变量 未登录/无权限(非 admin)占位 **/ const forbidden = useMemo(() => !data, [data]); if (forbidden) return ; /** # 函数 Tree 勾选变化:父子联动后把「整组全选」折叠回分组 key 存储(等价语义) */ function handleCheck(role: string, checked: { checked: string[] }) { setDraft((d) => { const menus = new Set(); for (const k of checked.checked) { if (k.startsWith("group:")) { const children = GROUP_CHILDREN.get(k) ?? []; children.forEach((c) => menus.add(c)); } else menus.add(k); } return { ...d, [role]: { ...d[role]!, menus } }; }); } /** # 函数 Tree 默认勾选项(整组全选渲染为分组勾选) */ function checkedKeys(role: string): string[] { const menus = draft[role]?.menus ?? new Set(); const keys: string[] = []; for (const [gk, children] of GROUP_CHILDREN) { if (children.every((c) => menus.has(c))) keys.push(gk); else children.forEach((c) => menus.has(c) && keys.push(c)); } return keys; } /** # 函数 保存:提交全部角色草稿 */ async function handleSave() { setSaving(true); try { await adminSavePermissions({ data: { roles: Object.fromEntries( EDITABLE_ROLES.map((role) => [ role, { menus: [...(draft[role]?.menus ?? [])], mcp: draft[role]?.mcp ?? false, api: draft[role]?.api ?? false }, ]), ), }, }); message.success(t("权限配置已保存")); await router.invalidate(); } catch (e) { message.error(e instanceof Error ? e.message : t("保存失败")); } finally { setSaving(false); } } return ( } loading={saving} onClick={() => void handleSave()}> {t("保存")} } /> {EDITABLE_ROLES.map((role) => draft[role] ? ( {t(ROLE_META[role]!.label)} {t(ROLE_META[role]!.desc)} } extra={ MCP setDraft((d) => ({ ...d, [role]: { ...d[role]!, mcp: v } }))} /> API setDraft((d) => ({ ...d, [role]: { ...d[role]!, api: v } }))} /> } > handleCheck(role, { checked: (info as unknown as { checked: string[] }).checked })} treeData={buildMenuTree(t)} style={{ maxWidth: 560 }} /> ) : ( ), )} ); }