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.
177 lines
5.7 KiB
TypeScript
177 lines
5.7 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* rbac-manager(admin / 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>
|
||
);
|
||
}
|