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:
203
src/modules/textbooks/data-access-graph.ts
Normal file
203
src/modules/textbooks/data-access-graph.ts
Normal 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
|
||||
})
|
||||
Reference in New Issue
Block a user