将教材模块图谱从基本无用状态升级为完整知识图谱可视化系统。 数据层:新增 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 一致性。
2267 lines
68 KiB
Markdown
2267 lines
68 KiB
Markdown
# 知识图谱重构实现计划
|
||
|
||
> **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<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.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<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 使用 — 一致
|