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:
651
apps/portal-shell/src/lib/api/admin.ts
Normal file
651
apps/portal-shell/src/lib/api/admin.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user