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 已更新
This commit is contained in:
@@ -21,6 +21,25 @@ import {
|
||||
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 {
|
||||
@@ -45,6 +64,18 @@ const db = {
|
||||
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>(
|
||||
@@ -507,6 +538,263 @@ export const handlers = [
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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}` }] },
|
||||
|
||||
Reference in New Issue
Block a user