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:
SpecialX
2026-07-13 13:05:29 +08:00
parent fad6e43b47
commit 7cf9aec20e
40 changed files with 6707 additions and 7 deletions

View File

@@ -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}` }] },