Files
NextEdu/src/modules/textbooks/graph-layout.ts
SpecialX 22d3f07fcf feat(textbooks): 教材模块审计重构 — 跨模块解耦 + 权限 + i18n + 错误边界 + 纯函数抽取
P0 修复:
- 解耦跨模块 UI 依赖:knowledge-point-dialogs 不再直接 import questions,
  改为 renderQuestionCreator render prop 由页面注入
- 接入 usePermission Hook 替换 canEdit 硬编码
- 全模块 i18n 改造:新增 en/zh-CN 翻译文件,替换所有硬编码文案
- Server Action 资源归属校验:新增 verifyChapterBelongsToTextbook/
  verifyKnowledgePointBelongsToTextbook,在 reorder/update/delete/create 中校验

P1 改进:
- 补齐 Error Boundary:4 个 error.tsx + TextbookSectionErrorBoundary 区块包裹
- 抽取纯函数到 utils.ts/graph-layout.ts/constants.ts 并补单测(26 用例全通过)
- 消除重复组件:删除 knowledge-point-panel/create-knowledge-point-dialog
- 修复类型断言:chapter.children! → 守卫式访问
- 图谱 a11y:添加 role/aria-label/aria-pressed
- 统一删除确认:confirm() → AlertDialog
- 数据范围过滤:getTextbooksWithScope 支持学生端按年级过滤

P2 预留:
- TextbookAnalytics 埋点接口 + Provider + Hook

同步 005 架构数据 JSON:补充 getTextbooksWithScope/verify*/ChapterTreeNode 等
2026-06-22 16:25:59 +08:00

142 lines
3.8 KiB
TypeScript
Raw 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.
/**
* 知识图谱布局纯函数。
*
* 从 knowledge-graph.tsx 抽离,便于单元测试。
*/
import type { KnowledgePoint } from "./types"
export interface GraphNode extends KnowledgePoint {
x: number
y: number
}
export interface GraphEdge {
id: string
x1: number
y1: number
x2: number
y2: number
}
export interface GraphLayout {
nodes: GraphNode[]
edges: GraphEdge[]
width: number
height: number
}
/** 节点尺寸常量 */
export const NODE_WIDTH = 160
export const NODE_HEIGHT = 52
export const GAP_X = 40
export const GAP_Y = 90
/**
* 计算知识图谱的分层布局。
*
* 算法:
* 1. 根据 parentId 构建父子关系
* 2. BFS 计算每个节点的层级level
* 3. 同层节点按出现顺序水平排列
* 4. 生成节点坐标和边坐标
*
* @param knowledgePoints 知识点列表
* @returns 图布局(节点带坐标、边、总宽高)
*/
export function computeGraphLayout(
knowledgePoints: KnowledgePoint[]
): GraphLayout {
if (knowledgePoints.length === 0) {
return { nodes: [], edges: [], width: 0, height: 0 }
}
const byId = new Map<string, KnowledgePoint>()
for (const kp of knowledgePoints) byId.set(kp.id, kp)
const children = new Map<string, string[]>()
const roots: string[] = []
for (const kp of knowledgePoints) {
if (kp.parentId && byId.has(kp.parentId)) {
const arr = children.get(kp.parentId) ?? []
arr.push(kp.id)
children.set(kp.parentId, arr)
} else {
roots.push(kp.id)
}
}
const levelMap = new Map<string, number>()
const levels: string[][] = []
const queue = [...roots].map((id) => ({ id, level: 0 }))
// 容错:如果没有任何根节点(全部循环引用),把所有节点放第 0 层
if (queue.length === 0) {
for (const kp of knowledgePoints) queue.push({ id: kp.id, level: 0 })
}
while (queue.length > 0) {
const item = queue.shift()
if (!item) continue
if (levelMap.has(item.id)) continue
levelMap.set(item.id, item.level)
if (!levels[item.level]) levels[item.level] = []
levels[item.level].push(item.id)
const kids = children.get(item.id) ?? []
for (const kid of kids) {
if (!levelMap.has(kid)) queue.push({ id: kid, level: item.level + 1 })
}
}
// 容错:处理孤立节点(未在 BFS 中访问到)
for (const kp of knowledgePoints) {
if (!levelMap.has(kp.id)) {
const level = levels.length
levelMap.set(kp.id, level)
if (!levels[level]) levels[level] = []
levels[level].push(kp.id)
}
}
const maxCount = Math.max(...levels.map((l) => l.length), 1)
const width = maxCount * (NODE_WIDTH + GAP_X) + GAP_X
const height = levels.length * (NODE_HEIGHT + GAP_Y) + GAP_Y
const positions = new Map<string, { x: number; y: number }>()
levels.forEach((ids, level) => {
ids.forEach((id, index) => {
const x = GAP_X + index * (NODE_WIDTH + GAP_X)
const y = GAP_Y + level * (NODE_HEIGHT + GAP_Y)
positions.set(id, { x, y })
})
})
const nodes = knowledgePoints.map((kp) => {
const pos = positions.get(kp.id) ?? { x: GAP_X, y: GAP_Y }
return { ...kp, x: pos.x, y: pos.y }
})
const edges = knowledgePoints
.filter((kp) => kp.parentId && positions.has(kp.parentId))
.map((kp) => {
const parentId = kp.parentId as string
const parentPos = positions.get(parentId)
const childPos = positions.get(kp.id)
// 类型守卫:两个位置都必须存在(已在 filter 中保证,但 TS 需要 narrowing
if (!parentPos || !childPos) {
return null
}
return {
id: `${parentId}-${kp.id}`,
x1: parentPos.x + NODE_WIDTH / 2,
y1: parentPos.y + NODE_HEIGHT,
x2: childPos.x + NODE_WIDTH / 2,
y2: childPos.y,
}
})
.filter((e): e is GraphEdge => e !== null)
return { nodes, edges, width, height }
}