Files
Edu/apps/admin-portal/src/mocks/handlers.ts
SpecialX 7cf9aec20e feat(admin-portal): 完成参考项目差距闭环 - 4 批次补齐 + Vitest 测试
差距分析闭环(workline §7-§8):
- P0:审计子模块 3 页 + 学校组织 7 页
- P1:系统设置 4 Card + 公告管理 + 邀请码管理
- P2:文件管理 + AI 配置
- P6:5 测试文件 / 69 用例全过

共享基础设施:
- permissions +11 权限点 +14 路由
- view-models +20 接口
- i18n +100 key
- graphql-client +25 operation
- fixtures/handlers +15 mock +20 handler

质量:typecheck + lint 零错误,vitest 69/69 通过,arch.db 已更新
2026-07-13 13:05:29 +08:00

805 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MSW Handlers
*
* 仲裁 ARB-004 §4MSW 拦截所有 GraphQL/HTTP 请求
* 端点:
* - POST /api/auth/login - mock 登录
* - POST /api/admin/graphql - admin 命名空间 GraphQL
* - GET /ws - WebSocketmock-socket 在 use-websocket.ts 中处理)
*/
import { graphql, http, HttpResponse } from "msw";
import {
mockCurrentUser,
mockUsers,
mockRoles,
mockPermissions,
mockViewports,
mockOrganization,
mockClasses,
mockTeachers,
mockStudents,
mockAuditLogs,
mockDashboard,
mockSystemSettings,
mockSchools,
mockGrades,
mockGradeInsights,
mockDepartments,
mockAcademicYears,
mockAuditOverviewStats,
mockAuditTrend,
mockDataChangeLogs,
mockLoginLogs,
mockAnnouncements,
mockInvitationCodes,
mockFiles,
mockFileStats,
mockAiProviders,
mockAiUsage,
mockSecurityPolicy,
mockFileUploadConfig,
mockNotificationConfig,
mockBrandConfig,
genId,
} from "./fixtures";
import type {
UserViewModel,
RoleViewModel,
ViewportConfigViewModel,
PaginatedResult,
ListFilter,
} from "@/types/view-models";
const BASE_URL = "";
// 内存中可变副本(支持 mock 增删改)
const db = {
users: [...mockUsers] as UserViewModel[],
roles: [...mockRoles] as RoleViewModel[],
viewports: [...mockViewports] as ViewportConfigViewModel[],
permissions: [...mockPermissions],
organization: [...mockOrganization],
classes: [...mockClasses],
teachers: [...mockTeachers],
students: [...mockStudents],
auditLogs: [...mockAuditLogs],
systemSettings: { ...mockSystemSettings },
schools: [...mockSchools],
grades: [...mockGrades],
departments: [...mockDepartments],
academicYears: [...mockAcademicYears],
announcements: [...mockAnnouncements],
invitationCodes: [...mockInvitationCodes],
files: [...mockFiles],
aiProviders: [...mockAiProviders],
securityPolicy: { ...mockSecurityPolicy },
fileUploadConfig: { ...mockFileUploadConfig },
notificationConfig: { ...mockNotificationConfig },
brandConfig: { ...mockBrandConfig },
};
function paginate<T>(
items: T[],
filter: Partial<ListFilter>,
): PaginatedResult<T> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const start = (page - 1) * pageSize;
const end = start + pageSize;
const sliced = items.slice(start, end);
return {
items: sliced,
total: items.length,
page,
pageSize,
hasNext: end < items.length,
};
}
function recordAudit(
action: string,
resourceType: string,
resourceId: string,
afterState?: string,
): void {
db.auditLogs.unshift({
id: genId("al"),
eventId: genId("evt"),
occurredAt: Date.now(),
actorUserId: mockCurrentUser.id,
actorName: mockCurrentUser.name,
action,
resourceType,
resourceId,
ip: "127.0.0.1",
userAgent: "MSW-Mock",
beforeState: null,
afterState: afterState ?? null,
traceId: genId("trace"),
});
}
export const handlers = [
// ─── 认证 ────────────────────────────────────────────
http.post(`${BASE_URL}/api/auth/login`, async ({ request }) => {
const body = (await request.json()) as { email: string; password: string };
if (body.email === "admin@edu.test" && body.password === "admin123") {
return HttpResponse.json({
success: true,
data: {
user: mockCurrentUser,
tokens: { accessToken: "mock-jwt-token-admin-001" },
},
});
}
return HttpResponse.json(
{
success: false,
error: { message: "邮箱或密码错误" },
},
{ status: 401 },
);
}),
// ─── GraphQL admin 命名空间 ──────────────────────────
graphql.operation(({ query, variables }) => {
const operationName =
(query.match(/^\s*(query|mutation)\s+(\w+)/) || [])[2] ?? "";
// ── currentUser ──
if (operationName === "CurrentUser") {
return HttpResponse.json({ data: { currentUser: mockCurrentUser } });
}
// ── adminUsers ──
if (operationName === "AdminUsers") {
const filter = (variables.filter ?? {}) as Partial<ListFilter>;
let items = [...db.users];
if (filter.search) {
const q = filter.search.toLowerCase();
items = items.filter(
(u) =>
u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q),
);
}
if (filter.status && filter.status !== "all") {
items = items.filter((u) => u.status === filter.status);
}
const result = paginate(items, filter);
return HttpResponse.json({ data: { adminUsers: result } });
}
// ── adminUser ──
if (operationName === "AdminUser") {
const id = variables.id as string;
const user = db.users.find((u) => u.id === id) ?? null;
return HttpResponse.json({ data: { adminUser: user } });
}
// ── createUser ──
if (operationName === "CreateUser") {
const input = variables.input as {
email: string;
name: string;
password: string;
roleIds?: string[];
dataScope?: string;
organizationId?: string;
};
const roles = db.roles.filter((r) => input.roleIds?.includes(r.id));
const newUser: UserViewModel = {
id: genId("u"),
email: input.email,
name: input.name,
roles,
status: "active",
dataScope: (input.dataScope as UserViewModel["dataScope"]) ?? "SCHOOL",
organizationId: input.organizationId ?? null,
schoolName: "Edu 实验学校",
lastLoginAt: null,
createdAt: Date.now(),
updatedAt: Date.now(),
};
db.users.push(newUser);
recordAudit(
"USER_CREATE",
"iam.user",
newUser.id,
JSON.stringify({ email: newUser.email, name: newUser.name }),
);
return HttpResponse.json({
data: {
createUser: {
id: newUser.id,
email: newUser.email,
name: newUser.name,
status: newUser.status,
},
},
});
}
// ── updateUser ──
if (operationName === "UpdateUser") {
const id = variables.id as string;
const input = variables.input as {
name?: string;
email?: string;
roleIds?: string[];
dataScope?: string;
organizationId?: string;
};
const user = db.users.find((u) => u.id === id);
if (!user) {
return HttpResponse.json(
{ errors: [{ message: "用户不存在" }] },
{ status: 404 },
);
}
if (input.name) user.name = input.name;
if (input.email) user.email = input.email;
if (input.roleIds)
user.roles = db.roles.filter((r) => input.roleIds!.includes(r.id));
if (input.dataScope)
user.dataScope = input.dataScope as UserViewModel["dataScope"];
if (input.organizationId !== undefined)
user.organizationId = input.organizationId;
user.updatedAt = Date.now();
recordAudit("USER_UPDATE", "iam.user", user.id, JSON.stringify(input));
return HttpResponse.json({
data: {
updateUser: {
id: user.id,
email: user.email,
name: user.name,
status: user.status,
},
},
});
}
// ── toggleUserStatus ──
if (operationName === "ToggleUserStatus") {
const id = variables.id as string;
const newStatus = variables.status as UserViewModel["status"];
const user = db.users.find((u) => u.id === id);
if (!user) {
return HttpResponse.json(
{ errors: [{ message: "用户不存在" }] },
{ status: 404 },
);
}
const before = user.status;
user.status = newStatus;
user.updatedAt = Date.now();
recordAudit(
"USER_TOGGLE_STATUS",
"iam.user",
user.id,
JSON.stringify({ before, after: newStatus }),
);
return HttpResponse.json({
data: { toggleUserStatus: { id: user.id, status: user.status } },
});
}
// ── adminRoles ──
if (operationName === "AdminRoles") {
return HttpResponse.json({ data: { adminRoles: db.roles } });
}
// ── createRole ──
if (operationName === "CreateRole") {
const input = variables.input as {
name: string;
code: string;
description?: string;
dataScope?: string;
};
const newRole: RoleViewModel = {
id: genId("r"),
name: input.name,
code: input.code,
description: input.description ?? "",
permissions: [],
userCount: 0,
dataScope: (input.dataScope as RoleViewModel["dataScope"]) ?? "SCHOOL",
isSystem: false,
createdAt: Date.now(),
updatedAt: Date.now(),
};
db.roles.push(newRole);
recordAudit(
"ROLE_CREATE",
"iam.role",
newRole.id,
JSON.stringify({ name: newRole.name, code: newRole.code }),
);
return HttpResponse.json({
data: {
createRole: {
id: newRole.id,
name: newRole.name,
code: newRole.code,
},
},
});
}
// ── updateRolePermissions ──
if (operationName === "UpdateRolePermissions") {
const roleId = variables.roleId as string;
const codes = variables.permissionCodes as string[];
const role = db.roles.find((r) => r.id === roleId);
if (!role) {
return HttpResponse.json(
{ errors: [{ message: "角色不存在" }] },
{ status: 404 },
);
}
role.permissions = db.permissions.filter((p) => codes.includes(p.code));
role.updatedAt = Date.now();
recordAudit(
"ROLE_UPDATE",
"iam.role",
role.id,
JSON.stringify({ permissionCodes: codes }),
);
return HttpResponse.json({
data: {
updateRolePermissions: {
id: role.id,
permissions: role.permissions.map((p) => ({
id: p.id,
code: p.code,
})),
},
},
});
}
// ── adminPermissions ──
if (operationName === "AdminPermissions") {
return HttpResponse.json({ data: { adminPermissions: db.permissions } });
}
// ── adminViewports ──
if (operationName === "AdminViewports") {
const scope = variables.scope as string | undefined;
const items = scope
? db.viewports.filter((v) => v.scope === scope)
: db.viewports;
return HttpResponse.json({ data: { adminViewports: items } });
}
// ── updateViewport ──
if (operationName === "UpdateViewport") {
const id = variables.id as string;
const input = variables.input as {
label?: string;
sortOrder?: number;
isVisible?: boolean;
requiredPermission?: string | null;
};
const vp = db.viewports.find((v) => v.id === id);
if (!vp) {
return HttpResponse.json(
{ errors: [{ message: "视口不存在" }] },
{ status: 404 },
);
}
if (input.label !== undefined) vp.label = input.label;
if (input.sortOrder !== undefined) vp.sortOrder = input.sortOrder;
if (input.isVisible !== undefined) vp.isVisible = input.isVisible;
if (input.requiredPermission !== undefined)
vp.requiredPermission = input.requiredPermission;
recordAudit(
"VIEWPORT_UPDATE",
"iam.viewport",
vp.id,
JSON.stringify(input),
);
return HttpResponse.json({
data: {
updateViewport: {
id: vp.id,
key: vp.key,
label: vp.label,
sortOrder: vp.sortOrder,
isVisible: vp.isVisible,
},
},
});
}
// ── adminOrganization ──
if (operationName === "AdminOrganization") {
const parentId = variables.parentId as string | null | undefined;
const items =
parentId === undefined || parentId === null
? db.organization.filter((n) => n.parentId === null)
: db.organization.filter((n) => n.parentId === parentId);
return HttpResponse.json({ data: { adminOrganization: items } });
}
// ── adminClasses ──
if (operationName === "AdminClasses") {
const filter = (variables.filter ?? {}) as Partial<ListFilter>;
let items = [...db.classes];
if (filter.search) {
const q = filter.search.toLowerCase();
items = items.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.gradeName.toLowerCase().includes(q),
);
}
if (filter.schoolId) {
// mock单一学校
}
const result = paginate(items, filter);
return HttpResponse.json({ data: { adminClasses: result } });
}
// ── adminTeachers ──
if (operationName === "AdminTeachers") {
const filter = (variables.filter ?? {}) as Partial<ListFilter>;
let items = [...db.teachers];
if (filter.search) {
const q = filter.search.toLowerCase();
items = items.filter(
(t) =>
t.name.toLowerCase().includes(q) ||
t.email.toLowerCase().includes(q),
);
}
if (filter.status && filter.status !== "all") {
items = items.filter((t) => t.status === filter.status);
}
const result = paginate(items, filter);
return HttpResponse.json({ data: { adminTeachers: result } });
}
// ── adminStudents ──
if (operationName === "AdminStudents") {
const filter = (variables.filter ?? {}) as Partial<ListFilter>;
let items = [...db.students];
if (filter.search) {
const q = filter.search.toLowerCase();
items = items.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.email.toLowerCase().includes(q),
);
}
if (filter.status && filter.status !== "all") {
items = items.filter((s) => s.status === filter.status);
}
const result = paginate(items, filter);
return HttpResponse.json({ data: { adminStudents: result } });
}
// ── auditLogs ──
if (operationName === "AuditLogs") {
const filter = (variables.filter ?? {}) as Partial<ListFilter> & {
action?: string;
actorUserId?: string;
};
let items = [...db.auditLogs];
if (filter.search) {
const q = filter.search.toLowerCase();
items = items.filter(
(l) =>
l.actorName.toLowerCase().includes(q) ||
l.action.toLowerCase().includes(q) ||
l.resourceType.toLowerCase().includes(q),
);
}
if (filter.action) {
items = items.filter((l) => l.action === filter.action);
}
if (filter.actorUserId) {
items = items.filter((l) => l.actorUserId === filter.actorUserId);
}
const result = paginate(items, filter);
return HttpResponse.json({ data: { auditLogs: result } });
}
// ── adminDashboard ──
if (operationName === "AdminDashboard") {
return HttpResponse.json({ data: { adminDashboard: mockDashboard } });
}
// ── systemSettings ──
if (operationName === "SystemSettings") {
return HttpResponse.json({ data: { systemSettings: db.systemSettings } });
}
// ── updateSystemSettings ──
if (operationName === "UpdateSystemSettings") {
const input = variables.input as Partial<typeof mockSystemSettings>;
Object.assign(db.systemSettings, input);
recordAudit(
"SYSTEM_SETTINGS_UPDATE",
"admin.system",
"settings",
JSON.stringify(input),
);
return HttpResponse.json({
data: {
updateSystemSettings: {
schoolName: db.systemSettings.schoolName,
schoolCode: db.systemSettings.schoolCode,
academicYear: db.systemSettings.academicYear,
semester: db.systemSettings.semester,
},
},
});
}
// ── auditOverviewStats ──
if (operationName === "AuditOverviewStats") {
return HttpResponse.json({
data: { auditOverviewStats: mockAuditOverviewStats },
});
}
// ── auditTrend ──
if (operationName === "AuditTrend") {
return HttpResponse.json({ data: { auditTrend: mockAuditTrend } });
}
// ── dataChangeLogs ──
if (operationName === "DataChangeLogs") {
return HttpResponse.json({
data: {
dataChangeLogs: paginate(mockDataChangeLogs, variables.filter ?? {}),
},
});
}
// ── loginLogs ──
if (operationName === "LoginLogs") {
return HttpResponse.json({
data: { loginLogs: paginate(mockLoginLogs, variables.filter ?? {}) },
});
}
// ── adminSchools ──
if (operationName === "AdminSchools") {
return HttpResponse.json({ data: { adminSchools: db.schools } });
}
// ── adminGrades ──
if (operationName === "AdminGrades") {
const schoolId = variables.schoolId;
const grades = schoolId
? db.grades.filter((g) => g.schoolId === schoolId)
: db.grades;
return HttpResponse.json({ data: { adminGrades: grades } });
}
// ── adminGradeInsights ──
if (operationName === "AdminGradeInsights") {
return HttpResponse.json({
data: { adminGradeInsights: mockGradeInsights },
});
}
// ── adminDepartments ──
if (operationName === "AdminDepartments") {
const schoolId = variables.schoolId;
const depts = schoolId
? db.departments.filter((d) => d.schoolId === schoolId)
: db.departments;
return HttpResponse.json({ data: { adminDepartments: depts } });
}
// ── adminAcademicYears ──
if (operationName === "AdminAcademicYears") {
return HttpResponse.json({
data: { adminAcademicYears: db.academicYears },
});
}
// ── adminAnnouncements ──
if (operationName === "AdminAnnouncements") {
return HttpResponse.json({
data: {
adminAnnouncements: paginate(
db.announcements,
variables.filter ?? {},
),
},
});
}
// ── createAnnouncement ──
if (operationName === "CreateAnnouncement") {
const input = variables.input;
const newAnn = {
id: genId("ann"),
title: input.title,
content: input.content,
status: "draft" as const,
audience: input.audience ?? ["all"],
authorName: mockCurrentUser.name,
isPinned: false,
readCount: 0,
totalCount: 0,
publishedAt: null,
createdAt: Date.now(),
updatedAt: Date.now(),
};
db.announcements.unshift(newAnn);
recordAudit("ANNOUNCEMENT_CREATE", "announcement", newAnn.id);
return HttpResponse.json({
data: {
createAnnouncement: {
id: newAnn.id,
title: newAnn.title,
status: newAnn.status,
},
},
});
}
// ── publishAnnouncement ──
if (operationName === "PublishAnnouncement") {
const ann = db.announcements.find((a) => a.id === variables.id);
if (ann) {
ann.status = "published";
ann.publishedAt = Date.now();
ann.updatedAt = Date.now();
recordAudit("ANNOUNCEMENT_PUBLISH", "announcement", ann.id);
}
return HttpResponse.json({
data: {
publishAnnouncement: { id: variables.id, status: "published" },
},
});
}
// ── archiveAnnouncement ──
if (operationName === "ArchiveAnnouncement") {
const ann = db.announcements.find((a) => a.id === variables.id);
if (ann) {
ann.status = "archived";
ann.updatedAt = Date.now();
recordAudit("ANNOUNCEMENT_ARCHIVE", "announcement", ann.id);
}
return HttpResponse.json({
data: { archiveAnnouncement: { id: variables.id, status: "archived" } },
});
}
// ── toggleAnnouncementPin ──
if (operationName === "ToggleAnnouncementPin") {
const ann = db.announcements.find((a) => a.id === variables.id);
if (ann) {
ann.isPinned = !ann.isPinned;
ann.updatedAt = Date.now();
}
return HttpResponse.json({
data: {
toggleAnnouncementPin: {
id: variables.id,
isPinned: ann?.isPinned ?? false,
},
},
});
}
// ── adminInvitationCodes ──
if (operationName === "AdminInvitationCodes") {
return HttpResponse.json({
data: {
adminInvitationCodes: paginate(
db.invitationCodes,
variables.filter ?? {},
),
},
});
}
// ── generateInvitationCodes ──
if (operationName === "GenerateInvitationCodes") {
const input = variables.input;
const count = Math.min(input.count ?? 10, 1000);
const batchId = genId("bat");
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const codes: string[] = [];
for (let i = 0; i < count; i++) {
let code = "";
for (let j = 0; j < 8; j++) {
code += charset[Math.floor(Math.random() * charset.length)];
}
codes.push(code);
db.invitationCodes.unshift({
id: genId("ic"),
code,
status: "unused",
roleId: input.roleId ?? null,
roleName: null,
classId: input.classId ?? null,
className: null,
email: input.email ?? null,
batchId,
usedBy: null,
usedByName: null,
usedAt: null,
createdAt: Date.now(),
expiresAt: input.expiresAt ?? null,
});
}
recordAudit("INVITATION_CODE_GENERATE", "invitation_code", batchId);
return HttpResponse.json({
data: { generateInvitationCodes: { codes, batchId } },
});
}
// ── deleteInvitationCodes ──
if (operationName === "DeleteInvitationCodes") {
const ids = variables.ids as string[];
db.invitationCodes = db.invitationCodes.filter(
(ic) => !ids.includes(ic.id),
);
recordAudit("INVITATION_CODE_DELETE", "invitation_code", ids.join(","));
return HttpResponse.json({ data: { deleteInvitationCodes: true } });
}
// ── adminFiles ──
if (operationName === "AdminFiles") {
return HttpResponse.json({
data: { adminFiles: paginate(db.files, variables.filter ?? {}) },
});
}
// ── adminFileStats ──
if (operationName === "AdminFileStats") {
return HttpResponse.json({ data: { adminFileStats: mockFileStats } });
}
// ── deleteFiles ──
if (operationName === "DeleteFiles") {
const ids = variables.ids as string[];
db.files = db.files.filter((f) => !ids.includes(f.id));
recordAudit("FILE_DELETE", "file", ids.join(","));
return HttpResponse.json({ data: { deleteFiles: true } });
}
// ── aiProviders ──
if (operationName === "AiProviders") {
return HttpResponse.json({ data: { aiProviders: db.aiProviders } });
}
// ── updateAiProvider ──
if (operationName === "UpdateAiProvider") {
const provider = db.aiProviders.find((p) => p.id === variables.id);
if (provider) {
Object.assign(provider, variables.input);
}
return HttpResponse.json({
data: {
updateAiProvider: {
id: variables.id,
isActive: provider?.isActive ?? false,
},
},
});
}
// ── aiUsage ──
if (operationName === "AiUsage") {
return HttpResponse.json({ data: { aiUsage: mockAiUsage } });
}
// ── 未匹配的 operation ──
return HttpResponse.json(
{ errors: [{ message: `未实现的 GraphQL operation: ${operationName}` }] },
{ status: 501 },
);
}),
];