Files
Edu/apps/portal-shell/src/widgets/admin/rbac-manager/index.tsx
SpecialX a28a6bd6ea feat(portal-shell): clean widget design tokens and fix lint:tokens (P1-6)
516 mechanical replacements across 25 widget files:
- spacing xs/sm/md/lg/xl to numeric 1/2/3/4/6
- text-heading-3 to text-lg font-semibold
- bg-danger to bg-destructive
- border border dedup

Fix .eslintrc.tokens.js to use typescript-eslint parser (was importing
uninstalled @typescript-eslint/parser). lint:tokens now passes.
2026-07-22 15:07:38 +08:00

177 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* rbac-manageradmin / main
*
* 角色权限管理。左侧角色列表,右侧权限矩阵(行=权限,列=角色)。
* 勾选/取消勾选时调用 mutation 保存该角色的权限集合。
*
* 关联portal-shell spec §5.6 统一 Hook
*/
import { useMemo, useState } from "react";
import {
usePermissions,
useRoles,
useUpdateRolePermissions,
} from "@/lib/api/admin";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function RbacManager(_props: PluginProps): React.ReactElement {
const [selectedRoleId, setSelectedRoleId] = useState<string>("");
const [busyRoleId, setBusyRoleId] = useState<string | null>(null);
const rolesResult = useRoles();
const permsResult = usePermissions();
const { run: updatePermissions } = useUpdateRolePermissions();
const roles = rolesResult.data ?? [];
const permissions = permsResult.data ?? [];
// 选中角色默认取第一个
const effectiveRoleId =
selectedRoleId.length > 0
? selectedRoleId
: roles.length > 0
? (roles[0]?.id ?? "")
: "";
// 当前角色 → 权限 ID 集合
const rolePermissionMap = useMemo(() => {
const map = new Map<string, Set<string>>();
for (const role of roles) {
const set = new Set<string>();
for (const p of role.permissions) {
set.add(p.id);
}
map.set(role.id, set);
}
return map;
}, [roles]);
const handleToggle = async (
roleId: string,
permissionId: string,
): Promise<void> => {
const current = rolePermissionMap.get(roleId);
if (current === undefined) {
return;
}
const next = new Set(current);
if (next.has(permissionId)) {
next.delete(permissionId);
} else {
next.add(permissionId);
}
setBusyRoleId(roleId);
try {
await updatePermissions(roleId, Array.from(next));
await rolesResult.refetch();
} finally {
setBusyRoleId(null);
}
};
if (rolesResult.loading && !rolesResult.data) {
return <PluginSkeleton variant="table" />;
}
if (roles.length === 0) {
return (
<section className="rounded-xl border bg-card p-4">
<h3 className="text-lg font-semibold text-foreground"></h3>
<p className="mt-2 text-sm text-muted-foreground"></p>
</section>
);
}
return (
<section className="rounded-xl border bg-card p-4">
<h3 className="text-lg font-semibold text-foreground"></h3>
<div className="mt-2 flex gap-4">
{/* 左侧角色列表 */}
<div className="w-64 flex-shrink-0">
<p className="text-sm text-muted-foreground"></p>
<ul className="mt-1 space-y-1">
{roles.map((role) => {
const isSelected = role.id === effectiveRoleId;
return (
<li key={role.id}>
<button
type="button"
onClick={() => setSelectedRoleId(role.id)}
aria-pressed={isSelected}
className={`w-full rounded-md border px-2 py-1 text-left text-sm ${
isSelected
? "border-accent bg-primary-subtle text-foreground"
: "border bg-background text-foreground"
}`}
>
{role.name}
</button>
</li>
);
})}
</ul>
</div>
{/* 右侧权限矩阵 */}
<div className="flex-1 overflow-x-auto">
{permissions.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="border-b border text-muted-foreground">
<th className="py-1 text-left"></th>
{roles.map((role) => (
<th
key={role.id}
className={`py-1 text-center ${
role.id === effectiveRoleId
? "text-foreground"
: "text-muted-foreground"
}`}
>
{role.name}
</th>
))}
</tr>
</thead>
<tbody>
{permissions.map((perm) => (
<tr key={perm.id} className="border-b border">
<td className="py-1">
<p className="text-foreground">{perm.name}</p>
<p className="text-xs text-muted-foreground">
{perm.resource} / {perm.action}
</p>
</td>
{roles.map((role) => {
const checked =
rolePermissionMap.get(role.id)?.has(perm.id) ?? false;
const isBusy = busyRoleId === role.id;
return (
<td key={role.id} className="py-1 text-center">
<input
type="checkbox"
checked={checked}
disabled={isBusy}
onChange={() => handleToggle(role.id, perm.id)}
aria-label={`${role.name} - ${perm.name}`}
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</section>
);
}