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,651 @@
"use client";
/**
* Admin domain API语义化 Hook
*
* 为 widgets/admin/* 提供语义化查询/变更 Hook封装 GraphQL DOC 与类型映射。
* widget 通过 `import { useUsers } from "@/lib/api/admin"` 调用。
*
* 设计:
* - 查询 Hook 返回 UseQueryResult<TData>data 已归一化(剥离外层 query 字段)
* - 变更 Hook 返回 { run, loading, error }run 抛 ApiError 表示业务失败
* - 分页查询返回 UseQueryResult<PaginatedResult<T>>,保留 items + total
*
* 关联spec §2.2、§5.6
*/
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import { useWidgetMutation } from "@/lib/useWidgetMutation";
import { ApiError } from "./errors";
import type { PaginatedResult, Pagination, UseQueryResult } from "./types";
import {
GET_USERS_DOC,
UPDATE_USER_STATUS_DOC,
UPDATE_USER_ROLE_DOC,
GET_ROLES_DOC,
GET_PERMISSIONS_DOC,
UPDATE_ROLE_PERMISSIONS_DOC,
GET_AUDIT_LOGS_DOC,
GET_INVITATION_CODES_DOC,
CREATE_INVITATION_CODE_DOC,
REVOKE_INVITATION_CODE_DOC,
GET_SCHOOL_DOC,
UPDATE_SCHOOL_DOC,
GET_PLUGIN_REGISTRY_DOC,
GET_ROLE_PLUGIN_MAPPING_DOC,
GET_LAYOUT_TEMPLATES_DOC,
GET_ROLE_LAYOUT_DEFAULT_DOC,
UPDATE_PLUGIN_REGISTRY_DOC,
UPDATE_ROLE_PLUGIN_MAPPING_DOC,
UPDATE_ROLE_LAYOUT_DEFAULT_DOC,
RESET_USER_LAYOUT_OVERRIDE_DOC,
} from "./operations/admin.graphql";
// ============================================================
// Types: User management
// ============================================================
export interface User {
id: string;
name: string;
email: string;
role: string;
status: string;
createdAt: string;
}
export type UserQueryVars = {
role: string | null;
limit: number;
offset: number;
};
// ============================================================
// Types: RBAC
// ============================================================
export interface Permission {
id: string;
name: string;
resource: string;
action: string;
description: string;
}
export interface RolePermission {
id: string;
name: string;
resource: string;
action: string;
}
export interface Role {
id: string;
name: string;
permissions: RolePermission[];
}
// ============================================================
// Types: Audit logs
// ============================================================
export interface AuditLog {
id: string;
userId: string;
userName: string;
action: string;
resource: string;
resourceId: string;
ip: string;
timestamp: string;
details: string;
}
export interface AuditLogFilter {
userId?: string | null;
action?: string | null;
resource?: string | null;
}
// ============================================================
// Types: Invitation codes
// ============================================================
export interface InvitationCode {
id: string;
code: string;
role: string;
status: string;
usedCount: number;
maxUses: number;
expiresAt: string;
createdAt: string;
createdBy: string;
}
export interface CreateInvitationCodeInput {
role: string;
maxUses: number;
ttlHours: number;
}
export type CreatedInvitationCode = Pick<
InvitationCode,
"id" | "code" | "role" | "maxUses" | "expiresAt"
>;
// ============================================================
// Types: School
// ============================================================
export interface School {
id: string;
name: string;
address: string;
phone: string;
email: string;
currentAcademicYear: string;
currentTerm: string;
semesterStart: string;
semesterEnd: string;
}
export interface SchoolInput {
name?: string;
address?: string;
phone?: string;
email?: string;
currentAcademicYear?: string;
currentTerm?: string;
semesterStart?: string;
semesterEnd?: string;
}
// ============================================================
// Types: Plugin manager
// ============================================================
export interface RegistryItem {
pluginId: string;
category: string;
version: string;
displayName: string;
description: string;
requiredRoles: string[];
isBuiltin: boolean;
isActive: boolean;
defaultSlot: string;
defaultSize: { colSpan: number; rowSpan: number };
defaultProps: Record<string, unknown>;
propsSchema: Record<string, unknown>;
}
export interface RoleMapping {
role: string;
pluginId: string;
slot: string;
sortOrder: number;
isEnabled: boolean;
widgetProps: Record<string, unknown>;
}
export interface RolePluginMappingInput {
pluginId: string;
slot: string;
sortOrder: number;
isEnabled: boolean;
widgetProps: Record<string, unknown>;
}
export interface LayoutTemplate {
layoutId: string;
displayName: string;
description: string;
availableSlots: string[];
}
export interface RoleLayoutDefault {
role: string;
layoutId: string;
slotOverrides: Record<string, unknown>;
}
export interface PluginRegistryInput {
isActive?: boolean;
defaultProps?: Record<string, unknown>;
}
export interface UpdatedPluginRegistry {
pluginId: string;
isActive: boolean;
defaultProps: Record<string, unknown>;
}
export interface UpdatedRoleMapping {
role: string;
pluginId: string;
isEnabled: boolean;
}
// ============================================================
// Hooks: User management
// ============================================================
export function useUsers(
vars: UserQueryVars,
): UseQueryResult<PaginatedResult<User>> {
const result = useWidgetQuery<
{ users: PaginatedResult<User> },
UserQueryVars
>(GET_USERS_DOC, vars);
return {
...result,
data: result.data?.users,
};
}
export function useUpdateUserStatus(): {
run: (id: string, status: string) => Promise<{ id: string; status: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateUserStatus: { id: string; status: string } },
{ id: string; status: string }
>(UPDATE_USER_STATUS_DOC);
const run = async (
id: string,
status: string,
): Promise<{ id: string; status: string }> => {
const data = await rawRun({ id, status });
if (!data?.updateUserStatus) {
throw new ApiError("Failed to update user status", "INTERNAL_ERROR");
}
return data.updateUserStatus;
};
return { run, loading, error };
}
export function useUpdateUserRole(): {
run: (id: string, role: string) => Promise<{ id: string; role: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateUserRole: { id: string; role: string } },
{ id: string; role: string }
>(UPDATE_USER_ROLE_DOC);
const run = async (
id: string,
role: string,
): Promise<{ id: string; role: string }> => {
const data = await rawRun({ id, role });
if (!data?.updateUserRole) {
throw new ApiError("Failed to update user role", "INTERNAL_ERROR");
}
return data.updateUserRole;
};
return { run, loading, error };
}
// ============================================================
// Hooks: RBAC
// ============================================================
export function useRoles(): UseQueryResult<Role[]> {
const result = useWidgetQuery<{ roles: Role[] }, Record<string, never>>(
GET_ROLES_DOC,
{},
);
return {
...result,
data: result.data?.roles,
};
}
export function usePermissions(): UseQueryResult<Permission[]> {
const result = useWidgetQuery<
{ permissions: Permission[] },
Record<string, never>
>(GET_PERMISSIONS_DOC, {});
return {
...result,
data: result.data?.permissions,
};
}
export function useUpdateRolePermissions(): {
run: (roleId: string, permissionIds: string[]) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateRolePermissions: { id: string } },
{ roleId: string; permissionIds: string[] }
>(UPDATE_ROLE_PERMISSIONS_DOC);
const run = async (
roleId: string,
permissionIds: string[],
): Promise<{ id: string }> => {
const data = await rawRun({ roleId, permissionIds });
if (!data?.updateRolePermissions) {
throw new ApiError("Failed to update role permissions", "INTERNAL_ERROR");
}
return data.updateRolePermissions;
};
return { run, loading, error };
}
// ============================================================
// Hooks: Audit logs
// ============================================================
export function useAuditLogs(
filter: AuditLogFilter,
pagination: Pagination,
): UseQueryResult<PaginatedResult<AuditLog>> {
const result = useWidgetQuery<
{ auditLogs: PaginatedResult<AuditLog> },
{ filter: AuditLogFilter; limit: number; offset: number }
>(GET_AUDIT_LOGS_DOC, {
filter,
limit: pagination.limit,
offset: pagination.offset,
});
return {
...result,
data: result.data?.auditLogs,
};
}
// ============================================================
// Hooks: Invitation codes
// ============================================================
export function useInvitationCodes(
status: string | null,
): UseQueryResult<InvitationCode[]> {
const result = useWidgetQuery<
{ invitationCodes: InvitationCode[] },
{ status: string | null }
>(GET_INVITATION_CODES_DOC, { status });
return {
...result,
data: result.data?.invitationCodes,
};
}
export function useCreateInvitationCode(): {
run: (input: CreateInvitationCodeInput) => Promise<CreatedInvitationCode>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ createInvitationCode: CreatedInvitationCode },
{ input: CreateInvitationCodeInput }
>(CREATE_INVITATION_CODE_DOC);
const run = async (
input: CreateInvitationCodeInput,
): Promise<CreatedInvitationCode> => {
const data = await rawRun({ input });
if (!data?.createInvitationCode) {
throw new ApiError("Failed to create invitation code", "INTERNAL_ERROR");
}
return data.createInvitationCode;
};
return { run, loading, error };
}
export function useRevokeInvitationCode(): {
run: (id: string) => Promise<{ id: string; status: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ revokeInvitationCode: { id: string; status: string } },
{ id: string }
>(REVOKE_INVITATION_CODE_DOC);
const run = async (id: string): Promise<{ id: string; status: string }> => {
const data = await rawRun({ id });
if (!data?.revokeInvitationCode) {
throw new ApiError("Failed to revoke invitation code", "INTERNAL_ERROR");
}
return data.revokeInvitationCode;
};
return { run, loading, error };
}
// ============================================================
// Hooks: School settings
// ============================================================
export function useSchool(): UseQueryResult<School | null> {
const result = useWidgetQuery<
{ school: School | null },
Record<string, never>
>(GET_SCHOOL_DOC, {});
return {
...result,
data: result.data?.school ?? null,
};
}
export function useUpdateSchool(): {
run: (input: SchoolInput) => Promise<School>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<{ updateSchool: School }, { input: SchoolInput }>(
UPDATE_SCHOOL_DOC,
);
const run = async (input: SchoolInput): Promise<School> => {
const data = await rawRun({ input });
if (!data?.updateSchool) {
throw new ApiError("Failed to update school", "INTERNAL_ERROR");
}
return data.updateSchool;
};
return { run, loading, error };
}
// ============================================================
// Hooks: Plugin manager
// ============================================================
export function usePluginRegistry(): UseQueryResult<RegistryItem[]> {
const result = useWidgetQuery<
{ pluginRegistry: RegistryItem[] },
Record<string, never>
>(GET_PLUGIN_REGISTRY_DOC, {});
return {
...result,
data: result.data?.pluginRegistry,
};
}
export function useRolePluginMapping(
role: string,
): UseQueryResult<RoleMapping[]> {
const result = useWidgetQuery<
{ rolePluginMapping: RoleMapping[] },
{ role: string | null }
>(GET_ROLE_PLUGIN_MAPPING_DOC, { role });
return {
...result,
data: result.data?.rolePluginMapping,
};
}
export function useLayoutTemplates(): UseQueryResult<LayoutTemplate[]> {
const result = useWidgetQuery<
{ layoutTemplates: LayoutTemplate[] },
Record<string, never>
>(GET_LAYOUT_TEMPLATES_DOC, {});
return {
...result,
data: result.data?.layoutTemplates,
};
}
export function useRoleLayoutDefault(
role: string,
): UseQueryResult<RoleLayoutDefault | null> {
const result = useWidgetQuery<
{ roleLayoutDefault: RoleLayoutDefault | null },
{ role: string | null }
>(GET_ROLE_LAYOUT_DEFAULT_DOC, { role });
return {
...result,
data: result.data?.roleLayoutDefault ?? null,
};
}
export function useUpdatePluginRegistry(): {
run: (
pluginId: string,
input: PluginRegistryInput,
) => Promise<UpdatedPluginRegistry>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updatePluginRegistry: UpdatedPluginRegistry },
{ pluginId: string; input: PluginRegistryInput }
>(UPDATE_PLUGIN_REGISTRY_DOC);
const run = async (
pluginId: string,
input: PluginRegistryInput,
): Promise<UpdatedPluginRegistry> => {
const data = await rawRun({ pluginId, input });
if (!data?.updatePluginRegistry) {
throw new ApiError("Failed to update plugin registry", "INTERNAL_ERROR");
}
return data.updatePluginRegistry;
};
return { run, loading, error };
}
export function useUpdateRolePluginMapping(): {
run: (
role: string,
mappings: RolePluginMappingInput[],
) => Promise<UpdatedRoleMapping[]>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateRolePluginMapping: UpdatedRoleMapping[] },
{ role: string; mappings: RolePluginMappingInput[] }
>(UPDATE_ROLE_PLUGIN_MAPPING_DOC);
const run = async (
role: string,
mappings: RolePluginMappingInput[],
): Promise<UpdatedRoleMapping[]> => {
const data = await rawRun({ role, mappings });
if (!data?.updateRolePluginMapping) {
throw new ApiError(
"Failed to update role plugin mapping",
"INTERNAL_ERROR",
);
}
return data.updateRolePluginMapping;
};
return { run, loading, error };
}
export function useUpdateRoleLayoutDefault(): {
run: (
role: string,
layoutId: string,
) => Promise<{ role: string; layoutId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateRoleLayoutDefault: { role: string; layoutId: string } },
{ role: string; layoutId: string }
>(UPDATE_ROLE_LAYOUT_DEFAULT_DOC);
const run = async (
role: string,
layoutId: string,
): Promise<{ role: string; layoutId: string }> => {
const data = await rawRun({ role, layoutId });
if (!data?.updateRoleLayoutDefault) {
throw new ApiError(
"Failed to update role layout default",
"INTERNAL_ERROR",
);
}
return data.updateRoleLayoutDefault;
};
return { run, loading, error };
}
export function useResetUserLayoutOverride(): {
run: (userId: string) => Promise<{ userId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ resetUserLayoutOverride: { userId: string } },
{ userId: string }
>(RESET_USER_LAYOUT_OVERRIDE_DOC);
const run = async (userId: string): Promise<{ userId: string }> => {
const data = await rawRun({ userId });
if (!data?.resetUserLayoutOverride) {
throw new ApiError(
"Failed to reset user layout override",
"INTERNAL_ERROR",
);
}
return data.resetUserLayoutOverride;
};
return { run, loading, error };
}