feat(textbooks): 知识图谱功能全面重构 — 前置依赖 + dagre 布局 + React Flow 可视化 + 师生双视角

将教材模块图谱从基本无用状态升级为完整知识图谱可视化系统。

数据层:新增 knowledgePointPrerequisites 表(复合主键+双外键 cascade);新增 data-access-graph.ts(server-only)知识点关联聚合、学生/班级掌握度查询;utils.ts 新增 hasCycleAfterAddingEdge(DFS 循环依赖检测)。

业务层:3 个新 Server Action(getKnowledgeGraphDataAction 三视图模式、createPrerequisiteAction 含循环检测、deletePrerequisiteAction);graph-layout.ts 重写为 dagre 分层有向图布局。

视图层:knowledge-graph.tsx 重写为 React Flow 主组件(全书视图+搜索高亮+关联节点高亮+章节着色);4 个新组件(graph-kp-node/graph-prerequisite-edge/graph-toolbar/graph-node-detail-panel);use-graph-data.ts 派生值模式避免 effect 中 setState。

架构:严格三层架构,客户端通过 Server Action 间接访问 server-only 数据层;权限校验+ i18n 全覆盖;架构文档 004/005 同步。

测试:utils.test.ts 新增 5 个循环检测测试,graph-layout.test.ts 重写 5 个 dagre 布局测试,全部 30 个教材模块单元测试通过。

附带提交 drizzle/0005 error-book 迁移文件以保持 journal 一致性。
This commit is contained in:
SpecialX
2026-06-23 00:13:03 +08:00
parent 15aa84b72c
commit 58656da983
28 changed files with 21377 additions and 575 deletions

View File

@@ -1,9 +1,11 @@
"use server";
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
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,
@@ -17,7 +19,15 @@ import {
reorderChapters,
verifyChapterBelongsToTextbook,
verifyKnowledgePointBelongsToTextbook,
getKnowledgePointsByChapterId,
createPrerequisite,
deletePrerequisite,
getPrerequisiteEdgesForTextbook,
} from "./data-access";
import {
getKnowledgePointsWithRelations,
getStudentKpMastery,
} from "./data-access-graph";
import {
CreateTextbookSchema,
UpdateTextbookSchema,
@@ -25,7 +35,12 @@ import {
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)
@@ -39,20 +54,18 @@ export async function reorderChaptersAction(
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: "Chapter does not belong to this textbook" };
return { success: false, message: t("chapterNotBelong") };
}
await reorderChapters(chapterId, newIndex, parentId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapters reordered successfully" };
return { success: true, message: t("chaptersReordered") };
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return { success: false, message: "Failed to reorder chapters" };
return handleActionError(e)
}
}
@@ -60,6 +73,7 @@ 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"),
@@ -70,7 +84,7 @@ export async function createTextbookAction(
if (!parsed.success) {
return {
success: false,
message: "Please fill in all required fields.",
message: t("fillRequired"),
errors: parsed.error.flatten().fieldErrors,
};
}
@@ -81,16 +95,10 @@ export async function createTextbookAction(
revalidatePath("/teacher/textbooks");
return {
success: true,
message: "Textbook created successfully.",
message: t("createSuccess"),
};
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return {
success: false,
message: "Failed to create textbook.",
};
return handleActionError(e)
}
}
@@ -99,6 +107,7 @@ export async function updateTextbookAction(
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const t = await getTranslations("textbooks.action");
const parsed = UpdateTextbookSchema.safeParse({
id: textbookId,
title: getStringValue(formData, "title"),
@@ -110,7 +119,7 @@ export async function updateTextbookAction(
if (!parsed.success) {
return {
success: false,
message: "Please fill in all required fields.",
message: t("fillRequired"),
errors: parsed.error.flatten().fieldErrors,
};
}
@@ -121,39 +130,28 @@ export async function updateTextbookAction(
revalidatePath(`/teacher/textbooks/${textbookId}`);
return {
success: true,
message: "Textbook updated successfully.",
message: t("updateSuccess"),
};
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return {
success: false,
message: "Failed to update textbook.",
};
return handleActionError(e)
}
}
export async function deleteTextbookAction(
textbookId: string
): Promise<ActionState> {
try {
await requirePermission(Permissions.TEXTBOOK_DELETE);
await deleteTextbook(textbookId);
revalidatePath("/teacher/textbooks");
return {
success: true,
message: "Textbook deleted successfully.",
};
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
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)
}
return {
success: false,
message: "Failed to delete textbook.",
};
}
}
export async function createChapterAction(
@@ -162,6 +160,7 @@ export async function createChapterAction(
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const t = await getTranslations("textbooks.action");
const parsed = CreateChapterSchema.safeParse({
textbookId,
title: getStringValue(formData, "title"),
@@ -172,7 +171,7 @@ export async function createChapterAction(
if (!parsed.success) {
return {
success: false,
message: "Title is required",
message: t("titleRequired"),
errors: parsed.error.flatten().fieldErrors,
};
}
@@ -181,12 +180,9 @@ export async function createChapterAction(
await requirePermission(Permissions.TEXTBOOK_CREATE);
await createChapter(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapter created successfully" };
return { success: true, message: t("chapterCreateSuccess") };
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return { success: false, message: "Failed to create chapter" };
return handleActionError(e)
}
}
@@ -195,6 +191,7 @@ export async function updateChapterContentAction(
content: string,
textbookId: string
): Promise<ActionState> {
const t = await getTranslations("textbooks.action");
const parsed = UpdateChapterContentSchema.safeParse({
chapterId,
content,
@@ -203,7 +200,7 @@ export async function updateChapterContentAction(
if (!parsed.success) {
return {
success: false,
message: "Invalid chapter content data",
message: t("invalidContent"),
errors: parsed.error.flatten().fieldErrors,
};
}
@@ -213,16 +210,13 @@ export async function updateChapterContentAction(
// P0-4 资源归属校验
const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId)
if (!belongs) {
return { success: false, message: "Chapter does not belong to this textbook" };
return { success: false, message: t("chapterNotBelong") };
}
await updateChapterContent(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Content updated successfully" };
return { success: true, message: t("contentUpdateSuccess") };
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return { success: false, message: "Failed to update content" };
return handleActionError(e)
}
}
@@ -231,20 +225,18 @@ export async function deleteChapterAction(
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: "Chapter does not belong to this textbook" };
return { success: false, message: t("chapterNotBelong") };
}
await deleteChapter(chapterId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapter deleted successfully" };
return { success: true, message: t("chapterDeleteSuccess") };
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return { success: false, message: "Failed to delete chapter" };
return handleActionError(e)
}
}
@@ -254,6 +246,7 @@ export async function createKnowledgePointAction(
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,
@@ -264,7 +257,7 @@ export async function createKnowledgePointAction(
if (!parsed.success) {
return {
success: false,
message: "Name is required",
message: t("nameRequired"),
errors: parsed.error.flatten().fieldErrors,
};
}
@@ -274,16 +267,13 @@ export async function createKnowledgePointAction(
// P0-4 资源归属校验:确保 chapter 属于该 textbook防止跨教材越权创建知识点
const chapterBelongs = await verifyChapterBelongsToTextbook(chapterId, textbookId);
if (!chapterBelongs) {
return { success: false, message: "Chapter does not belong to this textbook" };
return { success: false, message: t("chapterNotBelong") };
}
await createKnowledgePoint(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point created successfully" };
return { success: true, message: t("kpCreateSuccess") };
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return { success: false, message: "Failed to create knowledge point" };
return handleActionError(e)
}
}
@@ -292,20 +282,18 @@ export async function deleteKnowledgePointAction(
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: "Knowledge point does not belong to this textbook" };
return { success: false, message: t("kpNotBelong") };
}
await deleteKnowledgePoint(kpId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point deleted successfully" };
return { success: true, message: t("kpDeleteSuccess") };
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return { success: false, message: "Failed to delete knowledge point" };
return handleActionError(e)
}
}
@@ -315,6 +303,7 @@ export async function updateKnowledgePointAction(
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const t = await getTranslations("textbooks.action");
const parsed = UpdateKnowledgePointSchema.safeParse({
id: kpId,
name: getStringValue(formData, "name"),
@@ -325,7 +314,7 @@ export async function updateKnowledgePointAction(
if (!parsed.success) {
return {
success: false,
message: "Name is required",
message: t("nameRequired"),
errors: parsed.error.flatten().fieldErrors,
};
}
@@ -335,15 +324,161 @@ export async function updateKnowledgePointAction(
// P0-4 资源归属校验
const belongs = await verifyKnowledgePointBelongsToTextbook(kpId, textbookId)
if (!belongs) {
return { success: false, message: "Knowledge point does not belong to this textbook" };
return { success: false, message: t("kpNotBelong") };
}
await updateKnowledgePoint(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point updated successfully" };
return { success: true, message: t("kpUpdateSuccess") };
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return { success: false, message: "Failed to update knowledge point" };
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 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") };
}
await deletePrerequisite(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: t("prerequisiteDeleted") };
} catch (e) {
return handleActionError(e)
}
}