181 lines
7.1 KiB
TypeScript
181 lines
7.1 KiB
TypeScript
/**
|
|
* # 路由 /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<string, { menus: string[]; mcp: boolean; api: boolean; configured: boolean }>;
|
|
|
|
/** # 成员变量 可编辑角色(admin 恒全量,不在此列) **/
|
|
const EDITABLE_ROLES = ["manager", "editor", "customer"] as const;
|
|
|
|
/** # 成员变量 角色中文标签与说明 **/
|
|
const ROLE_META: Record<string, { label: string; desc: string }> = {
|
|
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<Record<string, { menus: Set<string>; 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 <Forbidden />;
|
|
|
|
/** # 函数 Tree 勾选变化:父子联动后把「整组全选」折叠回分组 key 存储(等价语义) */
|
|
function handleCheck(role: string, checked: { checked: string[] }) {
|
|
setDraft((d) => {
|
|
const menus = new Set<string>();
|
|
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<string>();
|
|
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 (
|
|
<Card size="small">
|
|
<PageHeader
|
|
title={t("权限管理")}
|
|
extra={
|
|
<Button type="primary" icon={<Save size={14} aria-hidden />} loading={saving} onClick={() => void handleSave()}>
|
|
{t("保存")}
|
|
</Button>
|
|
}
|
|
/>
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 16 }}
|
|
message={t("按角色配置菜单功能权限与 MCP / API 访问。admin 角色恒为全量,不可编辑;未勾选任何菜单的角色无法访问对应后台页面。")}
|
|
/>
|
|
<Space orientation="vertical" size={16} style={{ display: "flex" }}>
|
|
{EDITABLE_ROLES.map((role) =>
|
|
draft[role] ? (
|
|
<Card
|
|
key={role}
|
|
type="inner"
|
|
size="small"
|
|
title={
|
|
<Space size={8}>
|
|
<Tag color={role === "manager" ? "geekblue" : role === "editor" ? "cyan" : "default"}>{t(ROLE_META[role]!.label)}</Tag>
|
|
<Typography.Text type="secondary" style={{ fontSize: 13, fontWeight: 400 }}>{t(ROLE_META[role]!.desc)}</Typography.Text>
|
|
</Space>
|
|
}
|
|
extra={
|
|
<Space size={16}>
|
|
<Space size={6}>
|
|
<Typography.Text style={{ fontSize: 13 }}>MCP</Typography.Text>
|
|
<Switch size="small" checked={draft[role]!.mcp} onChange={(v) => setDraft((d) => ({ ...d, [role]: { ...d[role]!, mcp: v } }))} />
|
|
</Space>
|
|
<Space size={6}>
|
|
<Typography.Text style={{ fontSize: 13 }}>API</Typography.Text>
|
|
<Switch size="small" checked={draft[role]!.api} onChange={(v) => setDraft((d) => ({ ...d, [role]: { ...d[role]!, api: v } }))} />
|
|
</Space>
|
|
</Space>
|
|
}
|
|
>
|
|
<Tree
|
|
checkable
|
|
selectable={false}
|
|
defaultExpandAll
|
|
checkedKeys={checkedKeys(role)}
|
|
onCheck={(_, info) => handleCheck(role, { checked: (info as unknown as { checked: string[] }).checked })}
|
|
treeData={buildMenuTree(t)}
|
|
style={{ maxWidth: 560 }}
|
|
/>
|
|
</Card>
|
|
) : (
|
|
<Spin key={role} />
|
|
),
|
|
)}
|
|
</Space>
|
|
</Card>
|
|
);
|
|
}
|