feat(portal-shell): extract domain API layer and migrate 31 widgets
Task 4-10 of portal-shell data abstraction plan (M1-M2). Add 7 domain API modules under src/lib/api/ (parent/admin/teacher/ student/universal/sidebar/topbar), each exposing semantic hooks that wrap useWidgetQuery/useWidgetMutation and return flattened domain models. Widget code now imports from @/lib/api instead of inlining gql literals. - 31 widgets migrated (gql literal count in widgets: 0) - 7 test files (85 cases, all passing) - topbar.useNotifications renamed to useNotificationBell to avoid barrel export collision with universal.useNotifications - typecheck + lint (0 errors) + test (85/85) verified
This commit is contained in:
176
apps/portal-shell/src/widgets/admin/rbac-manager/index.tsx
Normal file
176
apps/portal-shell/src/widgets/admin/rbac-manager/index.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"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-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">角色权限</h3>
|
||||
<p className="mt-sm text-small text-ink-muted">暂无角色数据</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">角色权限</h3>
|
||||
<div className="mt-sm flex gap-md">
|
||||
{/* 左侧角色列表 */}
|
||||
<div className="w-64 flex-shrink-0">
|
||||
<p className="text-small text-ink-muted">角色</p>
|
||||
<ul className="mt-xs space-y-xs">
|
||||
{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-button border px-sm py-xs text-left text-small ${
|
||||
isSelected
|
||||
? "border-accent bg-accent-subtle text-ink"
|
||||
: "border-rule bg-paper text-ink"
|
||||
}`}
|
||||
>
|
||||
{role.name}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 右侧权限矩阵 */}
|
||||
<div className="flex-1 overflow-x-auto">
|
||||
{permissions.length === 0 ? (
|
||||
<p className="text-small text-ink-muted">暂无权限数据</p>
|
||||
) : (
|
||||
<table className="w-full text-tiny">
|
||||
<thead>
|
||||
<tr className="border-b border-rule text-ink-muted">
|
||||
<th className="py-xs text-left">权限</th>
|
||||
{roles.map((role) => (
|
||||
<th
|
||||
key={role.id}
|
||||
className={`py-xs text-center ${
|
||||
role.id === effectiveRoleId
|
||||
? "text-ink"
|
||||
: "text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
{role.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissions.map((perm) => (
|
||||
<tr key={perm.id} className="border-b border-rule">
|
||||
<td className="py-xs">
|
||||
<p className="text-ink">{perm.name}</p>
|
||||
<p className="text-tiny text-ink-muted">
|
||||
{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-xs 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user