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

@@ -1,120 +1,9 @@
"use client"
import { useMemo } from "react"
import { useTranslations } from "next-intl"
import type { KnowledgePoint } from "../types"
import { cn } from "@/shared/lib/utils"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
interface GraphNode extends KnowledgePoint {
x: number
y: number
}
interface GraphEdge {
id: string
x1: number
y1: number
x2: number
y2: number
}
interface GraphLayout {
nodes: GraphNode[]
edges: GraphEdge[]
width: number
height: number
}
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 }))
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 })
}
}
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 nodeWidth = 160
const nodeHeight = 52
const gapX = 40
const gapY = 90
const maxCount = Math.max(...levels.map((l) => l.length), 1)
const width = maxCount * (nodeWidth + gapX) + gapX
const height = levels.length * (nodeHeight + gapY) + gapY
const positions = new Map<string, { x: number; y: number }>()
levels.forEach((ids, level) => {
ids.forEach((id, index) => {
const x = gapX + index * (nodeWidth + gapX)
const y = gapY + level * (nodeHeight + gapY)
positions.set(id, { x, y })
})
})
const nodes = knowledgePoints.map((kp) => {
const pos = positions.get(kp.id) ?? { x: gapX, y: gapY }
return { ...kp, x: pos.x, y: pos.y }
})
const edges = knowledgePoints
.filter((kp) => kp.parentId && positions.has(kp.parentId))
.map((kp) => {
const parentPos = positions.get(kp.parentId as string)!
const childPos = positions.get(kp.id)!
return {
id: `${kp.parentId}-${kp.id}`,
x1: parentPos.x + nodeWidth / 2,
y1: parentPos.y + nodeHeight,
x2: childPos.x + nodeWidth / 2,
y2: childPos.y,
}
})
return { nodes, edges, width, height }
}
import { computeGraphLayout, NODE_WIDTH, NODE_HEIGHT } from "../graph-layout"
interface KnowledgeGraphProps {
knowledgePoints: KnowledgePoint[]
@@ -122,60 +11,71 @@ interface KnowledgeGraphProps {
onHighlight: (id: string) => void
}
export function KnowledgeGraph({ knowledgePoints, selectedId, onHighlight }: KnowledgeGraphProps) {
const graphLayout = useMemo(() => computeGraphLayout(knowledgePoints), [knowledgePoints])
export function KnowledgeGraph({
knowledgePoints,
selectedId,
onHighlight,
}: KnowledgeGraphProps) {
const t = useTranslations("textbooks")
const layout = useMemo(() => computeGraphLayout(knowledgePoints), [knowledgePoints])
if (knowledgePoints.length === 0) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
{t("reader.emptyKnowledge")}
</div>
)
}
return (
<ScrollArea className="flex-1 h-full px-2">
<div
className="relative"
style={{ width: graphLayout.width, height: graphLayout.height }}
<div className="h-full w-full overflow-auto p-4">
<svg
width={layout.width}
height={layout.height}
role="img"
aria-label={t("reader.tabs.graph")}
className="mx-auto"
>
<svg
width={graphLayout.width}
height={graphLayout.height}
className="absolute inset-0"
>
{graphLayout.edges.map((edge) => (
<line
key={edge.id}
x1={edge.x1}
y1={edge.y1}
x2={edge.x2}
y2={edge.y2}
stroke="hsl(var(--border))"
strokeWidth={2}
/>
))}
</svg>
{graphLayout.nodes.map((node) => (
<button
key={node.id}
type="button"
className={cn(
"absolute rounded-lg border bg-card px-3 py-2 text-left text-sm shadow-sm hover:bg-accent/50",
selectedId === node.id && "border-primary bg-primary/5"
)}
style={{ left: node.x, top: node.y, width: 160, height: 52 }}
onClick={() => onHighlight(node.id)}
>
<div className="font-medium truncate">{node.name}</div>
{node.description && (
<div className="text-[10px] text-muted-foreground truncate">
{node.description}
</div>
)}
</button>
<title>{t("reader.tabs.graph")}</title>
{/* 边 */}
{layout.edges.map((edge) => (
<line
key={edge.id}
x1={edge.x1}
y1={edge.y1}
x2={edge.x2}
y2={edge.y2}
stroke="currentColor"
strokeOpacity={0.3}
strokeWidth={1.5}
/>
))}
</div>
</ScrollArea>
{/* 节点 */}
{layout.nodes.map((node) => {
const isSelected = selectedId === node.id
return (
<g key={node.id} transform={`translate(${node.x}, ${node.y})`}>
<foreignObject width={NODE_WIDTH} height={NODE_HEIGHT}>
<button
type="button"
onClick={() => onHighlight(node.id)}
className={`flex h-full w-full items-center justify-center rounded-lg border-2 px-3 text-center text-xs font-medium transition-colors cursor-pointer ${
isSelected
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-card-foreground hover:border-primary/50 hover:bg-accent"
}`}
aria-label={`${t("reader.clickToViewKp")}: ${node.name}`}
aria-pressed={isSelected}
>
<span className="line-clamp-2">{node.name}</span>
</button>
</foreignObject>
</g>
)
})}
</svg>
</div>
)
}