Files
NextEdu/docs/superpowers/plans/2026-06-22-knowledge-graph.md
SpecialX 58656da983 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 一致性。
2026-06-23 00:13:03 +08:00

68 KiB
Raw Blame History

知识图谱重构实现计划

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.tsknowledgePoints 表定义之后(约第 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 mapkey = 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.tsdagre 集成)

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 教材模块章节中:

  1. 文件清单表格新增:

    • 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
  2. 导出函数列表新增:

    • ActionsgetKnowledgeGraphDataAction / createPrerequisiteAction / deletePrerequisiteAction
    • Data-accessgetKnowledgePointsWithRelations / getStudentKpMastery / getClassKpMastery / getPrerequisitesForKp / getSuccessorsForKp / createPrerequisite / deletePrerequisite / getPrerequisiteEdgesForTextbook
  3. knownIssues 中移除 "P2 图谱方向键导航未实现"

  4. dbTables 新增 knowledge_point_prerequisites

  • Step 2: 更新 005 架构数据 JSON

docs/architecture/005_architecture_data.jsonmodules.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 使用 — 一致