P2 修复(来自审计报告): - 2.4.4: Server Action 错误消息 i18n 化(attendance/elective 全部 Action) - 2.5.3: 抽取 AttendancePageLayout 组件复用(admin/teacher 页面) - 2.5.4: 抽取 ElectivePageLayout 组件复用(admin/teacher 列表页) - 2.6.3: 考勤月历键盘导航(tabIndex + 方向键 + Home/End + role=grid) - 2.8.2: getStudentAttendanceSummary 分页优化(SQL 聚合统计 + LIMIT 分页) - 2.8.3: resolveCourseDisplayNames 缓存优化(React cache 去重) - 2.1.4: elective data-access 跨模块依赖接口抽象(resolvers.ts 可注入) P2 建议项: - 选课时间冲突检测(parseSchedule + isScheduleConflict 纯函数 + checkScheduleConflict) - 学分上限校验(MAX_CREDIT_PER_TERM + checkCreditLimit) - 考勤/选课数据导出 Excel(export.ts + API 路由扩展) 新增文件: - src/modules/attendance/components/attendance-page-layout.tsx - src/modules/elective/components/elective-page-layout.tsx - src/modules/elective/resolvers.ts - src/modules/attendance/export.ts - src/modules/elective/export.ts 校验: - npm run lint 通过(exit 0) - npx tsc --noEmit attendance/elective/parent 相关零错误
516 lines
17 KiB
TypeScript
516 lines
17 KiB
TypeScript
"use server";
|
||
|
||
import { requirePermission } from "@/shared/lib/auth-guard";
|
||
import { Permissions } from "@/shared/types/permissions";
|
||
import type { ActionState } from "@/shared/types/action-state";
|
||
import { revalidatePath } from "next/cache";
|
||
import { getTranslations } from "next-intl/server";
|
||
import { getCurrentStudentUser } from "@/modules/users/data-access";
|
||
import {
|
||
createTextbook,
|
||
createChapter,
|
||
updateChapterContent,
|
||
deleteChapter,
|
||
createKnowledgePoint,
|
||
deleteKnowledgePoint,
|
||
updateKnowledgePoint,
|
||
updateTextbook,
|
||
deleteTextbook,
|
||
reorderChapters,
|
||
verifyChapterBelongsToTextbook,
|
||
verifyKnowledgePointBelongsToTextbook,
|
||
getKnowledgePointsByChapterId,
|
||
createPrerequisite,
|
||
deletePrerequisite,
|
||
getPrerequisiteEdgesForTextbook,
|
||
} from "./data-access";
|
||
import {
|
||
getKnowledgePointsWithRelations,
|
||
getStudentKpMastery,
|
||
getClassKpMastery,
|
||
} from "./data-access-graph";
|
||
import { getClassStudents } from "@/modules/classes/data-access-students";
|
||
import {
|
||
CreateTextbookSchema,
|
||
UpdateTextbookSchema,
|
||
CreateChapterSchema,
|
||
UpdateChapterContentSchema,
|
||
CreateKnowledgePointSchema,
|
||
UpdateKnowledgePointSchema,
|
||
CreatePrerequisiteSchema,
|
||
DeletePrerequisiteSchema,
|
||
} from "./schema";
|
||
import { hasCycleAfterAddingEdge } from "./utils";
|
||
import type { GraphViewMode, KnowledgeGraphData, KnowledgePoint, MasteryInfo } from "./types";
|
||
import { handleActionError } from "@/shared/lib/action-utils";
|
||
|
||
const getStringValue = (formData: FormData, key: string): string => {
|
||
const value = formData.get(key)
|
||
return typeof value === "string" ? value : ""
|
||
}
|
||
|
||
export async function reorderChaptersAction(
|
||
chapterId: string,
|
||
newIndex: number,
|
||
parentId: string | null,
|
||
textbookId: string
|
||
): Promise<ActionState> {
|
||
try {
|
||
const t = await getTranslations("textbooks.action");
|
||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||
// P0-4 资源归属校验:防止越权操作其他教材的章节
|
||
const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId)
|
||
if (!belongs) {
|
||
return { success: false, message: t("chapterNotBelong") };
|
||
}
|
||
await reorderChapters(chapterId, newIndex, parentId);
|
||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||
return { success: true, message: t("chaptersReordered") };
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
export async function createTextbookAction(
|
||
prevState: ActionState | null,
|
||
formData: FormData
|
||
): Promise<ActionState> {
|
||
const t = await getTranslations("textbooks.action");
|
||
const parsed = CreateTextbookSchema.safeParse({
|
||
title: getStringValue(formData, "title"),
|
||
subject: getStringValue(formData, "subject"),
|
||
grade: getStringValue(formData, "grade"),
|
||
publisher: getStringValue(formData, "publisher"),
|
||
});
|
||
|
||
if (!parsed.success) {
|
||
return {
|
||
success: false,
|
||
message: t("fillRequired"),
|
||
errors: parsed.error.flatten().fieldErrors,
|
||
};
|
||
}
|
||
|
||
try {
|
||
await requirePermission(Permissions.TEXTBOOK_CREATE);
|
||
await createTextbook(parsed.data);
|
||
revalidatePath("/teacher/textbooks");
|
||
return {
|
||
success: true,
|
||
message: t("createSuccess"),
|
||
};
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
export async function updateTextbookAction(
|
||
textbookId: string,
|
||
prevState: ActionState | null,
|
||
formData: FormData
|
||
): Promise<ActionState> {
|
||
const t = await getTranslations("textbooks.action");
|
||
const parsed = UpdateTextbookSchema.safeParse({
|
||
id: textbookId,
|
||
title: getStringValue(formData, "title"),
|
||
subject: getStringValue(formData, "subject"),
|
||
grade: getStringValue(formData, "grade"),
|
||
publisher: getStringValue(formData, "publisher"),
|
||
});
|
||
|
||
if (!parsed.success) {
|
||
return {
|
||
success: false,
|
||
message: t("fillRequired"),
|
||
errors: parsed.error.flatten().fieldErrors,
|
||
};
|
||
}
|
||
|
||
try {
|
||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||
await updateTextbook(parsed.data);
|
||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||
return {
|
||
success: true,
|
||
message: t("updateSuccess"),
|
||
};
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
export async function deleteTextbookAction(
|
||
textbookId: string
|
||
): Promise<ActionState> {
|
||
try {
|
||
const t = await getTranslations("textbooks.action");
|
||
await requirePermission(Permissions.TEXTBOOK_DELETE);
|
||
await deleteTextbook(textbookId);
|
||
revalidatePath("/teacher/textbooks");
|
||
return {
|
||
success: true,
|
||
message: t("deleteSuccess"),
|
||
};
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
export async function createChapterAction(
|
||
textbookId: string,
|
||
parentId: string | undefined,
|
||
prevState: ActionState | null,
|
||
formData: FormData
|
||
): Promise<ActionState> {
|
||
const t = await getTranslations("textbooks.action");
|
||
const parsed = CreateChapterSchema.safeParse({
|
||
textbookId,
|
||
title: getStringValue(formData, "title"),
|
||
parentId,
|
||
order: 0,
|
||
});
|
||
|
||
if (!parsed.success) {
|
||
return {
|
||
success: false,
|
||
message: t("titleRequired"),
|
||
errors: parsed.error.flatten().fieldErrors,
|
||
};
|
||
}
|
||
|
||
try {
|
||
await requirePermission(Permissions.TEXTBOOK_CREATE);
|
||
await createChapter(parsed.data);
|
||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||
return { success: true, message: t("chapterCreateSuccess") };
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
export async function updateChapterContentAction(
|
||
chapterId: string,
|
||
content: string,
|
||
textbookId: string
|
||
): Promise<ActionState> {
|
||
const t = await getTranslations("textbooks.action");
|
||
const parsed = UpdateChapterContentSchema.safeParse({
|
||
chapterId,
|
||
content,
|
||
});
|
||
|
||
if (!parsed.success) {
|
||
return {
|
||
success: false,
|
||
message: t("invalidContent"),
|
||
errors: parsed.error.flatten().fieldErrors,
|
||
};
|
||
}
|
||
|
||
try {
|
||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||
// P0-4 资源归属校验
|
||
const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId)
|
||
if (!belongs) {
|
||
return { success: false, message: t("chapterNotBelong") };
|
||
}
|
||
await updateChapterContent(parsed.data);
|
||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||
return { success: true, message: t("contentUpdateSuccess") };
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
export async function deleteChapterAction(
|
||
chapterId: string,
|
||
textbookId: string
|
||
): Promise<ActionState> {
|
||
try {
|
||
const t = await getTranslations("textbooks.action");
|
||
await requirePermission(Permissions.TEXTBOOK_DELETE);
|
||
// P0-4 资源归属校验
|
||
const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId)
|
||
if (!belongs) {
|
||
return { success: false, message: t("chapterNotBelong") };
|
||
}
|
||
await deleteChapter(chapterId);
|
||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||
return { success: true, message: t("chapterDeleteSuccess") };
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
export async function createKnowledgePointAction(
|
||
chapterId: string,
|
||
textbookId: string,
|
||
prevState: ActionState | null,
|
||
formData: FormData
|
||
): Promise<ActionState> {
|
||
const t = await getTranslations("textbooks.action");
|
||
const parsed = CreateKnowledgePointSchema.safeParse({
|
||
name: getStringValue(formData, "name"),
|
||
description: getStringValue(formData, "description") || undefined,
|
||
anchorText: getStringValue(formData, "anchorText") || undefined,
|
||
chapterId,
|
||
});
|
||
|
||
if (!parsed.success) {
|
||
return {
|
||
success: false,
|
||
message: t("nameRequired"),
|
||
errors: parsed.error.flatten().fieldErrors,
|
||
};
|
||
}
|
||
|
||
try {
|
||
await requirePermission(Permissions.TEXTBOOK_CREATE);
|
||
// P0-4 资源归属校验:确保 chapter 属于该 textbook,防止跨教材越权创建知识点
|
||
const chapterBelongs = await verifyChapterBelongsToTextbook(chapterId, textbookId);
|
||
if (!chapterBelongs) {
|
||
return { success: false, message: t("chapterNotBelong") };
|
||
}
|
||
await createKnowledgePoint(parsed.data);
|
||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||
return { success: true, message: t("kpCreateSuccess") };
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
export async function deleteKnowledgePointAction(
|
||
kpId: string,
|
||
textbookId: string
|
||
): Promise<ActionState> {
|
||
try {
|
||
const t = await getTranslations("textbooks.action");
|
||
await requirePermission(Permissions.TEXTBOOK_DELETE);
|
||
// P0-4 资源归属校验
|
||
const belongs = await verifyKnowledgePointBelongsToTextbook(kpId, textbookId)
|
||
if (!belongs) {
|
||
return { success: false, message: t("kpNotBelong") };
|
||
}
|
||
await deleteKnowledgePoint(kpId);
|
||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||
return { success: true, message: t("kpDeleteSuccess") };
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
export async function updateKnowledgePointAction(
|
||
kpId: string,
|
||
textbookId: string,
|
||
prevState: ActionState | null,
|
||
formData: FormData
|
||
): Promise<ActionState> {
|
||
const t = await getTranslations("textbooks.action");
|
||
const parsed = UpdateKnowledgePointSchema.safeParse({
|
||
id: kpId,
|
||
name: getStringValue(formData, "name"),
|
||
description: getStringValue(formData, "description") || undefined,
|
||
anchorText: getStringValue(formData, "anchorText") || undefined,
|
||
});
|
||
|
||
if (!parsed.success) {
|
||
return {
|
||
success: false,
|
||
message: t("nameRequired"),
|
||
errors: parsed.error.flatten().fieldErrors,
|
||
};
|
||
}
|
||
|
||
try {
|
||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||
// P0-4 资源归属校验
|
||
const belongs = await verifyKnowledgePointBelongsToTextbook(kpId, textbookId)
|
||
if (!belongs) {
|
||
return { success: false, message: t("kpNotBelong") };
|
||
}
|
||
await updateKnowledgePoint(parsed.data);
|
||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||
return { success: true, message: t("kpUpdateSuccess") };
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* P2-3 知识点懒加载:按章节 ID 获取知识点。
|
||
*
|
||
* 用于详情页切换章节时按需加载,避免一次性拉取整本教材所有知识点。
|
||
* 需 TEXTBOOK_READ 权限。
|
||
*/
|
||
export async function getKnowledgePointsByChapterAction(
|
||
chapterId: string,
|
||
textbookId: string
|
||
): Promise<ActionState<KnowledgePoint[]>> {
|
||
try {
|
||
const t = await getTranslations("textbooks.action");
|
||
await requirePermission(Permissions.TEXTBOOK_READ);
|
||
// P0-4 资源归属校验:确保 chapter 属于该 textbook
|
||
const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId);
|
||
if (!belongs) {
|
||
return { success: false, message: t("chapterNotBelong") };
|
||
}
|
||
const knowledgePoints = await getKnowledgePointsByChapterId(chapterId);
|
||
return { success: true, message: t("ok"), data: knowledgePoints };
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
// ===== 知识图谱 Actions =====
|
||
|
||
/**
|
||
* 获取知识图谱数据。
|
||
*
|
||
* - structure 模式:仅返回知识点+依赖+题目数
|
||
* - student-mastery 模式:附加当前学生掌握度
|
||
* - class-mastery 模式:附加班级平均掌握度(仅教师可用)
|
||
*/
|
||
export async function getKnowledgeGraphDataAction(
|
||
textbookId: string,
|
||
viewMode: GraphViewMode,
|
||
): Promise<ActionState<KnowledgeGraphData>> {
|
||
try {
|
||
const t = await getTranslations("textbooks.action");
|
||
await requirePermission(Permissions.TEXTBOOK_READ);
|
||
|
||
const knowledgePointsData = await getKnowledgePointsWithRelations(textbookId);
|
||
const masteryMap: Record<string, MasteryInfo> = {};
|
||
|
||
if (viewMode === "student-mastery") {
|
||
const student = await getCurrentStudentUser();
|
||
if (student) {
|
||
const mastery = await getStudentKpMastery(student.id, textbookId);
|
||
for (const [kpId, info] of mastery) {
|
||
masteryMap[kpId] = info;
|
||
}
|
||
}
|
||
// 无学生身份时 masteryMap 保持为空,前端将显示"未测评"状态
|
||
} else if (viewMode === "class-mastery") {
|
||
// 获取教师所带班级的所有学生 ID,计算班级平均掌握度
|
||
const students = await getClassStudents({ status: "active" });
|
||
const studentIds = students.map((s) => s.id);
|
||
if (studentIds.length > 0) {
|
||
const mastery = await getClassKpMastery(studentIds, textbookId);
|
||
for (const [kpId, info] of mastery) {
|
||
masteryMap[kpId] = info;
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
message: t("ok"),
|
||
data: { knowledgePoints: knowledgePointsData, masteryMap, viewMode },
|
||
};
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 声明前置依赖(含循环检测)。
|
||
*/
|
||
export async function createPrerequisiteAction(
|
||
formData: FormData,
|
||
): Promise<ActionState> {
|
||
try {
|
||
const t = await getTranslations("textbooks.action");
|
||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||
|
||
const knowledgePointId = getStringValue(formData, "knowledgePointId");
|
||
const prerequisiteKpId = getStringValue(formData, "prerequisiteKpId");
|
||
const textbookId = getStringValue(formData, "textbookId");
|
||
|
||
const parsed = CreatePrerequisiteSchema.safeParse({
|
||
knowledgePointId,
|
||
prerequisiteKpId,
|
||
});
|
||
if (!parsed.success) {
|
||
return { success: false, message: t("invalidInput") };
|
||
}
|
||
|
||
// 归属校验:两个知识点都必须属于当前教材,防止跨教材越权
|
||
const kpBelongs = await verifyKnowledgePointBelongsToTextbook(
|
||
parsed.data.knowledgePointId,
|
||
textbookId,
|
||
);
|
||
if (!kpBelongs) {
|
||
return { success: false, message: t("kpNotBelong") };
|
||
}
|
||
const prereqBelongs = await verifyKnowledgePointBelongsToTextbook(
|
||
parsed.data.prerequisiteKpId,
|
||
textbookId,
|
||
);
|
||
if (!prereqBelongs) {
|
||
return { success: false, message: t("kpNotBelong") };
|
||
}
|
||
|
||
// 循环检测
|
||
const existingEdges = await getPrerequisiteEdgesForTextbook(textbookId);
|
||
if (hasCycleAfterAddingEdge(
|
||
existingEdges,
|
||
parsed.data.knowledgePointId,
|
||
parsed.data.prerequisiteKpId,
|
||
)) {
|
||
return { success: false, message: t("cyclicDependency") };
|
||
}
|
||
|
||
await createPrerequisite(parsed.data);
|
||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||
return { success: true, message: t("prerequisiteCreated") };
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除前置依赖。
|
||
*/
|
||
export async function deletePrerequisiteAction(
|
||
formData: FormData,
|
||
): Promise<ActionState> {
|
||
try {
|
||
const t = await getTranslations("textbooks.action");
|
||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||
|
||
const knowledgePointId = getStringValue(formData, "knowledgePointId");
|
||
const prerequisiteKpId = getStringValue(formData, "prerequisiteKpId");
|
||
const textbookId = getStringValue(formData, "textbookId");
|
||
|
||
const parsed = DeletePrerequisiteSchema.safeParse({
|
||
knowledgePointId,
|
||
prerequisiteKpId,
|
||
});
|
||
if (!parsed.success) {
|
||
return { success: false, message: t("invalidInput") };
|
||
}
|
||
|
||
// 归属校验:防止跨教材越权删除前置依赖
|
||
const kpBelongs = await verifyKnowledgePointBelongsToTextbook(
|
||
parsed.data.knowledgePointId,
|
||
textbookId,
|
||
);
|
||
if (!kpBelongs) {
|
||
return { success: false, message: t("kpNotBelong") };
|
||
}
|
||
const prereqBelongs = await verifyKnowledgePointBelongsToTextbook(
|
||
parsed.data.prerequisiteKpId,
|
||
textbookId,
|
||
);
|
||
if (!prereqBelongs) {
|
||
return { success: false, message: t("kpNotBelong") };
|
||
}
|
||
|
||
await deletePrerequisite(parsed.data);
|
||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||
return { success: true, message: t("prerequisiteDeleted") };
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|