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 等
This commit is contained in:
SpecialX
2026-06-22 16:25:59 +08:00
parent 45ee1ae43c
commit 22d3f07fcf
35 changed files with 2043 additions and 792 deletions

View File

@@ -0,0 +1,141 @@
/**
* 知识图谱布局纯函数。
*
* 从 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 }
}