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

@@ -136,9 +136,9 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
<CollapsibleContent>
<div className="pt-1">
{hasChildren && (
<RecursiveSortableList
items={chapter.children!}
{hasChildren && chapter.children && (
<RecursiveSortableList
items={chapter.children}
level={level + 1}
selectedId={selectedId}
onSelect={onSelect}

View File

@@ -3,6 +3,7 @@
import { useState } from "react"
import { Plus } from "lucide-react"
import { useFormStatus } from "react-dom"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
@@ -20,9 +21,10 @@ import { toast } from "sonner"
function SubmitButton() {
const { pending } = useFormStatus()
const t = useTranslations("textbooks")
return (
<Button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create Chapter"}
{pending ? t("dialog.chapter.creating") : t("dialog.chapter.submit")}
</Button>
)
}
@@ -35,7 +37,14 @@ interface CreateChapterDialogProps {
onOpenChange?: (open: boolean) => void
}
export function CreateChapterDialog({ textbookId, parentId, trigger, open: controlledOpen, onOpenChange }: CreateChapterDialogProps) {
export function CreateChapterDialog({
textbookId,
parentId,
trigger,
open: controlledOpen,
onOpenChange,
}: CreateChapterDialogProps) {
const t = useTranslations("textbooks")
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
const open = controlledOpen ?? uncontrolledOpen
const setOpen = onOpenChange ?? setUncontrolledOpen
@@ -54,9 +63,13 @@ export function CreateChapterDialog({ textbookId, parentId, trigger, open: contr
trigger === null
? null
: trigger || (
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add Chapter</span>
<span className="sr-only">{t("dialog.chapter.createTitle")}</span>
</Button>
)
@@ -65,21 +78,19 @@ export function CreateChapterDialog({ textbookId, parentId, trigger, open: contr
{triggerNode ? <DialogTrigger asChild>{triggerNode}</DialogTrigger> : null}
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Chapter</DialogTitle>
<DialogDescription>
Create a new chapter or section.
</DialogDescription>
<DialogTitle>{t("dialog.chapter.createTitle")}</DialogTitle>
<DialogDescription>{t("dialog.chapter.createDesc")}</DialogDescription>
</DialogHeader>
<form action={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="title" className="text-right">
Title
{t("field.title")}
</Label>
<Input
id="title"
name="title"
placeholder="e.g. Chapter 1: Introduction"
placeholder={t("dialog.chapter.titlePlaceholder")}
className="col-span-3"
required
/>

View File

@@ -1,95 +0,0 @@
"use client"
import { useState } from "react"
import { Plus } from "lucide-react"
import { useFormStatus } from "react-dom"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { createKnowledgePointAction } from "../actions"
import { toast } from "sonner"
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Adding..." : "Add Point"}
</Button>
)
}
interface CreateKnowledgePointDialogProps {
chapterId: string
textbookId: string
}
export function CreateKnowledgePointDialog({ chapterId, textbookId }: CreateKnowledgePointDialogProps) {
const [open, setOpen] = useState(false)
const handleSubmit = async (formData: FormData) => {
const result = await createKnowledgePointAction(chapterId, textbookId, null, formData)
if (result.success) {
toast.success(result.message)
setOpen(false)
} else {
toast.error(result.message)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Plus className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add Knowledge Point</DialogTitle>
<DialogDescription>
Link a key concept to this chapter.
</DialogDescription>
</DialogHeader>
<form action={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">
Name
</Label>
<Input
id="name"
name="name"
placeholder="e.g. Pythagorean Theorem"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">
Description
</Label>
<Textarea
id="description"
name="description"
placeholder="Brief explanation..."
className="h-20"
/>
</div>
</div>
<DialogFooter>
<SubmitButton />
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

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>
)
}

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,
})}
</>
)
}

View File

@@ -1,6 +1,7 @@
"use client"
import { PlusCircle, Pencil, Trash2 } from "lucide-react"
import { useTranslations } from "next-intl"
import type { KnowledgePoint } from "../types"
import { cn } from "@/shared/lib/utils"
@@ -11,6 +12,8 @@ import { Button } from "@/shared/components/ui/button"
interface KnowledgePointListProps {
knowledgePoints: KnowledgePoint[]
canEdit: boolean
/** 是否有创建题目权限(控制"创建相关题目"按钮可见性) */
canCreateQuestion?: boolean
highlightedKpId: string | null
onHighlight: (id: string) => void
onEdit: (kp: KnowledgePoint) => void
@@ -21,16 +24,19 @@ interface KnowledgePointListProps {
export function KnowledgePointList({
knowledgePoints,
canEdit,
canCreateQuestion = false,
highlightedKpId,
onHighlight,
onEdit,
onDelete,
onCreateQuestion,
}: KnowledgePointListProps) {
const t = useTranslations("textbooks")
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>
)
}
@@ -51,22 +57,27 @@ export function KnowledgePointList({
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
<div className="flex items-center gap-1">
<Badge variant="outline" className="text-[10px] h-5 px-1">Lv.{kp.level}</Badge>
<Badge variant="outline" className="text-[10px] h-5 px-1">
{t("panel.level")}
{kp.level}
</Badge>
{canEdit && (
<div className="flex items-center gap-1 ml-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
onCreateQuestion(kp)
}}
title="创建相关题目"
aria-label="创建相关题目"
>
<PlusCircle className="h-3 w-3" />
</Button>
{canCreateQuestion && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
onCreateQuestion(kp)
}}
title={t("dialog.knowledge.createQuestion")}
aria-label={t("dialog.knowledge.createQuestion")}
>
<PlusCircle className="h-3 w-3" />
</Button>
)}
<Button
variant="ghost"
size="icon"
@@ -75,8 +86,8 @@ export function KnowledgePointList({
e.stopPropagation()
onEdit(kp)
}}
title="编辑知识点"
aria-label="编辑知识点"
title={t("dialog.knowledge.editKp")}
aria-label={t("dialog.knowledge.editKp")}
>
<Pencil className="h-3 w-3" />
</Button>
@@ -85,8 +96,8 @@ export function KnowledgePointList({
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => onDelete(kp.id, e)}
title="删除知识点"
aria-label="删除知识点"
title={t("dialog.knowledge.deleteKp")}
aria-label={t("dialog.knowledge.deleteKp")}
>
<Trash2 className="h-3 w-3" />
</Button>

View File

@@ -1,157 +0,0 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Card, CardContent } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Tag, Trash2 } from "lucide-react"
import { KnowledgePoint } from "../types"
import { CreateKnowledgePointDialog } from "./create-knowledge-point-dialog"
import { deleteKnowledgePointAction } from "../actions"
import { toast } from "sonner"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
interface KnowledgePointPanelProps {
knowledgePoints: KnowledgePoint[]
selectedChapterId: string | null
textbookId: string
}
export function KnowledgePointPanel({
knowledgePoints,
selectedChapterId,
textbookId
}: KnowledgePointPanelProps) {
const router = useRouter()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<KnowledgePoint | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const requestDelete = (kp: KnowledgePoint) => {
setDeleteTarget(kp)
setShowDeleteDialog(true)
}
const handleDelete = async () => {
if (!deleteTarget) return
setIsDeleting(true)
try {
const result = await deleteKnowledgePointAction(deleteTarget.id, textbookId)
if (result.success) {
toast.success(result.message)
setShowDeleteDialog(false)
setDeleteTarget(null)
router.refresh()
} else {
toast.error(result.message)
}
} catch {
toast.error("Failed to delete knowledge point")
} finally {
setIsDeleting(false)
}
}
// Filter KPs for the selected chapter
const chapterKPs = selectedChapterId
? knowledgePoints.filter(kp => kp.chapterId === selectedChapterId)
: []
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold text-sm uppercase tracking-wider text-muted-foreground flex items-center gap-2">
<Tag className="h-4 w-4" />
Knowledge Points
</h3>
{selectedChapterId && (
<CreateKnowledgePointDialog
chapterId={selectedChapterId}
textbookId={textbookId}
/>
)}
</div>
<ScrollArea className="flex-1">
<div className="p-4 space-y-3">
{selectedChapterId ? (
chapterKPs.length > 0 ? (
<>
{chapterKPs.map((kp) => (
<Card key={kp.id} className="relative group hover:shadow-sm transition-shadow">
<CardContent className="p-3">
<div className="flex justify-between items-start gap-2">
<div className="space-y-1">
<div className="font-medium text-sm leading-tight text-foreground">
{kp.name}
</div>
{kp.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{kp.description}
</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 -mr-1 -mt-1 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
onClick={() => requestDelete(kp)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center space-y-3">
<div className="p-3 rounded-full bg-muted/50">
<Tag className="h-6 w-6 text-muted-foreground/40" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">No points yet</p>
<p className="text-xs text-muted-foreground/60 max-w-[160px]">
Add knowledge points to tag content in this chapter.
</p>
</div>
</div>
)
) : (
<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground space-y-2">
<p className="text-sm">Select a chapter to manage knowledge points</p>
</div>
)}
</div>
</ScrollArea>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Knowledge Point?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the knowledge point
<span className="font-medium text-foreground"> {deleteTarget?.name}</span>.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,67 @@
"use client"
/**
* 教材模块内联 Error Boundary。
*
* 用于包裹独立数据区块(章节树、内容区、知识点区、图谱区),
* 隔离故障域,避免单点错误导致整个阅读器白屏。
*/
import { Component, type ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
interface TextbookSectionErrorBoundaryProps {
children: ReactNode
/** 自定义降级 UI 标题 */
fallbackTitle?: string
/** 自定义降级 UI 描述 */
fallbackDescription?: string
/** 重试按钮文案 */
retryLabel?: string
}
interface TextbookSectionErrorBoundaryState {
hasError: boolean
}
export class TextbookSectionErrorBoundary extends Component<
TextbookSectionErrorBoundaryProps,
TextbookSectionErrorBoundaryState
> {
constructor(props: TextbookSectionErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(): TextbookSectionErrorBoundaryState {
return { hasError: true }
}
handleReset = (): void => {
this.setState({ hasError: false })
}
render(): ReactNode {
if (this.state.hasError) {
return (
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 p-6 text-center">
<AlertCircle className="h-8 w-8 text-muted-foreground" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{this.props.fallbackTitle ?? "区块加载失败"}
</p>
<p className="text-xs text-muted-foreground">
{this.props.fallbackDescription ?? "请重试或刷新页面"}
</p>
</div>
<Button size="sm" variant="outline" onClick={this.handleReset}>
{this.props.retryLabel ?? "重试"}
</Button>
</div>
)
}
return this.props.children
}
}

View File

@@ -1,41 +1,36 @@
import Link from "next/link";
import { Book, MoreVertical, Edit, Trash2, BookOpen, GraduationCap, Building2 } from "lucide-react";
"use client"
import Link from "next/link"
import { useTranslations } from "next-intl"
import { Book, MoreVertical, Edit, Trash2, BookOpen, GraduationCap, Building2 } from "lucide-react"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
} from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { cn, formatDate } from "@/shared/lib/utils";
import { Textbook } from "../types";
} from "@/shared/components/ui/dropdown-menu"
import { cn, formatDate } from "@/shared/lib/utils"
import type { Textbook } from "../types"
import { getSubjectColor } from "../constants"
interface TextbookCardProps {
textbook: Textbook;
hrefBase?: string;
hideActions?: boolean;
textbook: Textbook
hrefBase?: string
hideActions?: boolean
}
const subjectColorMap: Record<string, string> = {
Mathematics: "bg-blue-50 text-blue-700 border-blue-200/70 dark:bg-blue-950/50 dark:text-blue-200 dark:border-blue-900/60",
Physics: "bg-purple-50 text-purple-700 border-purple-200/70 dark:bg-purple-950/50 dark:text-purple-200 dark:border-purple-900/60",
Chemistry: "bg-teal-50 text-teal-700 border-teal-200/70 dark:bg-teal-950/50 dark:text-teal-200 dark:border-teal-900/60",
English: "bg-orange-50 text-orange-700 border-orange-200/70 dark:bg-orange-950/50 dark:text-orange-200 dark:border-orange-900/60",
History: "bg-amber-50 text-amber-700 border-amber-200/70 dark:bg-amber-950/50 dark:text-amber-200 dark:border-amber-900/60",
Biology: "bg-emerald-50 text-emerald-700 border-emerald-200/70 dark:bg-emerald-950/50 dark:text-emerald-200 dark:border-emerald-900/60",
Geography: "bg-sky-50 text-sky-700 border-sky-200/70 dark:bg-sky-950/50 dark:text-sky-200 dark:border-sky-900/60",
};
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
const base = hrefBase || "/teacher/textbooks";
const colorClass = subjectColorMap[textbook.subject] || "bg-zinc-50 text-zinc-700 border-zinc-200/70 dark:bg-zinc-950/50 dark:text-zinc-200 dark:border-zinc-800/70";
const t = useTranslations("textbooks")
const base = hrefBase || "/teacher/textbooks"
const colorClass = getSubjectColor(textbook.subject)
return (
<Card className="group flex flex-col h-full overflow-hidden border-border/60 transition-all duration-300 hover:shadow-md hover:border-primary/50">
@@ -50,7 +45,7 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
<Book className="h-5 w-5" />
</div>
<div className="text-xs font-medium text-foreground/70">
{textbook.grade || "Grade N/A"}
{textbook.grade || t("card.gradeNA")}
</div>
</div>
</div>
@@ -68,54 +63,56 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
<div className="flex flex-wrap gap-y-1 gap-x-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<GraduationCap className="h-3.5 w-3.5" />
<span>{textbook.grade || "Grade N/A"}</span>
<span>{textbook.grade || t("card.gradeNA")}</span>
</div>
<div className="flex items-center gap-1.5">
<Building2 className="h-3.5 w-3.5" />
<span className="truncate max-w-[120px]" title={textbook.publisher || ""}>
{textbook.publisher || "Publisher N/A"}
{textbook.publisher || t("card.publisherNA")}
</span>
</div>
</div>
</CardContent>
</Link>
<CardFooter className="p-4 pt-2 mt-auto border-t border-border/60 bg-muted/30 flex items-center justify-between">
<CardFooter className="p-4 pt-2 mt-auto border-t border-border/60 bg-muted/30 flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-background/80 ring-1 ring-border/60">
<BookOpen className="h-3.5 w-3.5" />
</div>
<span>{textbook._count?.chapters || 0} Chapters</span>
<span>
{textbook._count?.chapters || 0} {t("card.chapters")}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground/60 mr-2">
Updated {formatDate(textbook.updatedAt)}
</span>
{!hideActions && (
<DropdownMenu>
<span className="text-[10px] text-muted-foreground/60 mr-2">
{t("card.updated")} {formatDate(textbook.updatedAt)}
</span>
{!hideActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 -mr-2">
<MoreVertical className="h-3.5 w-3.5" />
<span className="sr-only">More options</span>
<span className="sr-only">{t("card.moreOptions")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`${base}/${textbook.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit Content
</Link>
<Link href={`${base}/${textbook.id}`}>
<Edit className="mr-2 h-4 w-4" />
{t("card.editContent")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
{t("card.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</DropdownMenu>
)}
</div>
</CardFooter>
</Card>
);
)
}

View File

@@ -5,6 +5,7 @@ import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import rehypeSanitize from "rehype-sanitize"
import { Edit2, Save, Plus } from "lucide-react"
import { useTranslations } from "next-intl"
import type { Chapter, KnowledgePoint } from "../types"
import { cn } from "@/shared/lib/utils"
@@ -63,10 +64,12 @@ export function TextbookContentPanel({
isSaving,
processedContent,
}: TextbookContentPanelProps) {
const t = useTranslations("textbooks")
if (!selected) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
{t("reader.selectChapter")}
</div>
)
}
@@ -80,17 +83,17 @@ export function TextbookContentPanel({
{isEditing ? (
<>
<Button size="sm" variant="ghost" onClick={cancelEditing} disabled={isSaving}>
{t("reader.cancel")}
</Button>
<Button size="sm" onClick={saveContent} disabled={isSaving}>
<Save className="mr-2 h-4 w-4" />
{isSaving ? "保存中..." : "保存"}
{isSaving ? t("reader.saving") : t("reader.save")}
</Button>
</>
) : (
<Button size="sm" variant="outline" onClick={startEditing}>
<Edit2 className="mr-2 h-4 w-4" />
{t("reader.editContent")}
</Button>
)}
</div>
@@ -135,7 +138,7 @@ export function TextbookContentPanel({
onHighlight(id)
onSwitchToKnowledgeTab()
}}
title="点击查看知识点详情"
title={t("reader.clickToViewKp")}
>
{children}
</span>
@@ -149,7 +152,9 @@ export function TextbookContentPanel({
</ReactMarkdown>
</div>
) : (
<div className="text-muted-foreground italic py-8 text-center"></div>
<div className="text-muted-foreground italic py-8 text-center">
{t("reader.emptyContent")}
</div>
)}
</div>
</ContextMenuTrigger>
@@ -159,7 +164,7 @@ export function TextbookContentPanel({
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
{t("reader.addKnowledgePoint")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>

View File

@@ -1,6 +1,7 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { useTranslations } from "next-intl"
import {
Select,
@@ -10,8 +11,10 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
import { SUBJECTS, GRADES } from "../constants"
export function TextbookFilters() {
const t = useTranslations("textbooks")
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [subject, setSubject] = useQueryState("subject", parseAsString.withDefault("all"))
const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all"))
@@ -31,38 +34,35 @@ export function TextbookFilters() {
<FilterSearchInput
value={search}
onChange={(v) => setSearch(v || null)}
placeholder="Search by title, publisher..."
placeholder={t("filters.searchPlaceholder")}
/>
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
<SelectValue placeholder="Subject" />
<SelectValue placeholder={t("field.subject")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Subjects</SelectItem>
<SelectItem value="Mathematics">Mathematics</SelectItem>
<SelectItem value="Physics">Physics</SelectItem>
<SelectItem value="Chemistry">Chemistry</SelectItem>
<SelectItem value="Biology">Biology</SelectItem>
<SelectItem value="English">English</SelectItem>
<SelectItem value="History">History</SelectItem>
<SelectItem value="Geography">Geography</SelectItem>
<SelectItem value="all">{t("filters.allSubjects")}</SelectItem>
{SUBJECTS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{t(`subject.${s.labelKey}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={grade} onValueChange={(val) => setGrade(val === "all" ? null : val)}>
<SelectTrigger className="w-[130px] bg-background border-muted-foreground/20">
<SelectValue placeholder="Grade" />
<SelectValue placeholder={t("field.grade")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Grades</SelectItem>
<SelectItem value="Grade 7">Grade 7</SelectItem>
<SelectItem value="Grade 8">Grade 8</SelectItem>
<SelectItem value="Grade 9">Grade 9</SelectItem>
<SelectItem value="Grade 10">Grade 10</SelectItem>
<SelectItem value="Grade 11">Grade 11</SelectItem>
<SelectItem value="Grade 12">Grade 12</SelectItem>
<SelectItem value="all">{t("filters.allGrades")}</SelectItem>
{GRADES.map((g) => (
<SelectItem key={g.value} value={g.value}>
{t(`grade.${g.labelKey}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

View File

@@ -3,6 +3,7 @@
import { useState } from "react"
import { Plus } from "lucide-react"
import { useFormStatus } from "react-dom"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
@@ -23,22 +24,23 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { createTextbookAction } from "../actions"
import { SUBJECTS, GRADES } from "../constants"
import { toast } from "sonner"
function SubmitButton() {
const { pending } = useFormStatus()
const t = useTranslations("textbooks")
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save changes"}
{pending ? t("dialog.create.saving") : t("dialog.create.submit")}
</Button>
)
}
export function TextbookFormDialog() {
const t = useTranslations("textbooks")
const [open, setOpen] = useState(false)
// Using simple form action without useActionState hook for simplicity in this demo environment
// In production with React 19/Next 15, we'd use useActionState
const handleSubmit = async (formData: FormData) => {
const result = await createTextbookAction(null, formData)
if (result.success) {
@@ -54,72 +56,70 @@ export function TextbookFormDialog() {
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Textbook
{t("list.add")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Textbook</DialogTitle>
<DialogDescription>
Create a new digital textbook. Click save when you&apos;re done.
</DialogDescription>
<DialogTitle>{t("dialog.create.title")}</DialogTitle>
<DialogDescription>{t("dialog.create.description")}</DialogDescription>
</DialogHeader>
<form action={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="title" className="text-right">
Title
{t("field.title")}
</Label>
<Input
id="title"
name="title"
placeholder="e.g. Advanced Calculus"
placeholder={t("field.titlePlaceholder")}
className="col-span-3"
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="subject" className="text-right">
Subject
{t("field.subject")}
</Label>
<Select name="subject" required>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select subject" />
<SelectValue placeholder={t("field.subjectPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="Mathematics">Mathematics</SelectItem>
<SelectItem value="Physics">Physics</SelectItem>
<SelectItem value="Chemistry">Chemistry</SelectItem>
<SelectItem value="Biology">Biology</SelectItem>
<SelectItem value="English">English</SelectItem>
<SelectItem value="History">History</SelectItem>
<SelectItem value="Geography">Geography</SelectItem>
{SUBJECTS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{t(`subject.${s.labelKey}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade" className="text-right">
Grade
{t("field.grade")}
</Label>
<Select name="grade" required>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select grade" />
<SelectValue placeholder={t("field.gradePlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="Grade 10">Grade 10</SelectItem>
<SelectItem value="Grade 11">Grade 11</SelectItem>
<SelectItem value="Grade 12">Grade 12</SelectItem>
{GRADES.map((g) => (
<SelectItem key={g.value} value={g.value}>
{t(`grade.${g.labelKey}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="publisher" className="text-right">
Publisher
{t("field.publisher")}
</Label>
<Input
id="publisher"
name="publisher"
placeholder="e.g. Next Education"
placeholder={t("field.publisherPlaceholder")}
className="col-span-3"
/>
</div>

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>
)

View File

@@ -3,6 +3,7 @@
import { useState } from "react"
import { Edit } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
@@ -13,6 +14,16 @@ import {
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import {
@@ -23,8 +34,9 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { updateTextbookAction, deleteTextbookAction } from "../actions"
import { SUBJECTS, GRADES } from "../constants"
import { toast } from "sonner"
import { Textbook } from "../types"
import type { Textbook } from "../types"
interface TextbookSettingsDialogProps {
textbook: Textbook
@@ -32,8 +44,10 @@ interface TextbookSettingsDialogProps {
}
export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDialogProps) {
const t = useTranslations("textbooks")
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const router = useRouter()
const handleUpdate = async (formData: FormData) => {
@@ -48,15 +62,15 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
}
}
// P1-8 用 AlertDialog 替换浏览器原生 confirm()
const handleDelete = async () => {
if (!confirm("Are you sure you want to delete this textbook? This action cannot be undone.")) return
setDeleteDialogOpen(false)
setLoading(true)
const result = await deleteTextbookAction(textbook.id)
if (result.success) {
toast.success(result.message)
router.push("/teacher/textbooks") // Redirect after delete
router.push("/teacher/textbooks")
} else {
setLoading(false)
toast.error(result.message)
@@ -69,23 +83,21 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
{trigger || (
<Button variant="outline" size="sm">
<Edit className="mr-2 h-4 w-4" />
Settings
{t("dialog.settings.trigger")}
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Textbook Settings</DialogTitle>
<DialogDescription>
Update textbook details or delete this textbook.
</DialogDescription>
<DialogTitle>{t("dialog.settings.title")}</DialogTitle>
<DialogDescription>{t("dialog.settings.description")}</DialogDescription>
</DialogHeader>
<form action={handleUpdate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="title" className="text-right">
Title
{t("field.title")}
</Label>
<Input
id="title"
@@ -97,39 +109,41 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="subject" className="text-right">
Subject
{t("field.subject")}
</Label>
<Select name="subject" defaultValue={textbook.subject} required>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select subject" />
<SelectValue placeholder={t("field.subjectPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="Mathematics">Mathematics</SelectItem>
<SelectItem value="Physics">Physics</SelectItem>
<SelectItem value="History">History</SelectItem>
<SelectItem value="English">English</SelectItem>
<SelectItem value="Chemistry">Chemistry</SelectItem>
{SUBJECTS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{t(`subject.${s.labelKey}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade" className="text-right">
Grade
{t("field.grade")}
</Label>
<Select name="grade" defaultValue={textbook.grade || undefined} required>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select grade" />
<SelectValue placeholder={t("field.gradePlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="Grade 10">Grade 10</SelectItem>
<SelectItem value="Grade 11">Grade 11</SelectItem>
<SelectItem value="Grade 12">Grade 12</SelectItem>
{GRADES.map((g) => (
<SelectItem key={g.value} value={g.value}>
{t(`grade.${g.labelKey}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="publisher" className="text-right">
Publisher
{t("field.publisher")}
</Label>
<Input
id="publisher"
@@ -139,22 +153,39 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
/>
</div>
</div>
<DialogFooter className="flex justify-between sm:justify-between">
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={loading}
>
{loading ? "Processing..." : "Delete Textbook"}
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Saving..." : "Save Changes"}
</Button>
<Button
type="button"
variant="destructive"
onClick={() => setDeleteDialogOpen(true)}
disabled={loading}
>
{loading ? t("dialog.settings.processing") : t("dialog.settings.delete")}
</Button>
<Button type="submit" disabled={loading}>
{loading ? t("dialog.settings.processing") : t("dialog.settings.save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("dialog.settings.deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("dialog.settings.deleteConfirmDesc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("dialog.knowledge.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>
{t("dialog.settings.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
)
}