Files
NextEdu/src/modules/textbooks/actions.ts
SpecialX e2e0487a3b feat(attendance,elective): 实现所有 P2 长期改进项
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 相关零错误
2026-06-23 09:02:41 +08:00

516 lines
17 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.
"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)
}
}