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

2267 lines
68 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 知识图谱重构实现计划
> **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 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: 提交**
```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<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: 提交**
```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<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: 提交**
```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<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: 提交**
```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<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: 提交**
```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<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: 提交**
```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.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` 内容替换为:
```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<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: 提交**
```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<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: 提交**
```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 (
<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: 提交**
```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 (
<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: 提交**
```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 (
<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: 提交**
```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<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: 提交**
```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<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: 提交**
```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
<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}`
```tsx
<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: 提交**
```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 使用 — 一致