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 等
142 lines
3.8 KiB
TypeScript
142 lines
3.8 KiB
TypeScript
/**
|
||
* 知识图谱布局纯函数。
|
||
*
|
||
* 从 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 }
|
||
}
|