安全:createPrerequisiteAction 补充 prerequisiteKpId 归属校验;deletePrerequisiteAction 补充双知识点归属校验,防止跨教材越权。 功能:实现图谱添加/删除前置依赖(Dialog + Select 选择知识点 + 调用 Server Action + 自动刷新图谱),替换原 no-op 回调。 i18n:修复 8 处硬编码英文字符串(textbook-reader/chapter-sidebar-list/textbook-card/textbook-form-dialog/textbook-settings-dialog/create-chapter-dialog/teacher-textbook-reader),新增 saveFailed/createFailed/updateFailed/deleteFailed/questionCreatorDefaultContent 等 key。 类型安全:graph-prerequisite-edge.tsx 使用 GraphEdgeData 类型经 unknown 安全转换,替代裸 as 断言。 规范:analytics.tsx 移动 use client 指令到文件第一行;同步架构文档 005 JSON 类型定义(GraphNodeData/GraphEdgeData/MasteryLevel)。 验证:教材模块 lint 零错误、tsc 零错误、193 个单元测试全部通过。
508 lines
16 KiB
TypeScript
508 lines
16 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,
|
||
} from "./data-access-graph";
|
||
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;
|
||
}
|
||
}
|
||
} else if (viewMode === "class-mastery") {
|
||
// 简化实现:暂不获取班级学生列表,返回空 masteryMap
|
||
// 后续迭代可通过 classes 模块获取教师所带班级学生 ID,
|
||
// 再从 data-access-graph 导入 getClassKpMastery 并调用
|
||
// getClassKpMastery(studentIds, textbookId) 计算班级平均掌握度
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|