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:
SpecialX
2026-07-17 13:07:24 +08:00
parent f623dcf4a7
commit 2910a90271
73 changed files with 8206 additions and 87 deletions

View File

@@ -0,0 +1,176 @@
"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-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>
);
}