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,5 +1,7 @@
"use client"
import type { ReactNode } from "react"
import { useTranslations } from "next-intl"
import type { KnowledgePoint } from "../types"
import { Button } from "@/shared/components/ui/button"
import {
@@ -13,9 +15,20 @@ import {
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
interface KnowledgePointDialogsProps {
/**
* 题目创建器的渲染接口。
*
* 通过 render prop 注入,避免 textbooks 模块直接 import questions 模块组件P0-1 解耦)。
* 页面层负责传入实际的 CreateQuestionDialog 实现。
*/
export interface QuestionCreatorRenderProps {
open: boolean
onOpenChange: (open: boolean) => void
targetKp: KnowledgePoint | null
}
export interface KnowledgePointDialogsProps {
// Create KP dialog
createDialogOpen: boolean
setCreateDialogOpen: (open: boolean) => void
@@ -30,10 +43,16 @@ interface KnowledgePointDialogsProps {
isUpdatingKp: boolean
onUpdateKnowledgePoint: (formData: FormData) => Promise<void>
// Question dialog
// Question dialog(通过 render prop 注入,解耦 questions 模块)
questionDialogOpen: boolean
setQuestionDialogOpen: (open: boolean) => void
targetKpForQuestion: KnowledgePoint | null
/**
* 题目创建器渲染函数。
* 由页面层注入实际的 questions 模块组件,模块内部不直接 import questions。
* 若不传则不渲染题目创建入口(学生端等无权限场景)。
*/
renderQuestionCreator?: (props: QuestionCreatorRenderProps) => ReactNode
}
export function KnowledgePointDialogs({
@@ -50,34 +69,35 @@ export function KnowledgePointDialogs({
questionDialogOpen,
setQuestionDialogOpen,
targetKpForQuestion,
renderQuestionCreator,
}: KnowledgePointDialogsProps) {
const t = useTranslations("textbooks.dialog.knowledge")
return (
<>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
<DialogTitle>{t("createTitle")}</DialogTitle>
<DialogDescription>{t("createDesc")}</DialogDescription>
</DialogHeader>
<form action={onCreateKnowledgePoint as (formData: FormData) => void}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Label htmlFor="name">{t("name")}</Label>
<Input id="name" name="name" defaultValue={selectedText} required />
</div>
<div className="grid gap-2">
<Label htmlFor="description"></Label>
<Textarea id="description" name="description" placeholder="请输入描述..." />
<Label htmlFor="description">{t("description")}</Label>
<Textarea id="description" name="description" placeholder={t("descriptionPlaceholder")} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)} disabled={isCreating}>
{t("cancel")}
</Button>
<Button type="submit" disabled={isCreating}>
{isCreating ? "创建中..." : "创建"}
{isCreating ? t("creating") : t("create")}
</Button>
</DialogFooter>
</form>
@@ -87,26 +107,32 @@ export function KnowledgePointDialogs({
<Dialog open={editKpDialogOpen} onOpenChange={setEditKpDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
<DialogTitle>{t("editTitle")}</DialogTitle>
<DialogDescription>{t("editDesc")}</DialogDescription>
</DialogHeader>
<form action={onUpdateKnowledgePoint}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name"></Label>
<Label htmlFor="edit-name">{t("displayName")}</Label>
<Input id="edit-name" name="name" defaultValue={editingKp?.name} required />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description"></Label>
<Textarea id="edit-description" name="description" defaultValue={editingKp?.description || ""} placeholder="请输入描述..." />
<Label htmlFor="edit-description">{t("description")}</Label>
<Textarea
id="edit-description"
name="description"
defaultValue={editingKp?.description || ""}
placeholder={t("descriptionPlaceholder")}
/>
</div>
<div className="space-y-2 border rounded-md p-3 bg-muted/20">
<div className="flex items-center justify-between">
<Label htmlFor="edit-anchorText" className="text-muted-foreground text-xs flex items-center gap-1">
()
<Label
htmlFor="edit-anchorText"
className="text-muted-foreground text-xs flex items-center gap-1"
>
{t("anchorText")}
</Label>
</div>
<div className="pt-2">
@@ -118,31 +144,32 @@ export function KnowledgePointDialogs({
className="text-sm font-mono"
required
/>
<p className="text-[10px] text-muted-foreground mt-1">
</p>
<p className="text-[10px] text-muted-foreground mt-1">{t("anchorTextHint")}</p>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditKpDialogOpen(false)} disabled={isUpdatingKp}>
<Button
type="button"
variant="outline"
onClick={() => setEditKpDialogOpen(false)}
disabled={isUpdatingKp}
>
{t("cancel")}
</Button>
<Button type="submit" disabled={isUpdatingKp}>
{isUpdatingKp ? "保存中..." : "保存"}
{isUpdatingKp ? t("saving") : t("save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<CreateQuestionDialog
open={questionDialogOpen}
onOpenChange={setQuestionDialogOpen}
defaultKnowledgePointIds={targetKpForQuestion ? [targetKpForQuestion.id] : []}
defaultContent={targetKpForQuestion ? `Please explain the knowledge point: ${targetKpForQuestion.name}` : ""}
defaultType="text"
/>
{renderQuestionCreator?.({
open: questionDialogOpen,
onOpenChange: setQuestionDialogOpen,
targetKp: targetKpForQuestion,
})}
</>
)
}