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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
67
src/modules/textbooks/components/section-error-boundary.tsx
Normal file
67
src/modules/textbooks/components/section-error-boundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user