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

@@ -14,7 +14,9 @@ import {
updateKnowledgePoint,
updateTextbook,
deleteTextbook,
reorderChapters
reorderChapters,
verifyChapterBelongsToTextbook,
verifyKnowledgePointBelongsToTextbook,
} from "./data-access";
import {
CreateTextbookSchema,
@@ -38,6 +40,11 @@ export async function reorderChaptersAction(
): Promise<ActionState> {
try {
await requirePermission(Permissions.TEXTBOOK_UPDATE);
// P0-4 资源归属校验:防止越权操作其他教材的章节
const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId)
if (!belongs) {
return { success: false, message: "Chapter does not belong to this textbook" };
}
await reorderChapters(chapterId, newIndex, parentId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapters reordered successfully" };
@@ -203,6 +210,11 @@ export async function updateChapterContentAction(
try {
await requirePermission(Permissions.TEXTBOOK_UPDATE);
// P0-4 资源归属校验
const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId)
if (!belongs) {
return { success: false, message: "Chapter does not belong to this textbook" };
}
await updateChapterContent(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Content updated successfully" };
@@ -220,6 +232,11 @@ export async function deleteChapterAction(
): Promise<ActionState> {
try {
await requirePermission(Permissions.TEXTBOOK_DELETE);
// P0-4 资源归属校验
const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId)
if (!belongs) {
return { success: false, message: "Chapter does not belong to this textbook" };
}
await deleteChapter(chapterId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapter deleted successfully" };
@@ -254,6 +271,11 @@ export async function createKnowledgePointAction(
try {
await requirePermission(Permissions.TEXTBOOK_CREATE);
// P0-4 资源归属校验:确保 chapter 属于该 textbook防止跨教材越权创建知识点
const chapterBelongs = await verifyChapterBelongsToTextbook(chapterId, textbookId);
if (!chapterBelongs) {
return { success: false, message: "Chapter does not belong to this textbook" };
}
await createKnowledgePoint(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point created successfully" };
@@ -271,6 +293,11 @@ export async function deleteKnowledgePointAction(
): Promise<ActionState> {
try {
await requirePermission(Permissions.TEXTBOOK_DELETE);
// P0-4 资源归属校验
const belongs = await verifyKnowledgePointBelongsToTextbook(kpId, textbookId)
if (!belongs) {
return { success: false, message: "Knowledge point does not belong to this textbook" };
}
await deleteKnowledgePoint(kpId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point deleted successfully" };
@@ -305,6 +332,11 @@ export async function updateKnowledgePointAction(
try {
await requirePermission(Permissions.TEXTBOOK_UPDATE);
// P0-4 资源归属校验
const belongs = await verifyKnowledgePointBelongsToTextbook(kpId, textbookId)
if (!belongs) {
return { success: false, message: "Knowledge point does not belong to this textbook" };
}
await updateKnowledgePoint(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point updated successfully" };

View File

@@ -0,0 +1,43 @@
/**
* 教材模块埋点接口(预留)。
*
* 当前为 no-op 实现,后续接入真实监控 SDK 时只需替换 Provider。
* 通过 React Context 注入,组件内调用 `useTextbookAnalytics()` 获取。
*/
"use client"
import { createContext, useContext, type ReactNode } from "react"
export interface TextbookAnalytics {
/** 教材被打开时触发 */
onTextbookOpen?(textbookId: string): void
/** 章节被阅读时触发(含停留时长) */
onChapterRead?(textbookId: string, chapterId: string, durationMs: number): void
/** 知识点被点击时触发 */
onKnowledgePointClick?(kpId: string): void
/** 知识点被创建时触发 */
onKnowledgePointCreate?(chapterId: string, kpId: string): void
/** 章节内容被编辑保存时触发 */
onChapterContentUpdate?(chapterId: string): void
}
const TextbookAnalyticsContext = createContext<TextbookAnalytics>({})
export function TextbookAnalyticsProvider({
analytics,
children,
}: {
analytics?: TextbookAnalytics
children: ReactNode
}) {
return (
<TextbookAnalyticsContext.Provider value={analytics ?? {}}>
{children}
</TextbookAnalyticsContext.Provider>
)
}
export function useTextbookAnalytics(): TextbookAnalytics {
return useContext(TextbookAnalyticsContext)
}

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

View File

@@ -0,0 +1,75 @@
/**
* 教材模块共享常量。
*
* 集中管理学科、年级、学科颜色映射,避免在 filters/form/settings/card 多处硬编码导致不一致。
*/
export type SubjectOption = {
/** 存储值(英文 key写入数据库 */
value: string
/** i18n 键(用于翻译显示) */
labelKey: string
}
export type GradeOption = {
value: string
labelKey: string
}
/**
* 学科列表。
* 新增学科只需在此处添加一条记录,所有 Select/筛选/表单/设置弹窗自动同步。
*/
export const SUBJECTS: readonly SubjectOption[] = [
{ value: "Mathematics", labelKey: "mathematics" },
{ value: "Physics", labelKey: "physics" },
{ value: "Chemistry", labelKey: "chemistry" },
{ value: "Biology", labelKey: "biology" },
{ value: "English", labelKey: "english" },
{ value: "History", labelKey: "history" },
{ value: "Geography", labelKey: "geography" },
] as const
/**
* 年级列表。
*/
export const GRADES: readonly GradeOption[] = [
{ value: "Grade 7", labelKey: "grade7" },
{ value: "Grade 8", labelKey: "grade8" },
{ value: "Grade 9", labelKey: "grade9" },
{ value: "Grade 10", labelKey: "grade10" },
{ value: "Grade 11", labelKey: "grade11" },
{ value: "Grade 12", labelKey: "grade12" },
] as const
/**
* 学科主题色映射(用于教材卡片封面背景)。
* key 必须与 SUBJECTS 中的 value 一致。
*/
export const SUBJECT_COLORS: 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",
}
/** 默认学科色(未命中 SUBJECT_COLORS 时的兜底) */
export const DEFAULT_SUBJECT_COLOR =
"bg-zinc-50 text-zinc-700 border-zinc-200/70 dark:bg-zinc-950/50 dark:text-zinc-200 dark:border-zinc-800/70"
/**
* 根据学科 value 获取主题色 className。
*/
export function getSubjectColor(subject: string): string {
return SUBJECT_COLORS[subject] ?? DEFAULT_SUBJECT_COLOR
}

View File

@@ -19,57 +19,21 @@ import type {
UpdateKnowledgePointInput,
UpdateTextbookInput,
} from "./schema"
import {
buildChapterTree,
normalizeOptional,
sortChapters,
} from "./utils"
const normalizeOptional = (v: string | null | undefined): string | null => {
const trimmed = v?.trim()
if (!trimmed) return null
return trimmed
}
export { buildChapterTree, normalizeOptional, sortChapters }
const sortChapters = (a: Chapter, b: Chapter): number => {
const ao = a.order ?? 0
const bo = b.order ?? 0
if (ao !== bo) return ao - bo
return a.title.localeCompare(b.title)
}
const buildChapterTree = (rows: Chapter[]): Chapter[] => {
type ChapterNode = Chapter & { children: ChapterNode[] }
const isChapterNode = (n: Chapter): n is ChapterNode =>
Array.isArray(n.children)
const byId = new Map<string, ChapterNode>()
for (const ch of rows) {
byId.set(ch.id, { ...ch, children: [] })
}
const roots: ChapterNode[] = []
for (const ch of byId.values()) {
const pid = ch.parentId
if (pid) {
const parent = byId.get(pid)
if (parent) {
parent.children.push(ch)
} else {
roots.push(ch)
}
} else {
roots.push(ch)
}
}
const sortRecursive = (nodes: ChapterNode[]) => {
nodes.sort(sortChapters)
for (const n of nodes) {
if (isChapterNode(n)) {
sortRecursive(n.children)
}
}
}
sortRecursive(roots)
return roots
/**
* 数据范围过滤参数。
* 学生端应传入 grade 按年级过滤;教师/admin 端不传则返回全量。
*/
export interface TextbookQueryScope {
/** 按年级过滤(学生端传入学生所在年级) */
grade?: string
}
export const getTextbooks = cache(async (query?: string, subject?: string, grade?: string): Promise<Textbook[]> => {
@@ -459,6 +423,147 @@ export const getTextbooksDashboardStats = cache(async (): Promise<TextbooksDashb
}
})
// ---------------------------------------------------------------------------
// 资源归属校验P0-4
// ---------------------------------------------------------------------------
/**
* 校验章节是否属于指定教材。
*
* 用于 Server Action 二次校验,防止越权操作其他教材的章节。
*
* @returns true 表示归属一致
*/
export async function verifyChapterBelongsToTextbook(
chapterId: string,
textbookId: string
): Promise<boolean> {
const [row] = await db
.select({ textbookId: chapters.textbookId })
.from(chapters)
.where(eq(chapters.id, chapterId))
.limit(1)
if (!row) return false
return row.textbookId === textbookId
}
/**
* 校验知识点是否属于指定章节。
*/
export async function verifyKnowledgePointBelongsToChapter(
kpId: string,
chapterId: string
): Promise<boolean> {
const [row] = await db
.select({ chapterId: knowledgePoints.chapterId })
.from(knowledgePoints)
.where(eq(knowledgePoints.id, kpId))
.limit(1)
if (!row) return false
return row.chapterId === chapterId
}
/**
* 校验知识点是否属于指定教材(通过 chapter → textbook 关联)。
*
* 用于 Server Action 二次校验,防止越权操作其他教材的知识点。
*/
export async function verifyKnowledgePointBelongsToTextbook(
kpId: string,
textbookId: string
): Promise<boolean> {
const [row] = await db
.select({ textbookId: chapters.textbookId })
.from(knowledgePoints)
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
.where(eq(knowledgePoints.id, kpId))
.limit(1)
if (!row) return false
return row.textbookId === textbookId
}
// ---------------------------------------------------------------------------
// 带 scope 的查询P1-1 数据范围过滤)
// ---------------------------------------------------------------------------
/**
* 按数据范围获取教材列表。
*
* 学生端应传入 `scope.grade` 按年级过滤,避免跨年级越权读取。
*/
export const getTextbooksWithScope = cache(
async (
query?: string,
subject?: string,
grade?: string,
scope?: TextbookQueryScope
): Promise<Textbook[]> => {
const conditions: SQL[] = []
const q = query?.trim()
if (q) {
const needle = `%${q}%`
const nameCond = or(
like(textbooks.title, needle),
like(textbooks.subject, needle),
like(textbooks.grade, needle),
like(textbooks.publisher, needle)
)
if (nameCond) conditions.push(nameCond)
}
const s = subject?.trim()
if (s && s !== "all") conditions.push(eq(textbooks.subject, s))
// URL 参数 grade用户筛选
const g = grade?.trim()
if (g && g !== "all") conditions.push(eq(textbooks.grade, g))
// scope.grade数据范围过滤学生端强制按年级过滤
const scopeGrade = scope?.grade?.trim()
if (scopeGrade) conditions.push(eq(textbooks.grade, scopeGrade))
const rows = await db
.select({
id: textbooks.id,
title: textbooks.title,
subject: textbooks.subject,
grade: textbooks.grade,
publisher: textbooks.publisher,
createdAt: textbooks.createdAt,
updatedAt: textbooks.updatedAt,
chaptersCount: sql<number>`COUNT(${chapters.id})`,
})
.from(textbooks)
.leftJoin(chapters, eq(chapters.textbookId, textbooks.id))
.where(conditions.length ? and(...conditions) : undefined)
.groupBy(
textbooks.id,
textbooks.title,
textbooks.subject,
textbooks.grade,
textbooks.publisher,
textbooks.createdAt,
textbooks.updatedAt
)
.orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade))
return rows.map((r) => ({
id: r.id,
title: r.title,
subject: r.subject,
grade: r.grade,
publisher: r.publisher,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
_count: { chapters: Number(r.chaptersCount ?? 0) },
}))
}
)
// ---------------------------------------------------------------------------
// Cross-module query interfaces — read-only access for other modules
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,95 @@
import { describe, it, expect } from "vitest"
import type { KnowledgePoint } from "./types"
import { computeGraphLayout, NODE_WIDTH, NODE_HEIGHT, GAP_X, GAP_Y } from "./graph-layout"
describe("textbooks/graph-layout", () => {
const makeKp = (id: string, parentId: string | null = null): KnowledgePoint => ({
id,
name: `KP-${id}`,
chapterId: "c1",
parentId,
level: 1,
order: 0,
})
describe("computeGraphLayout", () => {
it("should return empty layout for empty input", () => {
const layout = computeGraphLayout([])
expect(layout.nodes).toEqual([])
expect(layout.edges).toEqual([])
expect(layout.width).toBe(0)
expect(layout.height).toBe(0)
})
it("should place single root node", () => {
const layout = computeGraphLayout([makeKp("1")])
expect(layout.nodes).toHaveLength(1)
expect(layout.nodes[0].x).toBe(GAP_X)
expect(layout.nodes[0].y).toBe(GAP_Y)
expect(layout.edges).toHaveLength(0)
})
it("should compute parent-child layout with edge", () => {
const layout = computeGraphLayout([makeKp("1"), makeKp("2", "1")])
expect(layout.nodes).toHaveLength(2)
expect(layout.edges).toHaveLength(1)
expect(layout.edges[0].id).toBe("1-2")
})
it("should place children at lower level (higher y)", () => {
const layout = computeGraphLayout([makeKp("1"), makeKp("2", "1")])
const root = layout.nodes.find((n) => n.id === "1")
const child = layout.nodes.find((n) => n.id === "2")
expect(child!.y).toBeGreaterThan(root!.y)
})
it("should handle multiple roots", () => {
const layout = computeGraphLayout([makeKp("1"), makeKp("2")])
expect(layout.nodes).toHaveLength(2)
expect(layout.edges).toHaveLength(0)
const n1 = layout.nodes.find((n) => n.id === "1")
const n2 = layout.nodes.find((n) => n.id === "2")
expect(n2!.x).toBeGreaterThan(n1!.x)
})
it("should handle circular references gracefully (no infinite loop)", () => {
// a → b → a 循环
const kps = [makeKp("a", "b"), makeKp("b", "a")]
const layout = computeGraphLayout(kps)
expect(layout.nodes).toHaveLength(2)
})
it("should handle all nodes referencing non-existent parent", () => {
const kps = [makeKp("1", "nonexistent"), makeKp("2", "nonexistent")]
const layout = computeGraphLayout(kps)
expect(layout.nodes).toHaveLength(2)
// 全部作为根节点处理
expect(layout.edges).toHaveLength(0)
})
it("should compute width based on max nodes per level", () => {
const kps = [
makeKp("1"),
makeKp("2"),
makeKp("3"),
makeKp("4", "1"),
]
const layout = computeGraphLayout(kps)
// 第 0 层有 3 个节点,是最大层
const expectedWidth = 3 * (NODE_WIDTH + GAP_X) + GAP_X
expect(layout.width).toBe(expectedWidth)
})
it("should compute height based on level count", () => {
const kps = [
makeKp("1"),
makeKp("2", "1"),
makeKp("3", "2"),
]
const layout = computeGraphLayout(kps)
// 3 层
const expectedHeight = 3 * (NODE_HEIGHT + GAP_Y) + GAP_Y
expect(layout.height).toBe(expectedHeight)
})
})
})

View File

@@ -0,0 +1,141 @@
/**
* 知识图谱布局纯函数。
*
* 从 knowledge-graph.tsx 抽离,便于单元测试。
*/
import type { KnowledgePoint } from "./types"
export interface GraphNode extends KnowledgePoint {
x: number
y: number
}
export interface GraphEdge {
id: string
x1: number
y1: number
x2: number
y2: number
}
export interface GraphLayout {
nodes: GraphNode[]
edges: GraphEdge[]
width: number
height: number
}
/** 节点尺寸常量 */
export const NODE_WIDTH = 160
export const NODE_HEIGHT = 52
export const GAP_X = 40
export const GAP_Y = 90
/**
* 计算知识图谱的分层布局。
*
* 算法:
* 1. 根据 parentId 构建父子关系
* 2. BFS 计算每个节点的层级level
* 3. 同层节点按出现顺序水平排列
* 4. 生成节点坐标和边坐标
*
* @param knowledgePoints 知识点列表
* @returns 图布局(节点带坐标、边、总宽高)
*/
export 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 }))
// 容错:如果没有任何根节点(全部循环引用),把所有节点放第 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 })
}
}
// 容错:处理孤立节点(未在 BFS 中访问到)
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 maxCount = Math.max(...levels.map((l) => l.length), 1)
const width = maxCount * (NODE_WIDTH + GAP_X) + GAP_X
const height = levels.length * (NODE_HEIGHT + GAP_Y) + GAP_Y
const positions = new Map<string, { x: number; y: number }>()
levels.forEach((ids, level) => {
ids.forEach((id, index) => {
const x = GAP_X + index * (NODE_WIDTH + GAP_X)
const y = GAP_Y + level * (NODE_HEIGHT + GAP_Y)
positions.set(id, { x, y })
})
})
const nodes = knowledgePoints.map((kp) => {
const pos = positions.get(kp.id) ?? { x: GAP_X, y: GAP_Y }
return { ...kp, x: pos.x, y: pos.y }
})
const edges = knowledgePoints
.filter((kp) => kp.parentId && positions.has(kp.parentId))
.map((kp) => {
const parentId = kp.parentId as string
const parentPos = positions.get(parentId)
const childPos = positions.get(kp.id)
// 类型守卫:两个位置都必须存在(已在 filter 中保证,但 TS 需要 narrowing
if (!parentPos || !childPos) {
return null
}
return {
id: `${parentId}-${kp.id}`,
x1: parentPos.x + NODE_WIDTH / 2,
y1: parentPos.y + NODE_HEIGHT,
x2: childPos.x + NODE_WIDTH / 2,
y2: childPos.y,
}
})
.filter((e): e is GraphEdge => e !== null)
return { nodes, edges, width, height }
}

View File

@@ -1,6 +1,7 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import type { KnowledgePoint } from "../types"
@@ -18,6 +19,7 @@ export function useKnowledgePointActions(
setHighlightedKpId: (id: string | null) => void,
onKpCreated?: () => void,
) {
const t = useTranslations("textbooks")
const [editingKp, setEditingKp] = useState<KnowledgePoint | null>(null)
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
@@ -37,16 +39,16 @@ export function useKnowledgePointActions(
)
if (result.success) {
toast.success("知识点已创建")
toast.success(t("action.kpCreateSuccess"))
onKpCreated?.()
window.getSelection()?.removeAllRanges()
return true
} else {
toast.error(result.message || "创建知识点失败")
toast.error(result.message || t("action.kpCreateFailed"))
return false
}
} catch {
toast.error("发生错误")
toast.error(t("action.errorOccurred"))
return false
}
}
@@ -75,7 +77,7 @@ export function useKnowledgePointActions(
toast.error(result.message)
}
} catch {
toast.error("删除失败")
toast.error(t("action.deleteFailed"))
} finally {
setPendingDeleteKpId(null)
}
@@ -95,7 +97,7 @@ export function useKnowledgePointActions(
toast.error(result.message)
}
} catch {
toast.error("更新失败")
toast.error(t("action.updateFailedGeneric"))
} finally {
setIsUpdatingKp(false)
}

View File

@@ -0,0 +1,181 @@
import { describe, it, expect } from "vitest"
import type { Chapter, KnowledgePoint } from "./types"
import {
buildChapterTree,
buildChapterIndex,
findChapterParent,
filterKnowledgePointsByChapter,
normalizeOptional,
sortChapters,
} from "./utils"
// 测试辅助:构造最小合法 Chapter补齐 createdAt/updatedAt 等必填字段)
const makeChapter = (over: Partial<Chapter> & Pick<Chapter, "id" | "title" | "textbookId">): Chapter => ({
order: 0,
parentId: null,
content: null,
createdAt: new Date(0),
updatedAt: new Date(0),
...over,
})
describe("textbooks/utils", () => {
describe("sortChapters", () => {
it("should sort by order ascending", () => {
const a = makeChapter({ id: "1", title: "A", order: 2, textbookId: "t1" })
const b = makeChapter({ id: "2", title: "B", order: 1, textbookId: "t1" })
expect(sortChapters(a, b)).toBeGreaterThan(0)
})
it("should fall back to title localeCompare when order equal", () => {
const a = makeChapter({ id: "1", title: "Banana", order: 1, textbookId: "t1" })
const b = makeChapter({ id: "2", title: "Apple", order: 1, textbookId: "t1" })
expect(sortChapters(a, b)).toBeGreaterThan(0)
})
it("should treat null order as 0", () => {
const a = makeChapter({ id: "1", title: "A", order: null, textbookId: "t1" })
const b = makeChapter({ id: "2", title: "B", order: 5, textbookId: "t1" })
expect(sortChapters(a, b)).toBeLessThan(0)
})
})
describe("buildChapterTree", () => {
it("should return empty array for empty input", () => {
expect(buildChapterTree([])).toEqual([])
})
it("should build single root", () => {
const rows: Chapter[] = [
makeChapter({ id: "1", title: "Root", order: 0, textbookId: "t1" }),
]
const tree = buildChapterTree(rows)
expect(tree).toHaveLength(1)
expect(tree[0].id).toBe("1")
expect(tree[0].children).toEqual([])
})
it("should build nested tree", () => {
const rows: Chapter[] = [
makeChapter({ id: "1", title: "Root", order: 0, textbookId: "t1" }),
makeChapter({ id: "2", title: "Child 1", order: 0, textbookId: "t1", parentId: "1" }),
makeChapter({ id: "3", title: "Child 2", order: 1, textbookId: "t1", parentId: "1" }),
makeChapter({ id: "4", title: "Grandchild", order: 0, textbookId: "t1", parentId: "2" }),
]
const tree = buildChapterTree(rows)
expect(tree).toHaveLength(1)
expect(tree[0].children).toHaveLength(2)
expect(tree[0].children[0].children).toHaveLength(1)
expect(tree[0].children[0].children[0].id).toBe("4")
})
it("should handle orphan nodes (parentId points to non-existent)", () => {
const rows: Chapter[] = [
makeChapter({ id: "1", title: "Orphan", order: 0, textbookId: "t1", parentId: "nonexistent" }),
]
const tree = buildChapterTree(rows)
expect(tree).toHaveLength(1)
expect(tree[0].id).toBe("1")
})
it("should sort children by order", () => {
const rows: Chapter[] = [
makeChapter({ id: "1", title: "Root", order: 0, textbookId: "t1" }),
makeChapter({ id: "2", title: "B", order: 2, textbookId: "t1", parentId: "1" }),
makeChapter({ id: "3", title: "A", order: 1, textbookId: "t1", parentId: "1" }),
]
const tree = buildChapterTree(rows)
expect(tree[0].children[0].id).toBe("3")
expect(tree[0].children[1].id).toBe("2")
})
})
describe("buildChapterIndex", () => {
it("should index all nodes including nested", () => {
const chapters: Chapter[] = [
makeChapter({
id: "1",
title: "Root",
order: 0,
textbookId: "t1",
children: [
makeChapter({ id: "2", title: "Child", order: 0, textbookId: "t1", parentId: "1", children: [] }),
],
}),
]
const index = buildChapterIndex(chapters)
expect(index.size).toBe(2)
expect(index.get("1")?.title).toBe("Root")
expect(index.get("2")?.title).toBe("Child")
})
it("should return empty map for empty input", () => {
expect(buildChapterIndex([]).size).toBe(0)
})
})
describe("findChapterParent", () => {
it("should find direct parent", () => {
const chapters: Chapter[] = [
makeChapter({
id: "1",
title: "Root",
order: 0,
textbookId: "t1",
children: [
makeChapter({ id: "2", title: "Child", order: 0, textbookId: "t1", parentId: "1", children: [] }),
],
}),
]
const parent = findChapterParent(chapters, "2")
expect(parent?.id).toBe("1")
})
it("should return null for root node", () => {
const chapters: Chapter[] = [
makeChapter({ id: "1", title: "Root", order: 0, textbookId: "t1", children: [] }),
]
expect(findChapterParent(chapters, "1")).toBeNull()
})
it("should return null for non-existent id", () => {
expect(findChapterParent([], "nonexistent")).toBeNull()
})
})
describe("filterKnowledgePointsByChapter", () => {
it("should return empty for null chapterId", () => {
expect(filterKnowledgePointsByChapter([], null)).toEqual([])
})
it("should filter by chapterId", () => {
const kps: KnowledgePoint[] = [
{ id: "1", name: "KP1", chapterId: "c1", level: 1, order: 0 },
{ id: "2", name: "KP2", chapterId: "c2", level: 1, order: 0 },
{ id: "3", name: "KP3", chapterId: "c1", level: 2, order: 0 },
]
const result = filterKnowledgePointsByChapter(kps, "c1")
expect(result).toHaveLength(2)
expect(result[0].id).toBe("1")
})
})
describe("normalizeOptional", () => {
it("should return null for empty string", () => {
expect(normalizeOptional("")).toBeNull()
})
it("should return null for whitespace-only string", () => {
expect(normalizeOptional(" ")).toBeNull()
})
it("should return null for null/undefined", () => {
expect(normalizeOptional(null)).toBeNull()
expect(normalizeOptional(undefined)).toBeNull()
})
it("should trim and return non-empty string", () => {
expect(normalizeOptional(" hello ")).toBe("hello")
})
})
})

View File

@@ -0,0 +1,129 @@
/**
* 教材模块纯逻辑工具函数。
*
* 从 data-access.ts 和组件中抽离的纯函数,便于单元测试。
*/
import type { Chapter, KnowledgePoint } from "./types"
/**
* 章节排序比较器:先按 order 升序order 相同按 title 字典序。
*/
export function sortChapters(a: Chapter, b: Chapter): number {
const ao = a.order ?? 0
const bo = b.order ?? 0
if (ao !== bo) return ao - bo
return a.title.localeCompare(b.title)
}
/**
* 将扁平章节列表构建为树形结构。
*
* 算法:
* 1. 第一遍遍历:所有章节放入 Map并初始化 children: []
* 2. 第二遍遍历:根据 parentId 挂载到父节点的 children无 parentId 或父节点不存在则作为根
* 3. 递归排序
*
* 时间复杂度 O(n log n),空间复杂度 O(n)。
*
* @param rows 扁平章节列表(顺序无关)
* @returns 树形根节点数组(已排序),每个节点的 children 均已初始化为非空数组
*/
export function buildChapterTree(rows: Chapter[]): ChapterTreeNode[] {
type ChapterNode = ChapterTreeNode
const byId = new Map<string, ChapterNode>()
for (const ch of rows) {
byId.set(ch.id, { ...ch, children: [] })
}
const roots: ChapterNode[] = []
for (const ch of byId.values()) {
const pid = ch.parentId
if (pid) {
const parent = byId.get(pid)
if (parent) {
parent.children.push(ch)
} else {
roots.push(ch)
}
} else {
roots.push(ch)
}
}
const sortRecursive = (nodes: ChapterNode[]) => {
nodes.sort(sortChapters)
for (const n of nodes) {
sortRecursive(n.children)
}
}
sortRecursive(roots)
return roots
}
/**
* 章节树节点:在 Chapter 基础上强制 children 为非空数组。
*/
export type ChapterTreeNode = Chapter & { children: ChapterTreeNode[] }
/**
* 构建章节索引 Mapid → Chapter用于快速查找。
*
* 递归遍历整棵树,包括所有子节点。
*/
export function buildChapterIndex(chapters: Chapter[]): Map<string, 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
}
/**
* 在章节树中查找某个章节的父节点。
*
* @returns 父节点;如果是根节点或未找到,返回 null。
*/
export function findChapterParent(
items: Chapter[],
id: string
): Chapter | null {
for (const item of items) {
if (item.children?.some((c) => c.id === id)) return item
if (item.children) {
const found = findChapterParent(item.children, id)
if (found) return found
}
}
return null
}
/**
* 过滤出指定章节的知识点。
*/
export function filterKnowledgePointsByChapter(
knowledgePoints: KnowledgePoint[],
chapterId: string | null
): KnowledgePoint[] {
if (!chapterId) return []
return knowledgePoints.filter((kp) => kp.chapterId === chapterId)
}
/**
* 规范化可选字符串字段trim 后若为空返回 null。
*/
export function normalizeOptional(
v: string | null | undefined
): string | null {
const trimmed = v?.trim()
if (!trimmed) return null
return trimmed
}

View File

@@ -212,7 +212,7 @@ export const getUserWithRole = cache(
* session and verifying the "student" role via JOIN users + usersToRoles + roles.
* Returns null if not authenticated or the user does not have the student role.
*/
export const getCurrentStudentUser = cache(async (): Promise<{ id: string; name: string } | null> => {
export const getCurrentStudentUser = cache(async (): Promise<{ id: string; name: string; gradeId: string | null } | null> => {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) return null
@@ -220,7 +220,15 @@ export const getCurrentStudentUser = cache(async (): Promise<{ id: string; name:
const student = await getUserWithRole(userId, "student")
if (!student) return null
return { id: student.id, name: student.name || "Student" }
// 查询学生的 gradeId关联 grades 表)
const [userRow] = await db
.select({ gradeId: users.gradeId })
.from(users)
.where(eq(users.id, student.id))
.limit(1)
return { id: student.id, name: student.name || "Student", gradeId: userRow?.gradeId ?? null }
})
/** Returns a map of userId -> { name, email } for the given user ids. */