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,12 +1,15 @@
"use client"
import { useMemo, useState, useEffect } from "react"
import { useMemo, useState, useEffect, type ReactNode } from "react"
import { useQueryState, parseAsString } from "nuqs"
import { Tag, List, Share2 } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import type { Chapter, KnowledgePoint } from "../types"
import { updateChapterContentAction } from "../actions"
import { Permissions } from "@/shared/types/permissions"
import { usePermission } from "@/shared/hooks/use-permission"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { Badge } from "@/shared/components/ui/badge"
@@ -25,35 +28,46 @@ import { ChapterSidebarList } from "./chapter-sidebar-list"
import { KnowledgeGraph } from "./knowledge-graph"
import { KnowledgePointList } from "./knowledge-point-list"
import { TextbookContentPanel } from "./textbook-content-panel"
import { KnowledgePointDialogs } from "./knowledge-point-dialogs"
import {
KnowledgePointDialogs,
type QuestionCreatorRenderProps,
} from "./knowledge-point-dialogs"
import { TextbookSectionErrorBoundary } from "./section-error-boundary"
import { useTextSelection } from "../hooks/use-text-selection"
import { useKnowledgePointActions } from "../hooks/use-knowledge-point-actions"
import { buildChapterIndex } from "../utils"
function buildChapterIndex(chapters: Chapter[]) {
const index = new Map<string, Chapter>()
const walk = (nodes: Chapter[]) => {
for (const node of nodes) {
index.set(node.id, node)
if (node.children && node.children.length > 0) walk(node.children)
}
}
walk(chapters)
return index
export interface TextbookReaderProps {
chapters: Chapter[]
knowledgePoints?: KnowledgePoint[]
/**
* 是否可编辑。已废弃——改由内部 usePermission() 自动判断。
* 保留 prop 仅为向后兼容,传入值会被忽略。
* @deprecated 改用权限系统自动判断
*/
canEdit?: boolean
textbookId?: string
/**
* 题目创建器渲染函数P0-1 解耦)。
* 由页面层注入 questions 模块的 CreateQuestionDialog 实现。
* 不传则不渲染题目创建入口。
*/
renderQuestionCreator?: (props: QuestionCreatorRenderProps) => ReactNode
}
export function TextbookReader({
chapters,
knowledgePoints = [],
canEdit = false,
textbookId,
}: {
chapters: Chapter[]
knowledgePoints?: KnowledgePoint[]
canEdit?: boolean
textbookId?: string
}) {
renderQuestionCreator,
}: TextbookReaderProps) {
const t = useTranslations("textbooks")
const { hasPermission } = usePermission()
// P0-2 前端权限改由 usePermission 判断,不再接受外部 canEdit 硬编码
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
const canCreateQuestion = hasPermission(Permissions.QUESTION_CREATE)
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
const [activeTab, setActiveTab] = useState("chapters")
const [highlightedKpId, setHighlightedKpId] = useState<string | null>(null)
@@ -185,11 +199,11 @@ export function TextbookReader({
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="chapters" className="gap-2">
<List className="h-4 w-4" />
{t("reader.tabs.chapters")}
</TabsTrigger>
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
<Tag className="h-4 w-4" />
{t("reader.tabs.knowledge")}
{currentChapterKPs.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">
{currentChapterKPs.length}
@@ -198,61 +212,80 @@ export function TextbookReader({
</TabsTrigger>
<TabsTrigger value="graph" className="gap-2" disabled={!selectedId}>
<Share2 className="h-4 w-4" />
{t("reader.tabs.graph")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-1 pb-4">
<ChapterSidebarList
chapters={chapters}
selectedChapterId={selectedId || undefined}
onSelectChapter={handleSelect}
textbookId={textbookId || ""}
canEdit={canEdit}
/>
</div>
</ScrollArea>
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-1 pb-4">
<ChapterSidebarList
chapters={chapters}
selectedChapterId={selectedId || undefined}
onSelectChapter={handleSelect}
textbookId={textbookId || ""}
canEdit={canEdit}
/>
</div>
</ScrollArea>
</TextbookSectionErrorBoundary>
</TabsContent>
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
{!selectedId ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : (
<KnowledgePointList
knowledgePoints={currentChapterKPs}
canEdit={canEdit}
highlightedKpId={highlightedKpId}
onHighlight={setHighlightedKpId}
onEdit={(kp) => {
setEditingKp(kp)
setEditKpDialogOpen(true)
}}
onDelete={requestDeleteKnowledgePoint}
onCreateQuestion={(kp) => {
setTargetKpForQuestion(kp)
setQuestionDialogOpen(true)
}}
/>
)}
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
{!selectedId ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
{t("reader.selectChapterKnowledge")}
</div>
) : (
<KnowledgePointList
knowledgePoints={currentChapterKPs}
canEdit={canEdit}
canCreateQuestion={canCreateQuestion}
highlightedKpId={highlightedKpId}
onHighlight={setHighlightedKpId}
onEdit={(kp) => {
setEditingKp(kp)
setEditKpDialogOpen(true)
}}
onDelete={requestDeleteKnowledgePoint}
onCreateQuestion={(kp) => {
setTargetKpForQuestion(kp)
setQuestionDialogOpen(true)
}}
/>
)}
</TextbookSectionErrorBoundary>
</TabsContent>
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
{!selectedId ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : (
<KnowledgeGraph
knowledgePoints={currentChapterKPs}
selectedId={highlightedKpId}
onHighlight={setHighlightedKpId}
/>
)}
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
{!selectedId ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
{t("reader.selectChapterGraph")}
</div>
) : (
<KnowledgeGraph
knowledgePoints={currentChapterKPs}
selectedId={highlightedKpId}
onHighlight={setHighlightedKpId}
/>
)}
</TextbookSectionErrorBoundary>
</TabsContent>
</Tabs>
</div>
@@ -261,14 +294,14 @@ export function TextbookReader({
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
<AlertDialogTitle>{t("dialog.knowledge.deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("dialog.knowledge.deleteDesc")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={confirmDeleteKnowledgePoint}></AlertDialogAction>
<AlertDialogCancel>{t("dialog.knowledge.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={confirmDeleteKnowledgePoint}>
{t("dialog.knowledge.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -287,32 +320,39 @@ export function TextbookReader({
questionDialogOpen={questionDialogOpen}
setQuestionDialogOpen={setQuestionDialogOpen}
targetKpForQuestion={targetKpForQuestion}
renderQuestionCreator={canCreateQuestion ? renderQuestionCreator : undefined}
/>
<TextbookContentPanel
selected={selected}
isEditing={isEditing}
editContent={editContent}
setEditContent={setEditContent}
canEdit={canEdit}
knowledgePoints={currentChapterKPs}
highlightedKpId={highlightedKpId}
onHighlight={setHighlightedKpId}
onSwitchToKnowledgeTab={() => setActiveTab("knowledge")}
contentRef={contentRef}
onPointerDown={handleContentPointerDown}
onContextMenuChange={handleContextMenuChange}
selectedText={selectedText}
createDialogOpen={createDialogOpen}
setCreateDialogOpen={setCreateDialogOpen}
isCreating={isCreating}
onCreateKnowledgePoint={onCreateKnowledgePoint}
startEditing={startEditing}
cancelEditing={() => setIsEditing(false)}
saveContent={handleSaveContent}
isSaving={isSaving}
processedContent={processedContent}
/>
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
<TextbookContentPanel
selected={selected}
isEditing={isEditing}
editContent={editContent}
setEditContent={setEditContent}
canEdit={canEdit}
knowledgePoints={currentChapterKPs}
highlightedKpId={highlightedKpId}
onHighlight={setHighlightedKpId}
onSwitchToKnowledgeTab={() => setActiveTab("knowledge")}
contentRef={contentRef}
onPointerDown={handleContentPointerDown}
onContextMenuChange={handleContextMenuChange}
selectedText={selectedText}
createDialogOpen={createDialogOpen}
setCreateDialogOpen={setCreateDialogOpen}
isCreating={isCreating}
onCreateKnowledgePoint={onCreateKnowledgePoint}
startEditing={startEditing}
cancelEditing={() => setIsEditing(false)}
saveContent={handleSaveContent}
isSaving={isSaving}
processedContent={processedContent}
/>
</TextbookSectionErrorBoundary>
</div>
</div>
)