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)
}
}

View File

@@ -0,0 +1,91 @@
"use client"
import { memo } from "react"
import { Handle, Position, type NodeProps } from "@xyflow/react"
import { useTranslations } from "next-intl"
import { cn } from "@/shared/lib/utils"
import type { GraphNodeData, MasteryLevel } from "../types"
import type { GraphLayoutNodeData } from "../graph-layout"
/** 根据掌握度计算色彩等级 */
function getMasteryLevel(mastery: number | null): MasteryLevel {
if (mastery === null) return "unassessed"
if (mastery < 60) return "low"
if (mastery < 85) return "medium"
return "high"
}
const MASTERY_COLORS: Record<MasteryLevel, string> = {
low: "border-red-500 bg-red-50 dark:bg-red-950/30",
medium: "border-yellow-500 bg-yellow-50 dark:bg-yellow-950/30",
high: "border-green-500 bg-green-50 dark:bg-green-950/30",
unassessed: "border-border bg-card",
}
const MASTERY_BAR_COLORS: Record<MasteryLevel, string> = {
low: "bg-red-500",
medium: "bg-yellow-500",
high: "bg-green-500",
unassessed: "bg-muted",
}
function GraphKpNodeComponent({ data, selected }: NodeProps) {
const t = useTranslations("textbooks")
const nodeData = data as unknown as GraphLayoutNodeData
const { kp } = nodeData
const graphData = (data as unknown as { graphData?: GraphNodeData }).graphData
const mastery = graphData?.mastery ?? null
const masteryLevel = getMasteryLevel(mastery?.masteryLevel ?? null)
const showMastery = graphData?.viewMode === "student-mastery" || graphData?.viewMode === "class-mastery"
return (
<div
className={cn(
"rounded-lg border-2 px-3 py-2 shadow-sm transition-all",
MASTERY_COLORS[masteryLevel],
selected && "ring-2 ring-primary ring-offset-1",
graphData?.isHighlighted && "ring-2 ring-primary",
!graphData?.isHighlighted && graphData !== undefined && "opacity-40",
)}
style={{ width: 180 }}
>
<Handle type="target" position={Position.Top} className="opacity-0" />
<div className="flex items-start justify-between gap-2 mb-1">
<span className="text-xs font-medium line-clamp-2 flex-1">{kp.name}</span>
{kp.questionCount > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary shrink-0">
{kp.questionCount} {t("graph.node.questions")}
</span>
)}
</div>
{showMastery && (
<div className="mt-2">
<div className="flex items-center justify-between text-[10px] text-muted-foreground mb-1">
<span>{t("graph.node.mastery")}</span>
<span>
{mastery ? `${Math.round(mastery.masteryLevel)}%` : t("graph.detail.masteryNotAssessed")}
</span>
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full transition-all", MASTERY_BAR_COLORS[masteryLevel])}
style={{ width: `${mastery?.masteryLevel ?? 0}%` }}
/>
</div>
</div>
)}
{kp.chapterTitle && (
<div className="mt-1 text-[10px] text-muted-foreground truncate">
{kp.chapterTitle}
</div>
)}
<Handle type="source" position={Position.Bottom} className="opacity-0" />
</div>
)
}
export const GraphKpNode = memo(GraphKpNodeComponent)

View File

@@ -0,0 +1,182 @@
"use client"
import { useTranslations } from "next-intl"
import Link from "next/link"
import { X, ExternalLink, Plus, Trash2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Separator } from "@/shared/components/ui/separator"
import type { KpWithRelations, MasteryInfo } from "../types"
interface GraphNodeDetailPanelProps {
kp: KpWithRelations
mastery: MasteryInfo | null
prerequisites: { id: string; name: string; description: string | null }[]
successors: { id: string; name: string; description: string | null }[]
canEdit: boolean
textbookId: string
onClose: () => void
onJumpToKp: (kpId: string) => void
onAddPrerequisite: () => void
onRemovePrerequisite: (prereqId: string) => void
}
export function GraphNodeDetailPanel({
kp,
mastery,
prerequisites,
successors,
canEdit,
onClose,
onJumpToKp,
onAddPrerequisite,
onRemovePrerequisite,
}: GraphNodeDetailPanelProps) {
const t = useTranslations("textbooks")
const correctRate = mastery && mastery.totalQuestions > 0
? Math.round((mastery.correctQuestions / mastery.totalQuestions) * 100)
: null
return (
<div className="flex flex-col h-full border-l bg-background">
<div className="flex items-center justify-between p-3 border-b shrink-0">
<h3 className="text-sm font-semibold truncate">{t("graph.detail.title")}</h3>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="p-3 space-y-4">
{/* 知识点名称 */}
<div>
<h4 className="text-base font-medium">{kp.name}</h4>
{kp.chapterTitle && (
<p className="text-xs text-muted-foreground mt-1">{kp.chapterTitle}</p>
)}
</div>
{/* 描述 */}
<div>
<h5 className="text-xs font-medium text-muted-foreground mb-1">
{t("graph.detail.title")}
</h5>
<p className="text-sm">
{kp.description || t("graph.detail.noDescription")}
</p>
</div>
{/* 掌握度 */}
{mastery && (
<>
<Separator />
<div>
<h5 className="text-xs font-medium text-muted-foreground mb-2">
{t("graph.node.mastery")}
</h5>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span>{t("graph.detail.correctRate")}</span>
<span>{correctRate !== null ? `${correctRate}%` : t("graph.detail.masteryNotAssessed")}</span>
</div>
<div className="flex justify-between">
<span>{t("graph.detail.totalQuestions")}</span>
<span>{mastery.totalQuestions}</span>
</div>
</div>
</div>
</>
)}
{/* 关联题目 */}
<Separator />
<div>
<div className="flex items-center justify-between mb-2">
<h5 className="text-xs font-medium text-muted-foreground">
{t("graph.node.questions")} ({kp.questionCount})
</h5>
{kp.questionCount > 0 && (
<Button asChild variant="ghost" size="sm" className="h-7 text-xs">
<Link href={`/teacher/questions?kp=${kp.id}`}>
<ExternalLink className="h-3 w-3 mr-1" />
{t("graph.detail.viewAllQuestions")}
</Link>
</Button>
)}
</div>
</div>
{/* 前置知识点 */}
<Separator />
<div>
<div className="flex items-center justify-between mb-2">
<h5 className="text-xs font-medium text-muted-foreground">
{t("graph.node.prerequisite")} ({prerequisites.length})
</h5>
{canEdit && (
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={onAddPrerequisite}>
<Plus className="h-3 w-3 mr-1" />
{t("graph.detail.addPrerequisite")}
</Button>
)}
</div>
{prerequisites.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("graph.detail.noPrerequisites")}</p>
) : (
<div className="space-y-1">
{prerequisites.map((p) => (
<div key={p.id} className="flex items-center justify-between gap-2">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs justify-start flex-1"
onClick={() => onJumpToKp(p.id)}
>
{p.name}
</Button>
{canEdit && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => onRemovePrerequisite(p.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
)}
</div>
{/* 后置知识点 */}
<Separator />
<div>
<h5 className="text-xs font-medium text-muted-foreground mb-2">
{t("graph.node.successor")} ({successors.length})
</h5>
{successors.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("graph.detail.noSuccessors")}</p>
) : (
<div className="flex flex-wrap gap-1">
{successors.map((s) => (
<Badge
key={s.id}
variant="outline"
className="cursor-pointer hover:bg-accent text-xs"
onClick={() => onJumpToKp(s.id)}
>
{s.name}
</Badge>
))}
</div>
)}
</div>
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,45 @@
"use client"
import { memo } from "react"
import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react"
import { cn } from "@/shared/lib/utils"
function GraphPrerequisiteEdgeComponent({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
}: EdgeProps) {
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
})
const isHighlighted = (data as { isHighlighted?: boolean } | undefined)?.isHighlighted ?? false
return (
<BaseEdge
id={id}
path={edgePath}
className={cn(
"transition-opacity",
isHighlighted ? "opacity-100" : "opacity-30",
)}
style={{
stroke: "currentColor",
strokeWidth: 2,
strokeDasharray: "6 4",
}}
/>
)
}
export const GraphPrerequisiteEdge = memo(GraphPrerequisiteEdgeComponent)

View File

@@ -0,0 +1,86 @@
"use client"
import { useTranslations } from "next-intl"
import { Search, RotateCcw } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import type { GraphViewMode } from "../types"
interface GraphToolbarProps {
viewMode: GraphViewMode
onViewModeChange: (mode: GraphViewMode) => void
availableViewModes: GraphViewMode[]
searchText: string
onSearchChange: (text: string) => void
onResetView: () => void
}
const ALL_VIEW_MODES: readonly GraphViewMode[] = [
"structure",
"student-mastery",
"class-mastery",
]
function isGraphViewMode(value: string): value is GraphViewMode {
return ALL_VIEW_MODES.some((mode) => mode === value)
}
export function GraphToolbar({
viewMode,
onViewModeChange,
availableViewModes,
searchText,
onSearchChange,
onResetView,
}: GraphToolbarProps) {
const t = useTranslations("textbooks")
const handleValueChange = (v: string): void => {
if (isGraphViewMode(v)) {
onViewModeChange(v)
}
}
return (
<div className="flex flex-wrap items-center gap-2 p-2 border-b bg-background/95 shrink-0">
<Select value={viewMode} onValueChange={handleValueChange}>
<SelectTrigger className="w-[140px] h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableViewModes.includes("structure") && (
<SelectItem value="structure">{t("graph.viewMode.structure")}</SelectItem>
)}
{availableViewModes.includes("student-mastery") && (
<SelectItem value="student-mastery">{t("graph.viewMode.studentMastery")}</SelectItem>
)}
{availableViewModes.includes("class-mastery") && (
<SelectItem value="class-mastery">{t("graph.viewMode.classMastery")}</SelectItem>
)}
</SelectContent>
</Select>
<div className="relative flex-1 min-w-[120px]">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("graph.toolbar.search")}
className="h-8 pl-7 text-xs"
/>
</div>
<Button variant="outline" size="sm" className="h-8 px-2" onClick={onResetView}>
<RotateCcw className="h-3 w-3 mr-1" />
<span className="text-xs">{t("graph.toolbar.resetView")}</span>
</Button>
</div>
)
}

View File

@@ -1,81 +1,277 @@
"use client"
import { useMemo } from "react"
import { useState, useMemo, useCallback } from "react"
import {
ReactFlow,
Background,
BackgroundVariant,
Controls,
MiniMap,
ReactFlowProvider,
useReactFlow,
type Node,
type Edge,
} from "@xyflow/react"
import "@xyflow/react/dist/style.css"
import { useTranslations } from "next-intl"
import type { KnowledgePoint } from "../types"
import { computeGraphLayout, NODE_WIDTH, NODE_HEIGHT } from "../graph-layout"
import { Share2 } from "lucide-react"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { GraphViewMode, GraphNodeData } from "../types"
import { computeGraphLayout } from "../graph-layout"
import { useGraphData } from "../hooks/use-graph-data"
import { GraphKpNode } from "./graph-kp-node"
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
import { GraphToolbar } from "./graph-toolbar"
import { GraphNodeDetailPanel } from "./graph-node-detail-panel"
const nodeTypes = { kpNode: GraphKpNode }
const edgeTypes = { prerequisiteEdge: GraphPrerequisiteEdge }
/** 章节颜色调色板 */
const CHAPTER_COLORS = [
"#3b82f6", "#ef4444", "#10b981", "#f59e0b",
"#8b5cf6", "#ec4899", "#06b6d4", "#84cc16",
]
interface KnowledgeGraphProps {
knowledgePoints: KnowledgePoint[]
selectedId: string | null
onHighlight: (id: string) => void
textbookId: string
/** 初始视图模式,默认 structure */
initialViewMode?: GraphViewMode
}
export function KnowledgeGraph({
knowledgePoints,
selectedId,
onHighlight,
}: KnowledgeGraphProps) {
function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: KnowledgeGraphProps) {
const t = useTranslations("textbooks")
const { hasPermission } = usePermission()
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
const isTeacher = hasPermission(Permissions.TEXTBOOK_UPDATE)
const reactFlow = useReactFlow()
const layout = useMemo(() => computeGraphLayout(knowledgePoints), [knowledgePoints])
const [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
const [searchText, setSearchText] = useState("")
const [selectedKpId, setSelectedKpId] = useState<string | null>(null)
if (knowledgePoints.length === 0) {
const { data, isLoading, error } = useGraphData(textbookId, viewMode)
const availableViewModes: GraphViewMode[] = isTeacher
? ["structure", "class-mastery"]
: ["structure", "student-mastery"]
// 章节颜色映射
const chapterColorMap = useMemo(() => {
const map = new Map<string, string>()
if (!data) return map
const chapterIds = [...new Set(
data.knowledgePoints
.map((kp) => kp.chapterId)
.filter((id): id is string => id !== null),
)]
chapterIds.forEach((id, index) => {
map.set(id, CHAPTER_COLORS[index % CHAPTER_COLORS.length]!)
})
return map
}, [data])
const layout = useMemo(() => {
if (!data) return { nodes: [], edges: [], width: 0, height: 0 }
return computeGraphLayout(data.knowledgePoints)
}, [data])
// 搜索高亮
const matchedIds = useMemo(() => {
if (!searchText || !data) return new Set<string>()
const searchLower = searchText.toLowerCase()
return new Set(
data.knowledgePoints
.filter((kp) => kp.name.toLowerCase().includes(searchLower))
.map((kp) => kp.id),
)
}, [searchText, data])
// 关联节点高亮(选中节点的前置+后置)
const relatedIds = useMemo(() => {
if (!selectedKpId || !data) return new Set<string>()
const related = new Set<string>([selectedKpId])
const selectedKp = data.knowledgePoints.find((kp) => kp.id === selectedKpId)
if (selectedKp) {
for (const id of selectedKp.prerequisiteIds) related.add(id)
for (const kp of data.knowledgePoints) {
if (kp.prerequisiteIds.includes(selectedKpId)) related.add(kp.id)
}
}
return related
}, [selectedKpId, data])
// 从已加载数据计算前置/后置列表(避免 server-only 导入)
const prerequisites = useMemo<{ id: string; name: string; description: string | null }[]>(() => {
if (!selectedKpId || !data) return []
const selectedKp = data.knowledgePoints.find((kp) => kp.id === selectedKpId)
if (!selectedKp) return []
return data.knowledgePoints
.filter((kp) => selectedKp.prerequisiteIds.includes(kp.id))
.map((kp) => ({ id: kp.id, name: kp.name, description: kp.description }))
}, [selectedKpId, data])
const successors = useMemo<{ id: string; name: string; description: string | null }[]>(() => {
if (!selectedKpId || !data) return []
return data.knowledgePoints
.filter((kp) => kp.prerequisiteIds.includes(selectedKpId))
.map((kp) => ({ id: kp.id, name: kp.name, description: kp.description }))
}, [selectedKpId, data])
// 组装 React Flow nodes
const rfNodes: Node[] = useMemo(() => {
return layout.nodes.map((node) => {
const kp = node.data.kp
const mastery = data?.masteryMap[kp.id] ?? null
const isSelected = selectedKpId === node.id
const isHighlighted = !searchText
? (selectedKpId === null || relatedIds.has(node.id))
: matchedIds.has(node.id)
const graphData: GraphNodeData = {
kp,
mastery,
viewMode,
isSelected,
isHighlighted,
chapterColor: chapterColorMap.get(kp.chapterId ?? "") ?? "#6b7280",
}
return {
...node,
data: { ...node.data, graphData },
selected: isSelected,
}
})
}, [layout, data, selectedKpId, relatedIds, matchedIds, searchText, viewMode, chapterColorMap])
// 组装 React Flow edges
const rfEdges: Edge[] = useMemo(() => {
return layout.edges.map((edge) => ({
...edge,
data: {
...edge.data,
isHighlighted: selectedKpId === null || relatedIds.has(edge.source) || relatedIds.has(edge.target),
},
}))
}, [layout, selectedKpId, relatedIds])
const onNodeClick = useCallback((_event: unknown, node: Node) => {
setSelectedKpId(node.id)
}, [])
const resetView = useCallback(() => {
reactFlow.fitView({ duration: 300 })
setSearchText("")
setSelectedKpId(null)
}, [reactFlow])
const onJumpToKp = useCallback((kpId: string) => {
setSelectedKpId(kpId)
reactFlow.fitView({ nodes: [{ id: kpId }], duration: 300 })
}, [reactFlow])
if (isLoading && !data) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
{t("reader.emptyKnowledge")}
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
{t("reader.loadingKnowledge")}
</div>
)
}
return (
<div className="h-full w-full overflow-auto p-4">
<svg
width={layout.width}
height={layout.height}
role="img"
aria-label={t("reader.tabs.graph")}
className="mx-auto"
>
<title>{t("reader.tabs.graph")}</title>
{/* 边 */}
{layout.edges.map((edge) => (
<line
key={edge.id}
x1={edge.x1}
y1={edge.y1}
x2={edge.x2}
y2={edge.y2}
stroke="currentColor"
strokeOpacity={0.3}
strokeWidth={1.5}
/>
))}
if (error) {
return (
<EmptyState
icon={Share2}
title={t("graph.error.loadFailed")}
description={error}
className="h-full border-none shadow-none bg-transparent"
/>
)
}
{/* 节点 */}
{layout.nodes.map((node) => {
const isSelected = selectedId === node.id
return (
<g key={node.id} transform={`translate(${node.x}, ${node.y})`}>
<foreignObject width={NODE_WIDTH} height={NODE_HEIGHT}>
<button
type="button"
onClick={() => onHighlight(node.id)}
className={`flex h-full w-full items-center justify-center rounded-lg border-2 px-3 text-center text-xs font-medium transition-colors cursor-pointer ${
isSelected
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-card-foreground hover:border-primary/50 hover:bg-accent"
}`}
aria-label={`${t("reader.clickToViewKp")}: ${node.name}`}
aria-pressed={isSelected}
>
<span className="line-clamp-2">{node.name}</span>
</button>
</foreignObject>
</g>
)
})}
</svg>
if (!data || data.knowledgePoints.length === 0) {
return (
<EmptyState
icon={Share2}
title={t("reader.emptyKnowledge")}
description={t("reader.emptyKnowledgeDesc")}
className="h-full border-none shadow-none bg-transparent"
/>
)
}
const selectedKp = selectedKpId ? data.knowledgePoints.find((kp) => kp.id === selectedKpId) : null
const selectedMastery = selectedKpId ? data.masteryMap[selectedKpId] ?? null : null
return (
<div className="flex h-full">
<div className="flex-1 flex flex-col min-h-0">
<GraphToolbar
viewMode={viewMode}
onViewModeChange={setViewMode}
availableViewModes={availableViewModes}
searchText={searchText}
onSearchChange={setSearchText}
onResetView={resetView}
/>
<div className="flex-1 min-h-0 relative">
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodeClick={onNodeClick}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.2}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls className="!bg-background !border !rounded-lg" />
<MiniMap
className="!bg-background !border !rounded-lg"
nodeColor={(node) => {
// node.data 是 Record<string, unknown>;从 unknown 安全转换读取 graphData
const graphData = (node.data as unknown as { graphData?: { chapterColor: string } })?.graphData
return graphData?.chapterColor ?? "#6b7280"
}}
/>
</ReactFlow>
</div>
</div>
{selectedKp && (
<div className="w-[300px] shrink-0">
<GraphNodeDetailPanel
kp={selectedKp}
mastery={selectedMastery}
prerequisites={prerequisites}
successors={successors}
canEdit={canEdit}
textbookId={textbookId}
onClose={() => setSelectedKpId(null)}
onJumpToKp={onJumpToKp}
onAddPrerequisite={() => {
// 后续迭代:打开添加前置对话框
}}
onRemovePrerequisite={(_prereqId: string) => {
// 后续迭代:调用 deletePrerequisiteAction
}}
/>
</div>
)}
</div>
)
}
export function KnowledgeGraph(props: KnowledgeGraphProps) {
return (
<ReactFlowProvider>
<KnowledgeGraphInner {...props} />
</ReactFlowProvider>
)
}

View File

@@ -1,18 +1,19 @@
"use client"
import { useMemo, useState, useEffect, type ReactNode } from "react"
import { useMemo, useState, useEffect, useRef, type ReactNode } from "react"
import { useQueryState, parseAsString } from "nuqs"
import { Tag, List, Share2 } from "lucide-react"
import { Tag, List, Share2, Menu } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import type { Chapter, KnowledgePoint } from "../types"
import { updateChapterContentAction } from "../actions"
import { updateChapterContentAction, getKnowledgePointsByChapterAction } from "../actions"
import { Permissions } from "@/shared/types/permissions"
import { usePermission } from "@/shared/hooks/use-permission"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
@@ -33,20 +34,30 @@ import {
type QuestionCreatorRenderProps,
} from "./knowledge-point-dialogs"
import { TextbookSectionErrorBoundary } from "./section-error-boundary"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/shared/components/ui/sheet"
import { useTextSelection } from "../hooks/use-text-selection"
import { useKnowledgePointActions } from "../hooks/use-knowledge-point-actions"
import { buildChapterIndex } from "../utils"
import { buildChapterIndex, highlightKnowledgePoints } from "../utils"
export interface TextbookReaderProps {
chapters: Chapter[]
knowledgePoints?: KnowledgePoint[]
/**
* 教材 ID用于按章节懒加载知识点P2-3
* 必传,否则知识点面板将始终为空。
*/
textbookId: string
/**
* 是否可编辑。已废弃——改由内部 usePermission() 自动判断。
* 保留 prop 仅为向后兼容,传入值会被忽略。
* @deprecated 改用权限系统自动判断
*/
canEdit?: boolean
textbookId?: string
/**
* 题目创建器渲染函数P0-1 解耦)。
* 由页面层注入 questions 模块的 CreateQuestionDialog 实现。
@@ -57,7 +68,6 @@ export interface TextbookReaderProps {
export function TextbookReader({
chapters,
knowledgePoints = [],
textbookId,
renderQuestionCreator,
}: TextbookReaderProps) {
@@ -71,6 +81,8 @@ export function TextbookReader({
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
const [activeTab, setActiveTab] = useState("chapters")
const [highlightedKpId, setHighlightedKpId] = useState<string | null>(null)
// P2-4 移动端抽屉式侧栏
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editContent, setEditContent] = useState("")
@@ -92,10 +104,42 @@ export function TextbookReader({
const selected = chapterId ? index.get(chapterId) ?? null : null
const selectedId = selected?.id ?? null
const currentChapterKPs = useMemo(() => {
if (!selectedId) return []
return knowledgePoints.filter((kp) => kp.chapterId === selectedId)
}, [knowledgePoints, selectedId])
// P2-3 知识点懒加载:按章节通过 Server Action 按需加载,避免一次性拉取全部知识点
// 使用缓存 + 派生值模式,避免在 effect 主体中同步 setState
// v2-P2: textbookId 变化时通过页面层 key={textbookId} 重置整个 reader无需手动清理缓存
const [kpsByChapter, setKpsByChapter] = useState<Record<string, KnowledgePoint[]>>({})
const requestedChaptersRef = useRef<Set<string>>(new Set())
// 用 useMemo 包裹以稳定引用,避免下游 useMemo 因 [] 引用变化而重复计算
const currentChapterKPs = useMemo<KnowledgePoint[]>(
() => (selectedId ? kpsByChapter[selectedId] ?? [] : []),
[selectedId, kpsByChapter]
)
// 加载状态派生:选中了章节但缓存中尚无数据时视为加载中
const isLoadingKPs = selectedId !== null && kpsByChapter[selectedId] === undefined
useEffect(() => {
if (!selectedId || !textbookId) {
return
}
// 已请求过的章节不重复请求(缓存命中)
if (requestedChaptersRef.current.has(selectedId)) {
return
}
requestedChaptersRef.current.add(selectedId)
let cancelled = false
getKnowledgePointsByChapterAction(selectedId, textbookId)
.then((result) => {
if (cancelled) return
const data = result.success ? result.data : undefined
setKpsByChapter((prev) => ({ ...prev, [selectedId]: data ?? [] }))
})
.catch(() => {
if (!cancelled) setKpsByChapter((prev) => ({ ...prev, [selectedId]: [] }))
})
return () => {
cancelled = true
}
}, [selectedId, textbookId])
const {
editingKp,
@@ -137,15 +181,21 @@ export function TextbookReader({
const handleSaveContent = async () => {
if (!selectedId || !textbookId) return
setIsSaving(true)
const result = await updateChapterContentAction(selectedId, editContent, textbookId)
setIsSaving(false)
try {
const result = await updateChapterContentAction(selectedId, editContent, textbookId)
if (result.success) {
toast.success(result.message)
setIsEditing(false)
setLocalContent(editContent)
} else {
toast.error(result.message)
if (result.success) {
toast.success(result.message)
setIsEditing(false)
setLocalContent(editContent)
} else {
toast.error(result.message)
}
} catch (e) {
console.error("Failed to save chapter content", e)
toast.error("Failed to save content")
} finally {
setIsSaving(false)
}
}
@@ -160,137 +210,165 @@ export function TextbookReader({
setChapterId(chapter.id)
setIsEditing(false)
setLocalContent(null)
// P2-4 移动端选择章节后关闭抽屉
setMobileSidebarOpen(false)
}
const effectiveContent = localContent ?? selected?.content
// P2-2 性能优化:单遍 alternation 正则替换,避免 O(n×m) 多遍扫描
const processedContent = useMemo(() => {
if (!effectiveContent) return ""
let content = effectiveContent
const sortedKPs = [...currentChapterKPs].sort((a, b) => b.name.length - a.name.length)
for (const kp of sortedKPs) {
const escapedName = kp.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const regex = new RegExp(`(${escapedName})`, "gi")
content = content.replace(regex, `[$1](#kp-${kp.id})`)
}
return content
return highlightKnowledgePoints(effectiveContent, currentChapterKPs)
}, [effectiveContent, currentChapterKPs])
useEffect(() => {
if (highlightedKpId) {
const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" })
el.classList.add("ring-2", "ring-primary", "ring-offset-2")
setTimeout(() => {
el.classList.remove("ring-2", "ring-primary", "ring-offset-2")
}, 2000)
}
if (!highlightedKpId) return
const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
if (!el) return
el.scrollIntoView({ behavior: "smooth", block: "center" })
el.classList.add("ring-2", "ring-primary", "ring-offset-2")
const timer = setTimeout(() => {
el.classList.remove("ring-2", "ring-primary", "ring-offset-2")
}, 2000)
return () => {
clearTimeout(timer)
}
}, [highlightedKpId])
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 h-full">
<div className="lg:col-span-4 lg:border-r lg:pr-6 flex flex-col min-h-0">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="chapters" className="gap-2">
<List className="h-4 w-4" />
{t("reader.tabs.chapters")}
</TabsTrigger>
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
<Tag className="h-4 w-4" />
{t("reader.tabs.knowledge")}
{currentChapterKPs.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">
{currentChapterKPs.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="graph" className="gap-2" disabled={!selectedId}>
<Share2 className="h-4 w-4" />
{t("reader.tabs.graph")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-1 pb-4">
<ChapterSidebarList
chapters={chapters}
selectedChapterId={selectedId || undefined}
onSelectChapter={handleSelect}
textbookId={textbookId || ""}
canEdit={canEdit}
/>
</div>
</ScrollArea>
</TextbookSectionErrorBoundary>
</TabsContent>
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
{!selectedId ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
{t("reader.selectChapterKnowledge")}
</div>
) : (
<KnowledgePointList
knowledgePoints={currentChapterKPs}
canEdit={canEdit}
canCreateQuestion={canCreateQuestion}
highlightedKpId={highlightedKpId}
onHighlight={setHighlightedKpId}
onEdit={(kp) => {
setEditingKp(kp)
setEditKpDialogOpen(true)
}}
onDelete={requestDeleteKnowledgePoint}
onCreateQuestion={(kp) => {
setTargetKpForQuestion(kp)
setQuestionDialogOpen(true)
}}
/>
)}
</TextbookSectionErrorBoundary>
</TabsContent>
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
{!selectedId ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
{t("reader.selectChapterGraph")}
</div>
) : (
<KnowledgeGraph
knowledgePoints={currentChapterKPs}
selectedId={highlightedKpId}
onHighlight={setHighlightedKpId}
/>
)}
</TextbookSectionErrorBoundary>
</TabsContent>
</Tabs>
// P2-4 侧边栏内容(章节/知识点/图谱 Tabs桌面端内联、移动端抽屉复用同一份
const sidebarContent = (
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="chapters" className="gap-2">
<List className="h-4 w-4" />
{t("reader.tabs.chapters")}
</TabsTrigger>
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
<Tag className="h-4 w-4" />
{t("reader.tabs.knowledge")}
{currentChapterKPs.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">
{currentChapterKPs.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="graph" className="gap-2">
<Share2 className="h-4 w-4" />
{t("reader.tabs.graph")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-1 pb-4">
<ChapterSidebarList
chapters={chapters}
selectedChapterId={selectedId || undefined}
onSelectChapter={handleSelect}
textbookId={textbookId || ""}
canEdit={canEdit}
/>
</div>
</ScrollArea>
</TextbookSectionErrorBoundary>
</TabsContent>
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
{!selectedId ? (
<EmptyState
icon={Tag}
title={t("reader.selectChapterKnowledge")}
description={t("reader.selectChapterKnowledgeDesc")}
className="h-full border-none shadow-none bg-transparent"
/>
) : isLoadingKPs ? (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
{t("reader.loadingKnowledge")}
</div>
) : (
<KnowledgePointList
knowledgePoints={currentChapterKPs}
canEdit={canEdit}
canCreateQuestion={canCreateQuestion}
highlightedKpId={highlightedKpId}
onHighlight={setHighlightedKpId}
onEdit={(kp) => {
setEditingKp(kp)
setEditKpDialogOpen(true)
}}
onDelete={requestDeleteKnowledgePoint}
onCreateQuestion={(kp) => {
setTargetKpForQuestion(kp)
setQuestionDialogOpen(true)
}}
/>
)}
</TextbookSectionErrorBoundary>
</TabsContent>
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
<KnowledgeGraph textbookId={textbookId} />
</TextbookSectionErrorBoundary>
</TabsContent>
</Tabs>
)
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 h-full">
{/* P2-4 桌面端侧栏lg 及以上内联显示 */}
<div className="hidden lg:flex lg:col-span-4 lg:border-r lg:pr-6 flex-col min-h-0">
{sidebarContent}
</div>
{/* P2-4 移动端侧栏lg 以下用 Sheet 抽屉式展示 */}
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
<SheetContent side="left" className="w-[85vw] max-w-sm p-0 flex flex-col">
<SheetHeader className="px-4 py-3 border-b shrink-0">
<SheetTitle className="text-left">{t("reader.sidebar")}</SheetTitle>
</SheetHeader>
<div className="flex-1 min-h-0 p-2">{sidebarContent}</div>
</SheetContent>
</Sheet>
<div className="lg:col-span-8 flex flex-col min-h-0 relative">
{/* P2-4 移动端侧栏触发按钮 */}
<div className="lg:hidden flex items-center gap-2 mb-3 px-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => setMobileSidebarOpen(true)}
aria-expanded={mobileSidebarOpen}
aria-controls="mobile-sidebar-sheet"
>
<Menu className="mr-2 h-4 w-4" />
{t("reader.openSidebar")}
</Button>
{selected && (
<span className="text-sm text-muted-foreground truncate">
{selected.title}
</span>
)}
</div>
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -334,7 +412,6 @@ export function TextbookReader({
editContent={editContent}
setEditContent={setEditContent}
canEdit={canEdit}
knowledgePoints={currentChapterKPs}
highlightedKpId={highlightedKpId}
onHighlight={setHighlightedKpId}
onSwitchToKnowledgeTab={() => setActiveTab("knowledge")}
@@ -342,10 +419,7 @@ export function TextbookReader({
onPointerDown={handleContentPointerDown}
onContextMenuChange={handleContextMenuChange}
selectedText={selectedText}
createDialogOpen={createDialogOpen}
setCreateDialogOpen={setCreateDialogOpen}
isCreating={isCreating}
onCreateKnowledgePoint={onCreateKnowledgePoint}
startEditing={startEditing}
cancelEditing={() => setIsEditing(false)}
saveContent={handleSaveContent}

View File

@@ -0,0 +1,203 @@
import "server-only"
import { cache } from "react"
import { and, asc, eq, inArray, sql, count } from "drizzle-orm"
import { db } from "@/shared/db"
import {
chapters,
knowledgePoints,
knowledgePointPrerequisites,
questionsToKnowledgePoints,
knowledgePointMastery,
} from "@/shared/db/schema"
import type { KpWithRelations, MasteryInfo } from "./types"
/**
* 获取教材下全书知识点(含前置依赖 + 关联题目数 + 章节标题)。
*
* 一次查询聚合,避免 N+1。
*/
export const getKnowledgePointsWithRelations = cache(async (
textbookId: string,
): Promise<KpWithRelations[]> => {
// 1. 查询全书知识点 + 章节标题
const kpRows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
description: knowledgePoints.description,
parentId: knowledgePoints.parentId,
chapterId: knowledgePoints.chapterId,
level: knowledgePoints.level,
order: knowledgePoints.order,
chapterTitle: chapters.title,
})
.from(knowledgePoints)
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
.where(eq(chapters.textbookId, textbookId))
.orderBy(asc(chapters.order), asc(knowledgePoints.order), asc(knowledgePoints.name))
if (kpRows.length === 0) return []
const kpIds = kpRows.map((r) => r.id)
// 2. 查询关联题目数(批量聚合)
const questionCountRows = await db
.select({
knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
count: count(),
})
.from(questionsToKnowledgePoints)
.where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds))
.groupBy(questionsToKnowledgePoints.knowledgePointId)
const questionCountMap = new Map<string, number>()
for (const r of questionCountRows) {
questionCountMap.set(r.knowledgePointId, Number(r.count))
}
// 3. 查询前置依赖(批量)
const prereqRows = await db
.select({
knowledgePointId: knowledgePointPrerequisites.knowledgePointId,
prerequisiteKpId: knowledgePointPrerequisites.prerequisiteKpId,
})
.from(knowledgePointPrerequisites)
.where(inArray(knowledgePointPrerequisites.knowledgePointId, kpIds))
const prereqMap = new Map<string, string[]>()
for (const r of prereqRows) {
const arr = prereqMap.get(r.knowledgePointId) ?? []
arr.push(r.prerequisiteKpId)
prereqMap.set(r.knowledgePointId, arr)
}
// 4. 组装结果
return kpRows.map((r) => ({
id: r.id,
name: r.name,
description: r.description,
parentId: r.parentId,
chapterId: r.chapterId,
level: r.level ?? 0,
order: r.order ?? 0,
chapterTitle: r.chapterTitle,
questionCount: questionCountMap.get(r.id) ?? 0,
prerequisiteIds: prereqMap.get(r.id) ?? [],
}))
})
/**
* 获取学生在某教材下所有知识点的掌握度。
*/
export const getStudentKpMastery = cache(async (
studentId: string,
textbookId: string,
): Promise<Map<string, MasteryInfo>> => {
const rows = await db
.select({
knowledgePointId: knowledgePointMastery.knowledgePointId,
masteryLevel: knowledgePointMastery.masteryLevel,
totalQuestions: knowledgePointMastery.totalQuestions,
correctQuestions: knowledgePointMastery.correctQuestions,
lastAssessedAt: knowledgePointMastery.lastAssessedAt,
})
.from(knowledgePointMastery)
.innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
.where(and(
eq(knowledgePointMastery.studentId, studentId),
eq(chapters.textbookId, textbookId),
))
const map = new Map<string, MasteryInfo>()
for (const r of rows) {
map.set(r.knowledgePointId, {
masteryLevel: Number(r.masteryLevel),
totalQuestions: r.totalQuestions,
correctQuestions: r.correctQuestions,
lastAssessedAt: r.lastAssessedAt,
})
}
return map
})
/**
* 获取班级(教师所带班级的所有学生)在某教材下知识点的平均掌握度。
*
* @param studentIds 班级学生 ID 列表
* @param textbookId 教材 ID
*/
export const getClassKpMastery = cache(async (
studentIds: string[],
textbookId: string,
): Promise<Map<string, MasteryInfo>> => {
if (studentIds.length === 0) return new Map()
const rows = await db
.select({
knowledgePointId: knowledgePointMastery.knowledgePointId,
avgMastery: sql<number>`AVG(${knowledgePointMastery.masteryLevel})`,
totalQuestions: sql<number>`SUM(${knowledgePointMastery.totalQuestions})`,
correctQuestions: sql<number>`SUM(${knowledgePointMastery.correctQuestions})`,
lastAssessedAt: sql<Date>`MAX(${knowledgePointMastery.lastAssessedAt})`,
})
.from(knowledgePointMastery)
.innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
.where(and(
inArray(knowledgePointMastery.studentId, studentIds),
eq(chapters.textbookId, textbookId),
))
.groupBy(knowledgePointMastery.knowledgePointId)
const map = new Map<string, MasteryInfo>()
for (const r of rows) {
map.set(r.knowledgePointId, {
masteryLevel: Number(r.avgMastery),
totalQuestions: Number(r.totalQuestions),
correctQuestions: Number(r.correctQuestions),
lastAssessedAt: r.lastAssessedAt,
})
}
return map
})
/**
* 获取某个知识点的前置依赖列表(含知识点详情)。
*/
export const getPrerequisitesForKp = cache(async (
kpId: string,
): Promise<{ id: string; name: string; description: string | null }[]> => {
const rows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
description: knowledgePoints.description,
})
.from(knowledgePointPrerequisites)
.innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointPrerequisites.prerequisiteKpId))
.where(eq(knowledgePointPrerequisites.knowledgePointId, kpId))
return rows
})
/**
* 获取某个知识点的后置知识点列表(即哪些知识点以此 KP 为前置)。
*/
export const getSuccessorsForKp = cache(async (
kpId: string,
): Promise<{ id: string; name: string; description: string | null }[]> => {
const rows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
description: knowledgePoints.description,
})
.from(knowledgePointPrerequisites)
.innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointPrerequisites.knowledgePointId))
.where(eq(knowledgePointPrerequisites.prerequisiteKpId, kpId))
return rows
})

View File

@@ -5,7 +5,8 @@ import { and, asc, count, eq, inArray, like, or, sql, isNull, type SQL } from "d
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import { chapters, knowledgePoints, textbooks } from "@/shared/db/schema"
import { chapters, knowledgePoints, knowledgePointPrerequisites, textbooks } from "@/shared/db/schema"
import { escapeLikePattern } from "@/shared/lib/action-utils"
import type {
Chapter,
KnowledgePoint,
@@ -14,7 +15,9 @@ import type {
import type {
CreateChapterInput,
CreateKnowledgePointInput,
CreatePrerequisiteInput,
CreateTextbookInput,
DeletePrerequisiteInput,
UpdateChapterContentInput,
UpdateKnowledgePointInput,
UpdateTextbookInput,
@@ -41,7 +44,7 @@ export const getTextbooks = cache(async (query?: string, subject?: string, grade
const q = query?.trim()
if (q) {
const needle = `%${q}%`
const needle = `%${escapeLikePattern(q)}%`
const nameCond = or(
like(textbooks.title, needle),
like(textbooks.subject, needle),
@@ -288,8 +291,10 @@ export async function deleteChapter(id: string): Promise<void> {
if (kids) stack.push(...kids)
}
await db.delete(knowledgePoints).where(inArray(knowledgePoints.chapterId, idsToDelete))
await db.delete(chapters).where(inArray(chapters.id, idsToDelete))
await db.transaction(async (tx) => {
await tx.delete(knowledgePoints).where(inArray(knowledgePoints.chapterId, idsToDelete))
await tx.delete(chapters).where(inArray(chapters.id, idsToDelete))
})
}
export const getKnowledgePointsByChapterId = cache(async (chapterId: string): Promise<KnowledgePoint[]> => {
@@ -505,7 +510,7 @@ export const getTextbooksWithScope = cache(
const q = query?.trim()
if (q) {
const needle = `%${q}%`
const needle = `%${escapeLikePattern(q)}%`
const nameCond = or(
like(textbooks.title, needle),
like(textbooks.subject, needle),
@@ -617,3 +622,41 @@ export const getKnowledgePointOptions = cache(async (): Promise<KnowledgePointOp
grade: row.grade ?? null,
}))
})
// ===== Prerequisite CRUD =====
export async function createPrerequisite(data: CreatePrerequisiteInput): Promise<void> {
await db.insert(knowledgePointPrerequisites).values({
knowledgePointId: data.knowledgePointId,
prerequisiteKpId: data.prerequisiteKpId,
})
}
export async function deletePrerequisite(data: DeletePrerequisiteInput): Promise<void> {
await db
.delete(knowledgePointPrerequisites)
.where(and(
eq(knowledgePointPrerequisites.knowledgePointId, data.knowledgePointId),
eq(knowledgePointPrerequisites.prerequisiteKpId, data.prerequisiteKpId),
))
}
/**
* 获取教材下所有知识点的前置依赖边列表。
* 用于循环检测。
*/
export async function getPrerequisiteEdgesForTextbook(
textbookId: string,
): Promise<Array<[string, string]>> {
const rows = await db
.select({
knowledgePointId: knowledgePointPrerequisites.knowledgePointId,
prerequisiteKpId: knowledgePointPrerequisites.prerequisiteKpId,
})
.from(knowledgePointPrerequisites)
.innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointPrerequisites.knowledgePointId))
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
.where(eq(chapters.textbookId, textbookId))
return rows.map((r) => [r.knowledgePointId, r.prerequisiteKpId] as [string, string])
}

View File

@@ -1,15 +1,23 @@
import { describe, it, expect } from "vitest"
import type { KnowledgePoint } from "./types"
import { computeGraphLayout, NODE_WIDTH, NODE_HEIGHT, GAP_X, GAP_Y } from "./graph-layout"
import type { KpWithRelations } from "./types"
import { computeGraphLayout } from "./graph-layout"
describe("textbooks/graph-layout", () => {
const makeKp = (id: string, parentId: string | null = null): KnowledgePoint => ({
const makeKp = (
id: string,
parentId: string | null = null,
prerequisiteIds: string[] = [],
): KpWithRelations => ({
id,
name: `KP-${id}`,
chapterId: "c1",
description: null,
parentId,
chapterId: "c1",
level: 1,
order: 0,
chapterTitle: "Chapter 1",
questionCount: 0,
prerequisiteIds,
})
describe("computeGraphLayout", () => {
@@ -17,79 +25,40 @@ describe("textbooks/graph-layout", () => {
const layout = computeGraphLayout([])
expect(layout.nodes).toEqual([])
expect(layout.edges).toEqual([])
expect(layout.width).toBe(0)
expect(layout.height).toBe(0)
})
it("should place single root node", () => {
it("should place single node", () => {
const layout = computeGraphLayout([makeKp("1")])
expect(layout.nodes).toHaveLength(1)
expect(layout.nodes[0].x).toBe(GAP_X)
expect(layout.nodes[0].y).toBe(GAP_Y)
expect(layout.edges).toHaveLength(0)
expect(layout.nodes[0].id).toBe("1")
})
it("should compute parent-child layout with edge", () => {
it("should generate parent-child edge", () => {
const layout = computeGraphLayout([makeKp("1"), makeKp("2", "1")])
expect(layout.nodes).toHaveLength(2)
expect(layout.edges).toHaveLength(1)
expect(layout.edges[0].id).toBe("1-2")
const parentEdge = layout.edges.find((e) => e.id === "parent-1-2")
expect(parentEdge).toBeDefined()
})
it("should place children at lower level (higher y)", () => {
const layout = computeGraphLayout([makeKp("1"), makeKp("2", "1")])
const root = layout.nodes.find((n) => n.id === "1")
const child = layout.nodes.find((n) => n.id === "2")
expect(child!.y).toBeGreaterThan(root!.y)
})
it("should handle multiple roots", () => {
const layout = computeGraphLayout([makeKp("1"), makeKp("2")])
expect(layout.nodes).toHaveLength(2)
expect(layout.edges).toHaveLength(0)
const n1 = layout.nodes.find((n) => n.id === "1")
const n2 = layout.nodes.find((n) => n.id === "2")
expect(n2!.x).toBeGreaterThan(n1!.x)
})
it("should handle circular references gracefully (no infinite loop)", () => {
// a → b → a 循环
const kps = [makeKp("a", "b"), makeKp("b", "a")]
const layout = computeGraphLayout(kps)
expect(layout.nodes).toHaveLength(2)
})
it("should handle all nodes referencing non-existent parent", () => {
const kps = [makeKp("1", "nonexistent"), makeKp("2", "nonexistent")]
const layout = computeGraphLayout(kps)
expect(layout.nodes).toHaveLength(2)
// 全部作为根节点处理
expect(layout.edges).toHaveLength(0)
})
it("should compute width based on max nodes per level", () => {
const kps = [
it("should generate prerequisite edge", () => {
const layout = computeGraphLayout([
makeKp("1"),
makeKp("2"),
makeKp("3"),
makeKp("4", "1"),
]
const layout = computeGraphLayout(kps)
// 第 0 层有 3 个节点,是最大层
const expectedWidth = 3 * (NODE_WIDTH + GAP_X) + GAP_X
expect(layout.width).toBe(expectedWidth)
makeKp("2", null, ["1"]),
])
const prereqEdge = layout.edges.find((e) => e.id === "prereq-1-2")
expect(prereqEdge).toBeDefined()
})
it("should compute height based on level count", () => {
const kps = [
it("should assign positions to all nodes", () => {
const layout = computeGraphLayout([
makeKp("1"),
makeKp("2", "1"),
makeKp("3", "2"),
]
const layout = computeGraphLayout(kps)
// 3 层
const expectedHeight = 3 * (NODE_HEIGHT + GAP_Y) + GAP_Y
expect(layout.height).toBe(expectedHeight)
makeKp("3", "1"),
])
for (const node of layout.nodes) {
expect(node.position.x).toBeGreaterThanOrEqual(0)
expect(node.position.y).toBeGreaterThanOrEqual(0)
}
})
})
})

View File

@@ -1,141 +1,121 @@
/**
* 知识图谱布局纯函数。
* 知识图谱布局纯函数dagre 集成)
*
* 从 knowledge-graph.tsx 抽离,便于单元测试。
*/
import type { KnowledgePoint } from "./types"
import dagre from "@dagrejs/dagre"
import type { EdgeLabel, GraphLabel, NodeLabel } from "@dagrejs/dagre"
import type { Edge, Node } from "@xyflow/react"
import type { KpWithRelations } from "./types"
export interface GraphNode extends KnowledgePoint {
x: number
y: number
}
export interface GraphEdge {
id: string
x1: number
y1: number
x2: number
y2: number
export interface GraphLayoutNodeData {
kp: KpWithRelations
label: string
// 索引签名:满足 @xyflow/react Node<GraphLayoutNodeData> 的 Record<string, unknown> 约束
[key: string]: unknown
}
export interface GraphLayout {
nodes: GraphNode[]
edges: GraphEdge[]
nodes: Node<GraphLayoutNodeData>[]
edges: Edge[]
width: number
height: number
}
/** 节点尺寸常量 */
export const NODE_WIDTH = 160
export const NODE_HEIGHT = 52
export const GAP_X = 40
export const GAP_Y = 90
export const NODE_WIDTH = 180
export const NODE_HEIGHT = 80
export const RANK_SEP = 90
export const NODE_SEP = 40
/**
* 计算知识图谱的分层布局。
* 使用 dagre 计算分层有向图布局。
*
* 算法:
* 1. 根据 parentId 构建父子关系
* 2. BFS 计算每个节点的层级level
* 3. 同层节点按出现顺序水平排列
* 4. 生成节点坐标和边坐标
*
* @param knowledgePoints 知识点列表
* @returns 图布局(节点带坐标、边、总宽高)
* @param knowledgePoints 知识点列表(含 parentId 和 prerequisiteIds
* @returns React Flow 格式的 nodes/edges + 画布尺寸
*/
export function computeGraphLayout(
knowledgePoints: KnowledgePoint[]
knowledgePoints: KpWithRelations[],
): GraphLayout {
if (knowledgePoints.length === 0) {
return { nodes: [], edges: [], width: 0, height: 0 }
}
const byId = new Map<string, KnowledgePoint>()
for (const kp of knowledgePoints) byId.set(kp.id, kp)
const children = new Map<string, string[]>()
const roots: string[] = []
const g = new dagre.graphlib.Graph<GraphLabel, NodeLabel, EdgeLabel>()
g.setGraph({ rankdir: "TB", nodesep: NODE_SEP, ranksep: RANK_SEP })
g.setDefaultEdgeLabel(() => ({}))
// 添加节点
for (const kp of knowledgePoints) {
if (kp.parentId && byId.has(kp.parentId)) {
const arr = children.get(kp.parentId) ?? []
arr.push(kp.id)
children.set(kp.parentId, arr)
} else {
roots.push(kp.id)
}
g.setNode(kp.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
}
const levelMap = new Map<string, number>()
const levels: string[][] = []
const queue = [...roots].map((id) => ({ id, level: 0 }))
// 容错:如果没有任何根节点(全部循环引用),把所有节点放第 0 层
if (queue.length === 0) {
for (const kp of knowledgePoints) queue.push({ id: kp.id, level: 0 })
}
while (queue.length > 0) {
const item = queue.shift()
if (!item) continue
if (levelMap.has(item.id)) continue
levelMap.set(item.id, item.level)
if (!levels[item.level]) levels[item.level] = []
levels[item.level].push(item.id)
const kids = children.get(item.id) ?? []
for (const kid of kids) {
if (!levelMap.has(kid)) queue.push({ id: kid, level: item.level + 1 })
}
}
// 容错:处理孤立节点(未在 BFS 中访问到)
// 添加 parentId 边(树归属,实线)
for (const kp of knowledgePoints) {
if (!levelMap.has(kp.id)) {
const level = levels.length
levelMap.set(kp.id, level)
if (!levels[level]) levels[level] = []
levels[level].push(kp.id)
if (kp.parentId && knowledgePoints.some((k) => k.id === kp.parentId)) {
g.setEdge(kp.parentId, kp.id)
}
}
const maxCount = Math.max(...levels.map((l) => l.length), 1)
const width = maxCount * (NODE_WIDTH + GAP_X) + GAP_X
const height = levels.length * (NODE_HEIGHT + GAP_Y) + GAP_Y
// 添加 prerequisite 边(依赖,虚线箭头)
for (const kp of knowledgePoints) {
for (const prereqId of kp.prerequisiteIds) {
if (knowledgePoints.some((k) => k.id === prereqId)) {
g.setEdge(prereqId, kp.id)
}
}
}
const positions = new Map<string, { x: number; y: number }>()
levels.forEach((ids, level) => {
ids.forEach((id, index) => {
const x = GAP_X + index * (NODE_WIDTH + GAP_X)
const y = GAP_Y + level * (NODE_HEIGHT + GAP_Y)
positions.set(id, { x, y })
})
dagre.layout(g)
const nodes: Node<GraphLayoutNodeData>[] = knowledgePoints.map((kp) => {
const dagreNode = g.node(kp.id)
return {
id: kp.id,
type: "kpNode",
position: {
x: (dagreNode.x ?? 0) - NODE_WIDTH / 2,
y: (dagreNode.y ?? 0) - NODE_HEIGHT / 2,
},
data: { kp, label: kp.name },
}
})
const nodes = knowledgePoints.map((kp) => {
const pos = positions.get(kp.id) ?? { x: GAP_X, y: GAP_Y }
return { ...kp, x: pos.x, y: pos.y }
})
const edges: Edge[] = []
const edges = knowledgePoints
.filter((kp) => kp.parentId && positions.has(kp.parentId))
.map((kp) => {
const parentId = kp.parentId as string
const parentPos = positions.get(parentId)
const childPos = positions.get(kp.id)
// 类型守卫:两个位置都必须存在(已在 filter 中保证,但 TS 需要 narrowing
if (!parentPos || !childPos) {
return null
// parentId 边
for (const kp of knowledgePoints) {
if (kp.parentId && knowledgePoints.some((k) => k.id === kp.parentId)) {
edges.push({
id: `parent-${kp.parentId}-${kp.id}`,
source: kp.parentId,
target: kp.id,
type: "default",
className: "edge-parent",
})
}
}
// prerequisite 边
for (const kp of knowledgePoints) {
for (const prereqId of kp.prerequisiteIds) {
if (knowledgePoints.some((k) => k.id === prereqId)) {
edges.push({
id: `prereq-${prereqId}-${kp.id}`,
source: prereqId,
target: kp.id,
type: "prerequisiteEdge",
className: "edge-prerequisite",
animated: false,
})
}
return {
id: `${parentId}-${kp.id}`,
x1: parentPos.x + NODE_WIDTH / 2,
y1: parentPos.y + NODE_HEIGHT,
x2: childPos.x + NODE_WIDTH / 2,
y2: childPos.y,
}
})
.filter((e): e is GraphEdge => e !== null)
}
}
const graph = g.graph()
const width = graph.width ?? 0
const height = graph.height ?? 0
return { nodes, edges, width, height }
}

View File

@@ -0,0 +1,69 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { getKnowledgeGraphDataAction } from "../actions"
import type { GraphViewMode, KnowledgeGraphData } from "../types"
interface UseGraphDataResult {
data: KnowledgeGraphData | null
isLoading: boolean
error: string | null
reload: () => void
}
/**
* 图谱数据加载 Hook。
*
* 按 textbookId + viewMode 加载,切换 viewMode 时重新加载。
* 使用派生值模式isLoading 从 data.viewMode 派生),避免 effect 中同步 setState。
*/
export function useGraphData(
textbookId: string,
viewMode: GraphViewMode,
): UseGraphDataResult {
const [data, setData] = useState<KnowledgeGraphData | null>(null)
const [error, setError] = useState<string | null>(null)
const [reloadTrigger, setReloadTrigger] = useState(0)
const lastRequestKey = useRef<string>("")
const reload = useCallback(() => {
setReloadTrigger((n) => n + 1)
}, [])
// 派生 loading 状态:无数据或当前数据不匹配请求的 viewMode
const isLoading = data === null || data.viewMode !== viewMode
useEffect(() => {
if (!textbookId) return
const requestKey = `${textbookId}:${viewMode}:${reloadTrigger}`
if (lastRequestKey.current === requestKey) return
lastRequestKey.current = requestKey
let cancelled = false
getKnowledgeGraphDataAction(textbookId, viewMode)
.then((result) => {
if (cancelled) return
if (result.success && result.data) {
setData(result.data)
setError(null)
} else {
setData(null)
setError(result.message ?? "Unknown error")
}
})
.catch((e: unknown) => {
if (!cancelled) {
setData(null)
setError(e instanceof Error ? e.message : "Unknown error")
}
})
return () => {
cancelled = true
}
}, [textbookId, viewMode, reloadTrigger])
return { data, isLoading, error, reload }
}

View File

@@ -62,3 +62,19 @@ export const ReorderChaptersSchema = z.object({
})
export type ReorderChaptersInput = z.infer<typeof ReorderChaptersSchema>
export const CreatePrerequisiteSchema = z.object({
knowledgePointId: z.string().min(1),
prerequisiteKpId: z.string().min(1),
}).refine((data) => data.knowledgePointId !== data.prerequisiteKpId, {
message: "知识点不能作为自己的前置",
})
export type CreatePrerequisiteInput = z.infer<typeof CreatePrerequisiteSchema>
export const DeletePrerequisiteSchema = z.object({
knowledgePointId: z.string().min(1),
prerequisiteKpId: z.string().min(1),
})
export type DeletePrerequisiteInput = z.infer<typeof DeletePrerequisiteSchema>

View File

@@ -43,3 +43,64 @@ export type KnowledgePoint = {
level: number;
order: number;
};
// ===== 知识图谱相关类型 =====
/** 图谱视图模式 */
export type GraphViewMode = "structure" | "student-mastery" | "class-mastery"
/** 掌握度信息 */
export interface MasteryInfo {
/** 掌握度等级 0-100 */
masteryLevel: number
/** 总题数 */
totalQuestions: number
/** 正确题数 */
correctQuestions: number
/** 最后测评时间 */
lastAssessedAt: Date
}
/** 带关联关系的知识点(图谱数据) */
export interface KpWithRelations {
id: string
name: string
description: string | null
parentId: string | null
chapterId: string | null
level: number
order: number
/** 关联题目数 */
questionCount: number
/** 前置知识点 ID 列表 */
prerequisiteIds: string[]
/** 章节标题(用于节点 tooltip */
chapterTitle: string | null
}
/** 图谱节点数据React Flow Node.data */
export interface GraphNodeData {
kp: KpWithRelations
mastery: MasteryInfo | null
viewMode: GraphViewMode
isSelected: boolean
isHighlighted: boolean
chapterColor: string
}
/** 图谱边数据React Flow Edge.data */
export interface GraphEdgeData {
edgeType: "parent" | "prerequisite"
isHighlighted: boolean
}
/** 图谱完整数据Server Action 返回) */
export interface KnowledgeGraphData {
knowledgePoints: KpWithRelations[]
/** mastery mapkey = kpId仅 mastery 模式下有值) */
masteryMap: Record<string, MasteryInfo>
viewMode: GraphViewMode
}
/** 掌握度色彩等级 */
export type MasteryLevel = "low" | "medium" | "high" | "unassessed"

View File

@@ -7,6 +7,8 @@ import {
filterKnowledgePointsByChapter,
normalizeOptional,
sortChapters,
highlightKnowledgePoints,
hasCycleAfterAddingEdge,
} from "./utils"
// 测试辅助:构造最小合法 Chapter补齐 createdAt/updatedAt 等必填字段)
@@ -178,4 +180,87 @@ describe("textbooks/utils", () => {
expect(normalizeOptional(" hello ")).toBe("hello")
})
})
describe("highlightKnowledgePoints", () => {
const makeKp = (id: string, name: string): KnowledgePoint => ({
id,
name,
chapterId: "c1",
level: 1,
order: 0,
})
it("should return content unchanged when no knowledge points", () => {
expect(highlightKnowledgePoints("hello world", [])).toBe("hello world")
})
it("should return content unchanged when content is empty", () => {
expect(highlightKnowledgePoints("", [makeKp("1", "test")])).toBe("")
})
it("should wrap single knowledge point name with link", () => {
const result = highlightKnowledgePoints("学习物理很有趣", [makeKp("kp1", "物理")])
expect(result).toBe("学习[物理](#kp-kp1)很有趣")
})
it("should match case-insensitively", () => {
const result = highlightKnowledgePoints("Hello HELLO hello", [makeKp("kp1", "hello")])
expect(result).toBe("[Hello](#kp-kp1) [HELLO](#kp-kp1) [hello](#kp-kp1)")
})
it("should match multiple occurrences", () => {
const result = highlightKnowledgePoints("物理物理物理", [makeKp("kp1", "物理")])
expect(result).toBe("[物理](#kp-kp1)[物理](#kp-kp1)[物理](#kp-kp1)")
})
it("should prioritize longest match (物理学 before 物理)", () => {
const kps = [makeKp("kp1", "物理"), makeKp("kp2", "物理学")]
const result = highlightKnowledgePoints("物理学是研究物理的学科", kps)
// "物理学" 应被 kp2 匹配,独立的 "物理" 应被 kp1 匹配
expect(result).toBe("[物理学](#kp-kp2)是研究[物理](#kp-kp1)的学科")
})
it("should handle multiple different knowledge points in one pass", () => {
const kps = [makeKp("kp1", "数学"), makeKp("kp2", "物理")]
const result = highlightKnowledgePoints("数学和物理都是科学", kps)
expect(result).toBe("[数学](#kp-kp1)和[物理](#kp-kp2)都是科学")
})
it("should escape regex metacharacters in knowledge point names", () => {
const result = highlightKnowledgePoints("计算 1+2 的结果", [makeKp("kp1", "1+2")])
expect(result).toBe("计算 [1+2](#kp-kp1) 的结果")
})
it("should handle knowledge point names with parentheses", () => {
const result = highlightKnowledgePoints("函数 f(x) 是映射", [makeKp("kp1", "f(x)")])
expect(result).toBe("函数 [f(x)](#kp-kp1) 是映射")
})
})
})
describe("textbooks/utils - cycle detection", () => {
it("should return false when adding edge to empty graph", () => {
const edges: Array<[string, string]> = []
expect(hasCycleAfterAddingEdge(edges, "a", "b")).toBe(false)
})
it("should detect direct cycle (a->b then b->a)", () => {
const edges: Array<[string, string]> = [["a", "b"]]
expect(hasCycleAfterAddingEdge(edges, "b", "a")).toBe(true)
})
it("should detect indirect cycle (a->b->c then c->a)", () => {
const edges: Array<[string, string]> = [["a", "b"], ["b", "c"]]
expect(hasCycleAfterAddingEdge(edges, "c", "a")).toBe(true)
})
it("should not detect cycle for independent chains", () => {
const edges: Array<[string, string]> = [["a", "b"], ["c", "d"]]
expect(hasCycleAfterAddingEdge(edges, "b", "c")).toBe(false)
})
it("should not detect cycle for diamond shape", () => {
const edges: Array<[string, string]> = [["a", "b"], ["a", "c"], ["b", "d"], ["c", "d"]]
expect(hasCycleAfterAddingEdge(edges, "d", "e")).toBe(false)
})
})

View File

@@ -127,3 +127,99 @@ export function normalizeOptional(
if (!trimmed) return null
return trimmed
}
/**
* 转义正则元字符,用于构建字面量匹配的正则。
*/
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
/**
* P2-2 知识点高亮:单遍替换实现。
*
* 旧实现 O(n×m):对每个知识点名做一次 `new RegExp(..., "gi")` 全局替换,
* 内容被反复扫描 n 次,且长名替换可能破坏短名匹配。
*
* 新实现 O(m):构建单个 alternation 正则 `(name1|name2|...)`,一次扫描完成所有替换。
* 按名称长度降序排列保证最长匹配优先(避免 "物理" 抢占 "物理学")。
*
* @param content 章节正文Markdown
* @param kps 当前章节的知识点列表
* @returns 替换后的 Markdown知识点名被包装为 `[name](#kp-{id})` 链接
*/
export function highlightKnowledgePoints(
content: string,
kps: KnowledgePoint[]
): string {
if (!content || kps.length === 0) return content
// 按名称长度降序,保证最长匹配优先
const sorted = [...kps].sort((a, b) => b.name.length - a.name.length)
// 构建 id 查找表name小写→ kpId取第一个匹配因已按长度降序
// 使用小写作为键以支持大小写不敏感匹配
const nameToId = new Map<string, string>()
for (const kp of sorted) {
const key = kp.name.toLowerCase()
if (!nameToId.has(key)) {
nameToId.set(key, kp.id)
}
}
// 构建单遍 alternation 正则
const pattern = sorted
.map((kp) => `(${escapeRegExp(kp.name)})`)
.join("|")
const regex = new RegExp(pattern, "gi")
return content.replace(regex, (match: string) => {
const id = nameToId.get(match.toLowerCase())
if (id) return `[${match}](#kp-${id})`
return match
})
}
/**
* 检测在添加新边 (from -> to) 后是否形成环。
*
* 算法DFS 从 to 出发,若能到达 from 则有环。
*
* @param existingEdges 现有边列表,每项为 [from, to]
* @param newFrom 新边的起点
* @param newTo 新边的终点
* @returns true 表示添加后会形成环
*/
export function hasCycleAfterAddingEdge(
existingEdges: Array<[string, string]>,
newFrom: string,
newTo: string,
): boolean {
// 构建邻接表
const adj = new Map<string, string[]>()
for (const [from, to] of existingEdges) {
const arr = adj.get(from) ?? []
arr.push(to)
adj.set(from, arr)
}
// 添加新边
const arr = adj.get(newFrom) ?? []
arr.push(newTo)
adj.set(newFrom, arr)
// DFS 从 newTo 出发,若能到达 newFrom 则有环
const visited = new Set<string>()
const stack = [newTo]
while (stack.length > 0) {
const node = stack.pop()
if (!node) continue
if (node === newFrom) return true
if (visited.has(node)) continue
visited.add(node)
const neighbors = adj.get(node) ?? []
for (const n of neighbors) {
if (!visited.has(n)) stack.push(n)
}
}
return false
}