将教材模块图谱从基本无用状态升级为完整知识图谱可视化系统。 数据层:新增 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 一致性。
68 KiB
知识图谱重构实现计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 全面重构教材模块知识图谱功能,支持跨章节全书视图、前置依赖关系、掌握度可视化、关联题目预览、缩放平移交互、师生双视角。
Architecture: 新增 knowledge_point_prerequisites 关联表表达前置依赖;data-access-graph.ts 聚合全书知识点+依赖+题目数;React Flow + dagre 渲染分层有向图;师生双视角通过 viewMode prop 切换;节点详情侧边栏面板。
Tech Stack: Next.js 16 App Router, @xyflow/react (已存在), @dagrejs/dagre (新增), Drizzle ORM, next-intl, Vitest
Spec: docs/superpowers/specs/2026-06-22-knowledge-graph-design.md
文件结构
新增文件
| 文件 | 职责 | 预计行数 |
|---|---|---|
src/modules/textbooks/data-access-graph.ts |
图谱专用数据访问(全书聚合+掌握度) | ~200 |
src/modules/textbooks/components/graph-kp-node.tsx |
React Flow 自定义节点 | ~120 |
src/modules/textbooks/components/graph-prerequisite-edge.tsx |
React Flow 自定义边(虚线箭头) | ~40 |
src/modules/textbooks/components/graph-toolbar.tsx |
视图切换/筛选/搜索工具栏 | ~100 |
src/modules/textbooks/components/graph-node-detail-panel.tsx |
节点详情侧边栏 | ~200 |
src/modules/textbooks/hooks/use-graph-data.ts |
图谱数据加载与缓存 Hook | ~80 |
修改文件
| 文件 | 修改内容 |
|---|---|
src/shared/db/schema.ts |
新增 knowledgePointPrerequisites 表 |
src/modules/textbooks/types.ts |
新增图谱相关类型 |
src/modules/textbooks/schema.ts |
新增 prerequisite Zod 校验 |
src/modules/textbooks/data-access.ts |
新增 prerequisite CRUD |
src/modules/textbooks/utils.ts |
新增循环依赖检测 |
src/modules/textbooks/actions.ts |
新增 3 个 Server Action |
src/modules/textbooks/graph-layout.ts |
重写为 dagre 布局 |
src/modules/textbooks/graph-layout.test.ts |
更新测试 |
src/modules/textbooks/components/knowledge-graph.tsx |
重写为 React Flow |
src/modules/textbooks/components/textbook-reader.tsx |
接入新图谱组件 |
src/shared/i18n/messages/zh-CN/textbooks.json |
新增 graph.* 键 |
src/shared/i18n/messages/en/textbooks.json |
新增 graph.* 键 |
Task 1: 安装依赖与数据库 Schema
Files:
-
Modify:
package.json -
Modify:
src/shared/db/schema.ts -
Step 1: 安装 @dagrejs/dagre
Run: npm install @dagrejs/dagre
Expected: package.json 中新增 @dagrejs/dagre 依赖
- Step 2: 在 schema.ts 中新增 knowledgePointPrerequisites 表
在 src/shared/db/schema.ts 的 knowledgePoints 表定义之后(约第 150 行后)插入:
// --- 知识点前置依赖(知识图谱) ---
export const knowledgePointPrerequisites = mysqlTable("knowledge_point_prerequisites", {
id: id("id").primaryKey(),
knowledgePointId: varchar("knowledge_point_id", { length: 128 }).notNull()
.references(() => knowledgePoints.id, { onDelete: "cascade" }),
prerequisiteKpId: varchar("prerequisite_kp_id", { length: 128 }).notNull()
.references(() => knowledgePoints.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
kpPairPk: primaryKey({ columns: [table.knowledgePointId, table.prerequisiteKpId] }),
kpIdx: index("kp_prereq_kp_idx").on(table.knowledgePointId),
prereqIdx: index("kp_prereq_prereq_idx").on(table.prerequisiteKpId),
}))
- Step 3: 生成迁移文件
Run: npm run db:generate
Expected: 在 drizzle/ 目录下生成新迁移文件,包含 knowledge_point_prerequisites 表创建语句
- Step 4: 应用迁移
Run: npm run db:migrate
Expected: 迁移成功应用
- Step 5: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 6: 提交
git add package.json package-lock.json src/shared/db/schema.ts drizzle/
git commit -m "feat(textbooks): add knowledge_point_prerequisites table and dagre dependency"
Task 2: 类型定义
Files:
-
Modify:
src/modules/textbooks/types.ts -
Step 1: 新增图谱相关类型
在 src/modules/textbooks/types.ts 末尾追加:
// ===== 知识图谱相关类型 =====
/** 图谱视图模式 */
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 map,key = kpId(仅 mastery 模式下有值) */
masteryMap: Record<string, MasteryInfo>
viewMode: GraphViewMode
}
/** 掌握度色彩等级 */
export type MasteryLevel = "low" | "medium" | "high" | "unassessed"
- Step 2: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 3: 提交
git add src/modules/textbooks/types.ts
git commit -m "feat(textbooks): add knowledge graph type definitions"
Task 3: Zod 校验 Schema
Files:
-
Modify:
src/modules/textbooks/schema.ts -
Step 1: 新增 prerequisite 校验
在 src/modules/textbooks/schema.ts 末尾追加:
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>
- Step 2: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 3: 提交
git add src/modules/textbooks/schema.ts
git commit -m "feat(textbooks): add prerequisite zod schemas"
Task 4: 循环依赖检测工具
Files:
-
Modify:
src/modules/textbooks/utils.ts -
Test:
src/modules/textbooks/utils.test.ts(如不存在则新建) -
Step 1: 写失败测试
在 src/modules/textbooks/utils.test.ts 中追加(若文件不存在则新建并导入 describe/it/expect):
import { describe, it, expect } from "vitest"
import { hasCycleAfterAddingEdge } from "./utils"
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)
})
})
- Step 2: 运行测试确认失败
Run: npx vitest run --config vitest.unit.config.ts src/modules/textbooks/utils.test.ts
Expected: FAIL - hasCycleAfterAddingEdge 未定义
- Step 3: 实现 hasCycleAfterAddingEdge
在 src/modules/textbooks/utils.ts 末尾追加:
/**
* 检测在添加新边 (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
}
- Step 4: 运行测试确认通过
Run: npx vitest run --config vitest.unit.config.ts src/modules/textbooks/utils.test.ts
Expected: PASS - 全部 5 个测试通过
- Step 5: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 6: 提交
git add src/modules/textbooks/utils.ts src/modules/textbooks/utils.test.ts
git commit -m "feat(textbooks): add cycle detection for prerequisite edges"
Task 5: 数据访问层 - 图谱专用查询
Files:
-
Create:
src/modules/textbooks/data-access-graph.ts -
Step 1: 创建 data-access-graph.ts
创建 src/modules/textbooks/data-access-graph.ts:
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,
order: r.order,
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
})
- Step 2: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 3: 提交
git add src/modules/textbooks/data-access-graph.ts
git commit -m "feat(textbooks): add graph data-access layer with relations and mastery"
Task 6: 数据访问层 - Prerequisite CRUD
Files:
-
Modify:
src/modules/textbooks/data-access.ts -
Step 1: 在 data-access.ts 末尾追加 prerequisite CRUD
在 src/modules/textbooks/data-access.ts 末尾追加:
// ===== Prerequisite CRUD =====
import { knowledgePointPrerequisites } from "@/shared/db/schema"
import type { CreatePrerequisiteInput, DeletePrerequisiteInput } from "./schema"
export async function createPrerequisite(data: CreatePrerequisiteInput): Promise<void> {
await db.insert(knowledgePointPrerequisites).values({
id: createId(),
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])
}
注意:import { knowledgePointPrerequisites } 需添加到文件顶部的 schema import 中。将顶部 import { chapters, knowledgePoints, textbooks } from "@/shared/db/schema" 改为 import { chapters, knowledgePoints, knowledgePointPrerequisites, textbooks } from "@/shared/db/schema"。
- Step 2: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 3: 提交
git add src/modules/textbooks/data-access.ts
git commit -m "feat(textbooks): add prerequisite CRUD functions"
Task 7: Server Actions
Files:
-
Modify:
src/modules/textbooks/actions.ts -
Step 1: 在 actions.ts 中新增图谱相关 Action
在 src/modules/textbooks/actions.ts 末尾追加:
// ===== 知识图谱 Actions =====
import type { GraphViewMode, KnowledgeGraphData } from "./types"
import {
getKnowledgePointsWithRelations,
getStudentKpMastery,
getClassKpMastery,
} from "./data-access-graph"
import {
createPrerequisite,
deletePrerequisite,
getPrerequisiteEdgesForTextbook,
} from "./data-access"
import {
CreatePrerequisiteSchema,
DeletePrerequisiteSchema,
} from "./schema"
import { hasCycleAfterAddingEdge } from "./utils"
import { getCurrentStudentUser, getCurrentTeacherUser } from "@/modules/users/data-access"
import { getActiveStudentIdsByClassId } from "@/modules/classes/data-access"
/**
* 获取知识图谱数据。
*
* - 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, import("./types").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") {
const teacher = await getCurrentTeacherUser()
if (teacher) {
// 获取教师所带班级的所有学生
// 简化:取教师所带的所有班级的学生
const classIds: string[] = []
const studentIds: string[] = []
for (const classId of classIds) {
const ids = await getActiveStudentIdsByClassId(classId)
studentIds.push(...ids)
}
if (studentIds.length > 0) {
const mastery = await getClassKpMastery(studentIds, textbookId)
for (const [kpId, info] of mastery) {
masteryMap[kpId] = info
}
}
}
}
return {
success: true,
message: t("ok"),
data: { knowledgePoints: knowledgePointsData, masteryMap, viewMode },
}
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
const t = await getTranslations("textbooks.action")
return { success: false, message: t("graphLoadFailed") }
}
}
/**
* 声明前置依赖(含循环检测)。
*/
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 parsed = CreatePrerequisiteSchema.safeParse({
knowledgePointId,
prerequisiteKpId,
})
if (!parsed.success) {
return { success: false, message: t("invalidInput") }
}
// 循环检测:需要先获取教材下所有现有边
// 通过 KP 的 chapterId 反查 textbookId
const kpBelongs = await verifyKnowledgePointBelongsToTextbook(
parsed.data.knowledgePointId,
getStringValue(formData, "textbookId"),
)
if (!kpBelongs) {
return { success: false, message: t("kpNotBelong") }
}
const existingEdges = await getPrerequisiteEdgesForTextbook(
getStringValue(formData, "textbookId"),
)
if (hasCycleAfterAddingEdge(
existingEdges,
parsed.data.knowledgePointId,
parsed.data.prerequisiteKpId,
)) {
return { success: false, message: t("cyclicDependency") }
}
await createPrerequisite(parsed.data)
revalidatePath(`/teacher/textbooks/${getStringValue(formData, "textbookId")}`)
return { success: true, message: t("prerequisiteCreated") }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
const t = await getTranslations("textbooks.action")
return { success: false, message: t("prerequisiteCreateFailed") }
}
}
/**
* 删除前置依赖。
*/
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 parsed = DeletePrerequisiteSchema.safeParse({
knowledgePointId,
prerequisiteKpId,
})
if (!parsed.success) {
return { success: false, message: t("invalidInput") }
}
await deletePrerequisite(parsed.data)
revalidatePath(`/teacher/textbooks/${getStringValue(formData, "textbookId")}`)
return { success: true, message: t("prerequisiteDeleted") }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
const t = await getTranslations("textbooks.action")
return { success: false, message: t("prerequisiteDeleteFailed") }
}
}
注意:顶部 import 需补充 import type { GraphViewMode, KnowledgeGraphData, MasteryInfo } from "./types"。同时需要确认 verifyKnowledgePointBelongsToTextbook 已存在(已存在于 data-access.ts)。
- Step 2: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 3: 提交
git add src/modules/textbooks/actions.ts
git commit -m "feat(textbooks): add graph data, create/delete prerequisite server actions"
Task 8: 重写 graph-layout.ts(dagre 集成)
Files:
-
Modify:
src/modules/textbooks/graph-layout.ts -
Modify:
src/modules/textbooks/graph-layout.test.ts -
Step 1: 更新 graph-layout.test.ts
将 src/modules/textbooks/graph-layout.test.ts 内容替换为:
import { describe, it, expect } from "vitest"
import type { KpWithRelations } from "./types"
import { computeGraphLayout } from "./graph-layout"
describe("textbooks/graph-layout", () => {
const makeKp = (
id: string,
parentId: string | null = null,
prerequisiteIds: string[] = [],
): KpWithRelations => ({
id,
name: `KP-${id}`,
description: null,
parentId,
chapterId: "c1",
level: 1,
order: 0,
chapterTitle: "Chapter 1",
questionCount: 0,
prerequisiteIds,
})
describe("computeGraphLayout", () => {
it("should return empty layout for empty input", () => {
const layout = computeGraphLayout([])
expect(layout.nodes).toEqual([])
expect(layout.edges).toEqual([])
})
it("should place single node", () => {
const layout = computeGraphLayout([makeKp("1")])
expect(layout.nodes).toHaveLength(1)
expect(layout.nodes[0].id).toBe("1")
})
it("should generate parent-child edge", () => {
const layout = computeGraphLayout([makeKp("1"), makeKp("2", "1")])
expect(layout.nodes).toHaveLength(2)
const parentEdge = layout.edges.find((e) => e.id === "parent-1-2")
expect(parentEdge).toBeDefined()
})
it("should generate prerequisite edge", () => {
const layout = computeGraphLayout([
makeKp("1"),
makeKp("2", null, ["1"]),
])
const prereqEdge = layout.edges.find((e) => e.id === "prereq-1-2")
expect(prereqEdge).toBeDefined()
})
it("should assign positions to all nodes", () => {
const layout = computeGraphLayout([
makeKp("1"),
makeKp("2", "1"),
makeKp("3", "1"),
])
for (const node of layout.nodes) {
expect(node.position.x).toBeGreaterThanOrEqual(0)
expect(node.position.y).toBeGreaterThanOrEqual(0)
}
})
})
})
- Step 2: 运行测试确认失败
Run: npx vitest run --config vitest.unit.config.ts src/modules/textbooks/graph-layout.test.ts
Expected: FAIL - 旧 computeGraphLayout 签名不匹配
- Step 3: 重写 graph-layout.ts
将 src/modules/textbooks/graph-layout.ts 内容替换为:
/**
* 知识图谱布局纯函数(dagre 集成)。
*
* 从 knowledge-graph.tsx 抽离,便于单元测试。
*/
import dagre from "@dagrejs/dagre"
import type { Edge, Node } from "@xyflow/react"
import type { KpWithRelations } from "./types"
export interface GraphLayoutNodeData {
kp: KpWithRelations
label: string
}
export interface GraphLayout {
nodes: Node<GraphLayoutNodeData>[]
edges: Edge[]
width: number
height: number
}
/** 节点尺寸常量 */
export const NODE_WIDTH = 180
export const NODE_HEIGHT = 80
export const RANK_SEP = 90
export const NODE_SEP = 40
/**
* 使用 dagre 计算分层有向图布局。
*
* @param knowledgePoints 知识点列表(含 parentId 和 prerequisiteIds)
* @returns React Flow 格式的 nodes/edges + 画布尺寸
*/
export function computeGraphLayout(
knowledgePoints: KpWithRelations[],
): GraphLayout {
if (knowledgePoints.length === 0) {
return { nodes: [], edges: [], width: 0, height: 0 }
}
const g = new dagre.graphlib.Graph()
g.setGraph({ rankdir: "TB", nodesep: NODE_SEP, ranksep: RANK_SEP })
g.setDefaultEdgeLabel(() => ({}))
// 添加节点
for (const kp of knowledgePoints) {
g.setNode(kp.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
}
// 添加 parentId 边(树归属,实线)
for (const kp of knowledgePoints) {
if (kp.parentId && knowledgePoints.some((k) => k.id === kp.parentId)) {
g.setEdge(kp.parentId, kp.id)
}
}
// 添加 prerequisite 边(依赖,虚线箭头)
for (const kp of knowledgePoints) {
for (const prereqId of kp.prerequisiteIds) {
if (knowledgePoints.some((k) => k.id === prereqId)) {
g.setEdge(prereqId, kp.id)
}
}
}
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 - NODE_WIDTH / 2,
y: dagreNode.y - NODE_HEIGHT / 2,
},
data: { kp, label: kp.name },
}
})
const edges: Edge[] = []
// 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,
})
}
}
}
const graph = g.graph()
const width = graph.width ?? 0
const height = graph.height ?? 0
return { nodes, edges, width, height }
}
- Step 4: 运行测试确认通过
Run: npx vitest run --config vitest.unit.config.ts src/modules/textbooks/graph-layout.test.ts
Expected: PASS - 全部 5 个测试通过
- Step 5: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 6: 提交
git add src/modules/textbooks/graph-layout.ts src/modules/textbooks/graph-layout.test.ts
git commit -m "refactor(textbooks): rewrite graph-layout with dagre for hierarchical directed graph"
Task 9: i18n 翻译键
Files:
-
Modify:
src/shared/i18n/messages/zh-CN/textbooks.json -
Modify:
src/shared/i18n/messages/en/textbooks.json -
Step 1: 在 zh-CN/textbooks.json 中新增 graph 命名空间
在 JSON 根对象中新增 graph 键:
{
"graph": {
"viewMode": {
"structure": "结构图",
"studentMastery": "个人掌握度",
"classMastery": "班级掌握度"
},
"node": {
"questions": "题目",
"mastery": "掌握度",
"prerequisite": "前置",
"successor": "后置"
},
"detail": {
"title": "知识点详情",
"noDescription": "暂无描述",
"viewAllQuestions": "查看全部题目",
"editPrerequisite": "编辑前置依赖",
"addPrerequisite": "添加前置",
"removePrerequisite": "移除",
"noPrerequisites": "暂无前置知识点",
"noSuccessors": "暂无后置知识点",
"masteryNotAssessed": "未测评",
"correctRate": "正确率",
"totalQuestions": "总题数"
},
"toolbar": {
"search": "搜索知识点",
"filterByChapter": "按章节筛选",
"resetView": "重置视图"
},
"empty": {
"noPrerequisites": "暂无前置依赖关系",
"noData": "暂无图谱数据"
},
"error": {
"cyclicDependency": "不能添加循环依赖",
"loadFailed": "图谱加载失败"
}
}
}
同时在 action 命名空间中新增:
{
"ok": "操作成功",
"graphLoadFailed": "图谱加载失败",
"invalidInput": "输入无效",
"kpNotBelong": "知识点不属于该教材",
"cyclicDependency": "不能添加循环依赖",
"prerequisiteCreated": "前置依赖已添加",
"prerequisiteCreateFailed": "添加前置依赖失败",
"prerequisiteDeleted": "前置依赖已删除",
"prerequisiteDeleteFailed": "删除前置依赖失败"
}
- Step 2: 在 en/textbooks.json 中新增对应英文
{
"graph": {
"viewMode": {
"structure": "Structure",
"studentMastery": "My Mastery",
"classMastery": "Class Mastery"
},
"node": {
"questions": "Questions",
"mastery": "Mastery",
"prerequisite": "Prerequisite",
"successor": "Successor"
},
"detail": {
"title": "Knowledge Point Details",
"noDescription": "No description",
"viewAllQuestions": "View all questions",
"editPrerequisite": "Edit prerequisites",
"addPrerequisite": "Add prerequisite",
"removePrerequisite": "Remove",
"noPrerequisites": "No prerequisite knowledge points",
"noSuccessors": "No successor knowledge points",
"masteryNotAssessed": "Not assessed",
"correctRate": "Correct rate",
"totalQuestions": "Total questions"
},
"toolbar": {
"search": "Search knowledge points",
"filterByChapter": "Filter by chapter",
"resetView": "Reset view"
},
"empty": {
"noPrerequisites": "No prerequisite relationships",
"noData": "No graph data"
},
"error": {
"cyclicDependency": "Cannot add cyclic dependency",
"loadFailed": "Graph failed to load"
}
}
}
action 命名空间:
{
"ok": "OK",
"graphLoadFailed": "Graph failed to load",
"invalidInput": "Invalid input",
"kpNotBelong": "Knowledge point does not belong to this textbook",
"cyclicDependency": "Cannot add cyclic dependency",
"prerequisiteCreated": "Prerequisite added",
"prerequisiteCreateFailed": "Failed to add prerequisite",
"prerequisiteDeleted": "Prerequisite removed",
"prerequisiteDeleteFailed": "Failed to remove prerequisite"
}
- Step 3: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 4: 提交
git add src/shared/i18n/messages/zh-CN/textbooks.json src/shared/i18n/messages/en/textbooks.json
git commit -m "feat(textbooks): add i18n keys for knowledge graph feature"
Task 10: React Flow 自定义节点
Files:
-
Create:
src/modules/textbooks/components/graph-kp-node.tsx -
Step 1: 创建 graph-kp-node.tsx
创建 src/modules/textbooks/components/graph-kp-node.tsx:
"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)
- Step 2: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 3: 提交
git add src/modules/textbooks/components/graph-kp-node.tsx
git commit -m "feat(textbooks): add React Flow custom knowledge point node"
Task 11: React Flow 自定义边
Files:
-
Create:
src/modules/textbooks/components/graph-prerequisite-edge.tsx -
Step 1: 创建 graph-prerequisite-edge.tsx
创建 src/modules/textbooks/components/graph-prerequisite-edge.tsx:
"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",
}}
markerEnd="url(#prerequisite-arrow)"
/>
)
}
export const GraphPrerequisiteEdge = memo(GraphPrerequisiteEdgeComponent)
- Step 2: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 3: 提交
git add src/modules/textbooks/components/graph-prerequisite-edge.tsx
git commit -m "feat(textbooks): add React Flow prerequisite edge with dashed arrow"
Task 12: 图谱工具栏
Files:
-
Create:
src/modules/textbooks/components/graph-toolbar.tsx -
Step 1: 创建 graph-toolbar.tsx
创建 src/modules/textbooks/components/graph-toolbar.tsx:
"use client"
import { useTranslations } from "next-intl"
import { Search, RotateCcw, Filter } 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
}
export function GraphToolbar({
viewMode,
onViewModeChange,
availableViewModes,
searchText,
onSearchChange,
onResetView,
}: GraphToolbarProps) {
const t = useTranslations("textbooks")
return (
<div className="flex flex-wrap items-center gap-2 p-2 border-b bg-background/95 shrink-0">
<Select value={viewMode} onValueChange={(v) => onViewModeChange(v as GraphViewMode)}>
<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>
)
}
- Step 2: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 3: 提交
git add src/modules/textbooks/components/graph-toolbar.tsx
git commit -m "feat(textbooks): add graph toolbar with view mode, search, reset"
Task 13: 节点详情面板
Files:
-
Create:
src/modules/textbooks/components/graph-node-detail-panel.tsx -
Step 1: 创建 graph-node-detail-panel.tsx
创建 src/modules/textbooks/components/graph-node-detail-panel.tsx:
"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,
textbookId,
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>
)
}
- Step 2: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 3: 提交
git add src/modules/textbooks/components/graph-node-detail-panel.tsx
git commit -m "feat(textbooks): add graph node detail panel with prerequisite management"
Task 14: 图谱数据 Hook
Files:
-
Create:
src/modules/textbooks/hooks/use-graph-data.ts -
Step 1: 创建 use-graph-data.ts
创建 src/modules/textbooks/hooks/use-graph-data.ts:
"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 时重新加载。
* 使用缓存 + 派生值模式,避免 effect 中同步 setState。
*/
export function useGraphData(
textbookId: string,
viewMode: GraphViewMode,
): UseGraphDataResult {
const [data, setData] = useState<KnowledgeGraphData | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [reloadTrigger, setReloadTrigger] = useState(0)
const lastRequestKey = useRef<string>("")
const reload = useCallback(() => {
setReloadTrigger((n) => n + 1)
}, [])
useEffect(() => {
if (!textbookId) return
const requestKey = `${textbookId}:${viewMode}:${reloadTrigger}`
if (lastRequestKey.current === requestKey) return
lastRequestKey.current = requestKey
let cancelled = false
setIsLoading(true)
setError(null)
getKnowledgeGraphDataAction(textbookId, viewMode)
.then((result) => {
if (cancelled) return
if (result.success && result.data) {
setData(result.data)
} else {
setError(result.message ?? "Unknown error")
setData(null)
}
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : "Unknown error")
setData(null)
}
})
.finally(() => {
if (!cancelled) setIsLoading(false)
})
return () => {
cancelled = true
}
}, [textbookId, viewMode, reloadTrigger])
return { data, isLoading, error, reload }
}
- Step 2: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 3: 提交
git add src/modules/textbooks/hooks/use-graph-data.ts
git commit -m "feat(textbooks): add useGraphData hook for lazy loading graph data"
Task 15: 重写 knowledge-graph.tsx
Files:
-
Modify:
src/modules/textbooks/components/knowledge-graph.tsx -
Step 1: 重写 knowledge-graph.tsx
将 src/modules/textbooks/components/knowledge-graph.tsx 内容替换为:
"use client"
import { useState, useMemo, useCallback, useRef } 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 { 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 { Button } from "@/shared/components/ui/button"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import type { GraphViewMode, KpWithRelations, MasteryInfo } 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"
import { getPrerequisitesForKp, getSuccessorsForKp } from "../data-access-graph"
const nodeTypes = { kpNode: GraphKpNode }
const edgeTypes = { prerequisiteEdge: GraphPrerequisiteEdge }
interface KnowledgeGraphProps {
textbookId: string
/** 初始视图模式,默认 structure */
initialViewMode?: GraphViewMode
}
/** 章节颜色调色板 */
const CHAPTER_COLORS = [
"#3b82f6", "#ef4444", "#10b981", "#f59e0b",
"#8b5cf6", "#ec4899", "#06b6d4", "#84cc16",
]
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 [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
const [searchText, setSearchText] = useState("")
const [selectedKpId, setSelectedKpId] = useState<string | null>(null)
const [prerequisites, setPrerequisites] = useState<{ id: string; name: string; description: string | null }[]>([])
const [successors, setSuccessors] = useState<{ id: string; name: string; description: string | null }[]>([])
const { data, isLoading, error, reload } = 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 searchLower = searchText.toLowerCase()
const matchedIds = useMemo(() => {
if (!searchText || !data) return new Set<string>()
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])
// 组装 React Flow nodes
const rfNodes: Node[] = useMemo(() => {
return layout.nodes.map((node) => {
const kp = (node.data as { kp: KpWithRelations }).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)
return {
...node,
data: {
...node.data,
graphData: {
kp,
mastery,
viewMode,
isSelected,
isHighlighted,
chapterColor: chapterColorMap.get(kp.chapterId ?? "") ?? "#6b7280",
},
},
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((_: unknown, node: Node) => {
setSelectedKpId(node.id)
}, [])
const onNodeDoubleClick = useCallback((_: unknown, node: Node) => {
setSelectedKpId(node.id)
// 加载前置和后置
const kpId = node.id
Promise.all([getPrerequisitesForKp(kpId), getSuccessorsForKp(kpId)])
.then(([prereqs, succs]) => {
setPrerequisites(prereqs)
setSuccessors(succs)
})
.catch(() => {
setPrerequisites([])
setSuccessors([])
})
}, [])
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="h-full flex items-center justify-center text-sm text-muted-foreground">
{t("reader.loadingKnowledge")}
</div>
)
}
if (error) {
return (
<EmptyState
icon={Share2}
title={t("graph.error.loadFailed")}
description={error}
className="h-full border-none shadow-none bg-transparent"
/>
)
}
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}
onNodeDoubleClick={onNodeDoubleClick}
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) => {
const data = node.data as { graphData?: { chapterColor: string } }
return data.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)
setPrerequisites([])
setSuccessors([])
}}
onJumpToKp={onJumpToKp}
onAddPrerequisite={() => {
// TODO: 打开添加前置对话框(可后续迭代)
}}
onRemovePrerequisite={(prereqId) => {
// TODO: 调用 deletePrerequisiteAction
}}
/>
</div>
)}
</div>
)
}
export function KnowledgeGraph(props: KnowledgeGraphProps) {
return (
<ReactFlowProvider>
<KnowledgeGraphInner {...props} />
</ReactFlowProvider>
)
}
- Step 2: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 3: Lint 检查
Run: npm run lint
Expected: 无错误
- Step 4: 提交
git add src/modules/textbooks/components/knowledge-graph.tsx
git commit -m "feat(textbooks): rewrite knowledge graph with React Flow + dagre"
Task 16: 接入 textbook-reader.tsx
Files:
-
Modify:
src/modules/textbooks/components/textbook-reader.tsx -
Step 1: 更新 textbook-reader.tsx 中的图谱 Tab
在 src/modules/textbooks/components/textbook-reader.tsx 中找到图谱 TabsContent(约第 315 行),替换为:
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
{!textbookId ? (
<EmptyState
icon={Share2}
title={t("reader.selectChapterGraph")}
description={t("reader.selectChapterGraphDesc")}
className="h-full border-none shadow-none bg-transparent"
/>
) : (
<KnowledgeGraph textbookId={textbookId} />
)}
</TextbookSectionErrorBoundary>
</TabsContent>
注意:此处改为全书视图,不再依赖 selectedId。图谱 Tab 始终可用(不 disabled)。
同时找到 TabsTrigger 中 value="graph" 的那行,移除 disabled={!selectedId}:
<TabsTrigger value="graph" className="gap-2">
<Share2 className="h-4 w-4" />
{t("reader.tabs.graph")}
</TabsTrigger>
- Step 2: 类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 3: Lint 检查
Run: npm run lint
Expected: 无错误
- Step 4: 提交
git add src/modules/textbooks/components/textbook-reader.tsx
git commit -m "feat(textbooks): integrate new knowledge graph into textbook reader"
Task 17: 架构文档同步
Files:
-
Modify:
docs/architecture/004_architecture_impact_map.md -
Modify:
docs/architecture/005_architecture_data.json -
Step 1: 更新 004 架构影响地图
在 docs/architecture/004_architecture_impact_map.md 的 §2.5 教材模块章节中:
-
文件清单表格新增:
data-access-graph.ts | ~200 | 图谱专用数据访问(全书聚合+掌握度)components/graph-kp-node.tsx | ~120 | React Flow 自定义节点components/graph-prerequisite-edge.tsx | ~40 | React Flow 自定义边components/graph-toolbar.tsx | ~100 | 图谱工具栏components/graph-node-detail-panel.tsx | ~200 | 节点详情侧边栏hooks/use-graph-data.ts | ~80 | 图谱数据加载 Hook
-
导出函数列表新增:
- Actions:
getKnowledgeGraphDataAction/createPrerequisiteAction/deletePrerequisiteAction - Data-access:
getKnowledgePointsWithRelations/getStudentKpMastery/getClassKpMastery/getPrerequisitesForKp/getSuccessorsForKp/createPrerequisite/deletePrerequisite/getPrerequisiteEdgesForTextbook
- Actions:
-
knownIssues 中移除 "P2 图谱方向键导航未实现"
-
dbTables 新增
knowledge_point_prerequisites
- Step 2: 更新 005 架构数据 JSON
在 docs/architecture/005_architecture_data.json 的 modules.textbooks 节点中同步更新:
-
exports.actions数组新增 3 个 action -
exports.dataAccess数组新增 8 个函数 -
dbTables数组新增knowledge_point_prerequisites -
knownIssues移除图谱方向键相关项 -
files数组新增 6 个文件 -
Step 3: 提交
git add docs/architecture/004_architecture_impact_map.md docs/architecture/005_architecture_data.json
git commit -m "docs(architecture): sync textbooks module with knowledge graph redesign"
Task 18: 最终验证
- Step 1: 全量类型检查
Run: npx tsc --noEmit
Expected: 无错误
- Step 2: 全量 Lint
Run: npm run lint
Expected: 无错误
- Step 3: 单元测试
Run: npm run test:unit
Expected: 全部通过(含新增的 graph-layout 和 cycle detection 测试)
- Step 4: 构建验证
Run: npm run build
Expected: 构建成功
自审清单
Spec 覆盖率:
- ✅ §3.1 knowledge_point_prerequisites 表 → Task 1
- ✅ §4.1 新增文件清单 → Task 5, 10-14
- ✅ §4.3 data-access 函数 → Task 5, 6
- ✅ §4.4 Server Actions → Task 7
- ✅ §5.1 视图模式 → Task 15
- ✅ §5.2 节点设计 → Task 10
- ✅ §5.3 边设计 → Task 11
- ✅ §5.4 画布交互 → Task 12, 15
- ✅ §5.5 详情面板 → Task 13
- ✅ §6 数据流 → Task 14
- ✅ §7.1 权限 → Task 7, 15
- ✅ §7.2 i18n → Task 9
- ✅ §8 测试 → Task 4, 8
- ✅ §9 依赖 → Task 1
- ✅ §10 架构图同步 → Task 17
类型一致性:
KpWithRelations在 Task 2 定义,Task 5/8/10/13/15 使用 — 一致GraphViewMode在 Task 2 定义,Task 7/12/14/15 使用 — 一致MasteryInfo在 Task 2 定义,Task 5/7/10/13/14 使用 — 一致computeGraphLayout在 Task 8 重写,Task 15 使用 — 一致useGraphData在 Task 14 定义,Task 15 使用 — 一致