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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user