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,29 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function TeacherTextbookDetailError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
const t = useTranslations("textbooks")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("error.loadFailed")}
description={t("error.loadFailedDesc")}
action={{
label: t("error.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -1,12 +1,15 @@
import type { JSX } from "react"
import type { JSX, ReactNode } from "react"
import { notFound } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import Link from "next/link"
import { getTranslations } from "next-intl/server"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
import { TextbookReader, type TextbookReaderProps } from "@/modules/textbooks/components/textbook-reader"
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog"
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
import type { KnowledgePoint } from "@/modules/textbooks/types"
export const dynamic = "force-dynamic"
@@ -16,6 +19,7 @@ export default async function TextbookDetailPage({
params: Promise<{ id: string }>
}): Promise<JSX.Element> {
const { id } = await params
const t = await getTranslations("textbooks")
const [textbook, chapters, knowledgePoints] = await Promise.all([
getTextbookById(id),
@@ -27,6 +31,25 @@ export default async function TextbookDetailPage({
notFound()
}
// P0-1 在页面层注入 questions 模块的 CreateQuestionDialog 实现
const renderQuestionCreator: TextbookReaderProps["renderQuestionCreator"] = ({
open,
onOpenChange,
targetKp,
}: {
open: boolean
onOpenChange: (open: boolean) => void
targetKp: KnowledgePoint | null
}): ReactNode => (
<CreateQuestionDialog
open={open}
onOpenChange={onOpenChange}
defaultKnowledgePointIds={targetKp ? [targetKp.id] : []}
defaultContent={targetKp ? `Please explain the knowledge point: ${targetKp.name}` : ""}
defaultType="text"
/>
)
return (
<div className="flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
{/* Header / Nav (Fixed height) */}
@@ -34,7 +57,7 @@ export default async function TextbookDetailPage({
<Button asChild variant="ghost" size="sm">
<Link href="/teacher/textbooks">
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
{t("reader.back")}
</Link>
</Button>
<div className="flex-1 min-w-0">
@@ -57,7 +80,7 @@ export default async function TextbookDetailPage({
chapters={chapters}
knowledgePoints={knowledgePoints}
textbookId={id}
canEdit={true}
renderQuestionCreator={renderQuestionCreator}
/>
</div>
</div>

View File

@@ -0,0 +1,29 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function TeacherTextbooksError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
const t = useTranslations("textbooks")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("error.loadFailed")}
description={t("error.loadFailedDesc")}
action={{
label: t("error.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { BookOpen } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
import { TextbookFormDialog } from "@/modules/textbooks/components/textbook-form-dialog"
import { getTextbooks } from "@/modules/textbooks/data-access"
@@ -10,7 +11,13 @@ import { getParam, type SearchParams } from "@/shared/lib/search-params"
export const dynamic = "force-dynamic"
async function TextbooksResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
async function TextbooksResults({
searchParams,
t,
}: {
searchParams: Promise<SearchParams>
t: Awaited<ReturnType<typeof getTranslations<"textbooks">>>
}): Promise<JSX.Element> {
const params = await searchParams
const q = getParam(params, "q") || undefined
@@ -25,9 +32,17 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise<Search
return (
<EmptyState
icon={BookOpen}
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
description={hasFilters ? "Try clearing filters or adjusting keywords." : "Create your first textbook to start organizing chapters."}
action={hasFilters ? { label: "Clear filters", href: "/teacher/textbooks" } : undefined}
title={hasFilters ? t("list.empty.withFilters") : t("list.empty.withoutFilters")}
description={
hasFilters
? t("list.empty.withFiltersDesc")
: t("list.empty.withoutFiltersDesc")
}
action={
hasFilters
? { label: t("list.clearFilters"), href: "/teacher/textbooks" }
: undefined
}
className="min-h-[400px] border-muted-foreground/10"
/>
)
@@ -42,16 +57,20 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise<Search
)
}
export default async function TextbooksPage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
export default async function TextbooksPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
const t = await getTranslations("textbooks")
return (
<div className="space-y-6 p-8">
{/* Page Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Textbooks</h1>
<p className="text-muted-foreground">
Manage your digital curriculum resources and chapters.
</p>
<h1 className="text-2xl font-bold tracking-tight">{t("list.title")}</h1>
<p className="text-muted-foreground">{t("list.subtitle")}</p>
</div>
<TextbookFormDialog />
</div>
@@ -61,7 +80,7 @@ export default async function TextbooksPage({ searchParams }: { searchParams: Pr
</Suspense>
<Suspense fallback={<div className="h-[360px] w-full animate-pulse rounded-md bg-muted" />}>
<TextbooksResults searchParams={searchParams} />
<TextbooksResults searchParams={searchParams} t={t} />
</Suspense>
</div>
)