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

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