Files
dusuniot/src/routes/admin.permissions.tsx
T

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>
);
}