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

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