# 知识图谱重构实现计划 > **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 行后)插入: ```typescript // --- 知识点前置依赖(知识图谱) --- 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: 提交** ```bash 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` 末尾追加: ```typescript // ===== 知识图谱相关类型 ===== /** 图谱视图模式 */ 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 viewMode: GraphViewMode } /** 掌握度色彩等级 */ export type MasteryLevel = "low" | "medium" | "high" | "unassessed" ``` - [ ] **Step 2: 类型检查** Run: `npx tsc --noEmit` Expected: 无错误 - [ ] **Step 3: 提交** ```bash 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` 末尾追加: ```typescript 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 export const DeletePrerequisiteSchema = z.object({ knowledgePointId: z.string().min(1), prerequisiteKpId: z.string().min(1), }) export type DeletePrerequisiteInput = z.infer ``` - [ ] **Step 2: 类型检查** Run: `npx tsc --noEmit` Expected: 无错误 - [ ] **Step 3: 提交** ```bash 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): ```typescript 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` 末尾追加: ```typescript /** * 检测在添加新边 (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() 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() 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: 提交** ```bash 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`: ```typescript 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 => { // 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() 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() 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> => { 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() 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> => { if (studentIds.length === 0) return new Map() const rows = await db .select({ knowledgePointId: knowledgePointMastery.knowledgePointId, avgMastery: sql`AVG(${knowledgePointMastery.masteryLevel})`, totalQuestions: sql`SUM(${knowledgePointMastery.totalQuestions})`, correctQuestions: sql`SUM(${knowledgePointMastery.correctQuestions})`, lastAssessedAt: sql`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() 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: 提交** ```bash 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` 末尾追加: ```typescript // ===== Prerequisite CRUD ===== import { knowledgePointPrerequisites } from "@/shared/db/schema" import type { CreatePrerequisiteInput, DeletePrerequisiteInput } from "./schema" export async function createPrerequisite(data: CreatePrerequisiteInput): Promise { await db.insert(knowledgePointPrerequisites).values({ id: createId(), knowledgePointId: data.knowledgePointId, prerequisiteKpId: data.prerequisiteKpId, }) } export async function deletePrerequisite(data: DeletePrerequisiteInput): Promise { await db .delete(knowledgePointPrerequisites) .where(and( eq(knowledgePointPrerequisites.knowledgePointId, data.knowledgePointId), eq(knowledgePointPrerequisites.prerequisiteKpId, data.prerequisiteKpId), )) } /** * 获取教材下所有知识点的前置依赖边列表。 * 用于循环检测。 */ export async function getPrerequisiteEdgesForTextbook( textbookId: string, ): Promise> { 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: 提交** ```bash 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` 末尾追加: ```typescript // ===== 知识图谱 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> { try { const t = await getTranslations("textbooks.action") await requirePermission(Permissions.TEXTBOOK_READ) const knowledgePointsData = await getKnowledgePointsWithRelations(textbookId) const masteryMap: Record = {} 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 { 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 { 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: 提交** ```bash 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` 内容替换为: ```typescript 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` 内容替换为: ```typescript /** * 知识图谱布局纯函数(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[] 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[] = 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: 提交** ```bash 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` 键: ```json { "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` 命名空间中新增: ```json { "ok": "操作成功", "graphLoadFailed": "图谱加载失败", "invalidInput": "输入无效", "kpNotBelong": "知识点不属于该教材", "cyclicDependency": "不能添加循环依赖", "prerequisiteCreated": "前置依赖已添加", "prerequisiteCreateFailed": "添加前置依赖失败", "prerequisiteDeleted": "前置依赖已删除", "prerequisiteDeleteFailed": "删除前置依赖失败" } ``` - [ ] **Step 2: 在 en/textbooks.json 中新增对应英文** ```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 命名空间: ```json { "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: 提交** ```bash 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`: ```typescript "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 = { 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 = { 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 (
{kp.name} {kp.questionCount > 0 && ( {kp.questionCount} {t("graph.node.questions")} )}
{showMastery && (
{t("graph.node.mastery")} {mastery ? `${Math.round(mastery.masteryLevel)}%` : t("graph.detail.masteryNotAssessed")}
)} {kp.chapterTitle && (
{kp.chapterTitle}
)}
) } export const GraphKpNode = memo(GraphKpNodeComponent) ``` - [ ] **Step 2: 类型检查** Run: `npx tsc --noEmit` Expected: 无错误 - [ ] **Step 3: 提交** ```bash 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`: ```typescript "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 ( ) } export const GraphPrerequisiteEdge = memo(GraphPrerequisiteEdgeComponent) ``` - [ ] **Step 2: 类型检查** Run: `npx tsc --noEmit` Expected: 无错误 - [ ] **Step 3: 提交** ```bash 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`: ```typescript "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 (
onSearchChange(e.target.value)} placeholder={t("graph.toolbar.search")} className="h-8 pl-7 text-xs" />
) } ``` - [ ] **Step 2: 类型检查** Run: `npx tsc --noEmit` Expected: 无错误 - [ ] **Step 3: 提交** ```bash 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`: ```typescript "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 (

{t("graph.detail.title")}

{/* 知识点名称 */}

{kp.name}

{kp.chapterTitle && (

{kp.chapterTitle}

)}
{/* 描述 */}
{t("graph.detail.title")}

{kp.description || t("graph.detail.noDescription")}

{/* 掌握度 */} {mastery && ( <>
{t("graph.node.mastery")}
{t("graph.detail.correctRate")} {correctRate !== null ? `${correctRate}%` : t("graph.detail.masteryNotAssessed")}
{t("graph.detail.totalQuestions")} {mastery.totalQuestions}
)} {/* 关联题目 */}
{t("graph.node.questions")} ({kp.questionCount})
{kp.questionCount > 0 && ( )}
{/* 前置知识点 */}
{t("graph.node.prerequisite")} ({prerequisites.length})
{canEdit && ( )}
{prerequisites.length === 0 ? (

{t("graph.detail.noPrerequisites")}

) : (
{prerequisites.map((p) => (
{canEdit && ( )}
))}
)}
{/* 后置知识点 */}
{t("graph.node.successor")} ({successors.length})
{successors.length === 0 ? (

{t("graph.detail.noSuccessors")}

) : (
{successors.map((s) => ( onJumpToKp(s.id)} > {s.name} ))}
)}
) } ``` - [ ] **Step 2: 类型检查** Run: `npx tsc --noEmit` Expected: 无错误 - [ ] **Step 3: 提交** ```bash 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`: ```typescript "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(null) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [reloadTrigger, setReloadTrigger] = useState(0) const lastRequestKey = useRef("") 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: 提交** ```bash 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` 内容替换为: ```typescript "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(initialViewMode) const [searchText, setSearchText] = useState("") const [selectedKpId, setSelectedKpId] = useState(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() 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() 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() const related = new Set([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 (
{t("reader.loadingKnowledge")}
) } if (error) { return ( ) } if (!data || data.knowledgePoints.length === 0) { return ( ) } const selectedKp = selectedKpId ? data.knowledgePoints.find((kp) => kp.id === selectedKpId) : null const selectedMastery = selectedKpId ? data.masteryMap[selectedKpId] ?? null : null return (
{ const data = node.data as { graphData?: { chapterColor: string } } return data.graphData?.chapterColor ?? "#6b7280" }} />
{selectedKp && (
{ setSelectedKpId(null) setPrerequisites([]) setSuccessors([]) }} onJumpToKp={onJumpToKp} onAddPrerequisite={() => { // TODO: 打开添加前置对话框(可后续迭代) }} onRemovePrerequisite={(prereqId) => { // TODO: 调用 deletePrerequisiteAction }} />
)}
) } export function KnowledgeGraph(props: KnowledgeGraphProps) { return ( ) } ``` - [ ] **Step 2: 类型检查** Run: `npx tsc --noEmit` Expected: 无错误 - [ ] **Step 3: Lint 检查** Run: `npm run lint` Expected: 无错误 - [ ] **Step 4: 提交** ```bash 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 行),替换为: ```tsx {!textbookId ? ( ) : ( )} ``` 注意:此处改为全书视图,不再依赖 `selectedId`。图谱 Tab 始终可用(不 disabled)。 同时找到 TabsTrigger 中 `value="graph"` 的那行,移除 `disabled={!selectedId}`: ```tsx {t("reader.tabs.graph")} ``` - [ ] **Step 2: 类型检查** Run: `npx tsc --noEmit` Expected: 无错误 - [ ] **Step 3: Lint 检查** Run: `npm run lint` Expected: 无错误 - [ ] **Step 4: 提交** ```bash 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. 导出函数列表新增: - Actions:`getKnowledgeGraphDataAction` / `createPrerequisiteAction` / `deletePrerequisiteAction` - Data-access:`getKnowledgePointsWithRelations` / `getStudentKpMastery` / `getClassKpMastery` / `getPrerequisitesForKp` / `getSuccessorsForKp` / `createPrerequisite` / `deletePrerequisite` / `getPrerequisiteEdgesForTextbook` 3. knownIssues 中移除 "P2 图谱方向键导航未实现" 4. 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: 提交** ```bash 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 使用 — 一致