v0.16.0: 后台体验升级——i18n(默认英文)/浅深主题/字号放大/关站维护页/个人设置(头像·语言·主题·密码·MCP Token)/权限管理(菜单+MCP·API)/询盘抽屉翻倍+PHP序列化数据清洗(22.7万行)+去裸JSON
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* # 路由 /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 direction="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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user