fix(textbooks): 规范核查修复 — 安全漏洞+功能缺失+i18n+类型安全
安全:createPrerequisiteAction 补充 prerequisiteKpId 归属校验;deletePrerequisiteAction 补充双知识点归属校验,防止跨教材越权。 功能:实现图谱添加/删除前置依赖(Dialog + Select 选择知识点 + 调用 Server Action + 自动刷新图谱),替换原 no-op 回调。 i18n:修复 8 处硬编码英文字符串(textbook-reader/chapter-sidebar-list/textbook-card/textbook-form-dialog/textbook-settings-dialog/create-chapter-dialog/teacher-textbook-reader),新增 saveFailed/createFailed/updateFailed/deleteFailed/questionCreatorDefaultContent 等 key。 类型安全:graph-prerequisite-edge.tsx 使用 GraphEdgeData 类型经 unknown 安全转换,替代裸 as 断言。 规范:analytics.tsx 移动 use client 指令到文件第一行;同步架构文档 005 JSON 类型定义(GraphNodeData/GraphEdgeData/MasteryLevel)。 验证:教材模块 lint 零错误、tsc 零错误、193 个单元测试全部通过。
This commit is contained in:
@@ -4508,8 +4508,8 @@
|
||||
{
|
||||
"name": "GraphNodeData",
|
||||
"type": "interface",
|
||||
"definition": "{ id, name, chapterId, chapterTitle, level, questionCount, mastery?: MasteryInfo, isHighlighted?, isRelated?, searchText? } & Record<string, unknown>",
|
||||
"purpose": "Task 9 新增:React Flow 自定义节点数据(含索引签名满足 React Flow 约束)",
|
||||
"definition": "{ kp: KpWithRelations, mastery: MasteryInfo | null, viewMode: GraphViewMode, isSelected: boolean, isHighlighted: boolean, chapterColor: string }",
|
||||
"purpose": "Task 9 新增:React Flow 自定义节点数据(传递给 graph-kp-node 组件)",
|
||||
"usedBy": [
|
||||
"components/graph-kp-node.tsx",
|
||||
"components/knowledge-graph.tsx"
|
||||
@@ -4518,7 +4518,7 @@
|
||||
{
|
||||
"name": "GraphEdgeData",
|
||||
"type": "interface",
|
||||
"definition": "{ edgeType: 'parent-child' | 'prerequisite', isHighlighted? } & Record<string, unknown>",
|
||||
"definition": "{ edgeType: 'parent' | 'prerequisite', isHighlighted: boolean }",
|
||||
"purpose": "Task 9 新增:React Flow 自定义边数据",
|
||||
"usedBy": [
|
||||
"components/graph-prerequisite-edge.tsx",
|
||||
@@ -4528,8 +4528,8 @@
|
||||
{
|
||||
"name": "MasteryLevel",
|
||||
"type": "type",
|
||||
"definition": "'unknown' | 'weak' | 'fair' | 'strong'",
|
||||
"purpose": "Task 9 新增:掌握度等级(红/黄/灰)",
|
||||
"definition": "'low' | 'medium' | 'high' | 'unassessed'",
|
||||
"purpose": "Task 9 新增:掌握度等级(红/黄/绿/灰)",
|
||||
"usedBy": [
|
||||
"components/graph-kp-node.tsx"
|
||||
]
|
||||
@@ -13694,8 +13694,12 @@
|
||||
"algorithms": {
|
||||
"sm2": {
|
||||
"name": "SM-2 间隔重复算法(简化版)",
|
||||
"description": "4 级评级(again/hard/good/easy),间隔 1/2/4/7 天起,指数增长(×1.2/×1.5/×2),连续 3 次答对标记为已掌握",
|
||||
"functions": ["calculateNewInterval", "calculateNewMastery", "deriveStatus", "calculateNextReviewAt"]
|
||||
"file": "sm2-algorithm.ts",
|
||||
"description": "独立纯函数模块,4 级评级(again/hard/good/easy),间隔 1/2/4/7 天起,指数增长(×1.2/×1.5/×2),连续 3 次答对标记为已掌握。支持时间注入便于测试。",
|
||||
"functions": ["calculateNewInterval", "calculateNewMastery", "deriveStatus", "calculateNextReviewAt", "calculateNewCorrectStreak", "calculateSm2Result"],
|
||||
"constants": ["REVIEW_INTERVALS", "INTERVAL_MULTIPLIERS", "MAX_MASTERY_LEVEL", "MIN_MASTERY_LEVEL", "MASTERED_REQUIRED_STREAK", "MASTERED_REQUIRED_MASTERY"],
|
||||
"testFile": "sm2-algorithm.test.ts",
|
||||
"testCount": 39
|
||||
}
|
||||
},
|
||||
"dataScope": {
|
||||
|
||||
@@ -426,7 +426,7 @@ export async function createPrerequisiteAction(
|
||||
return { success: false, message: t("invalidInput") };
|
||||
}
|
||||
|
||||
// 归属校验
|
||||
// 归属校验:两个知识点都必须属于当前教材,防止跨教材越权
|
||||
const kpBelongs = await verifyKnowledgePointBelongsToTextbook(
|
||||
parsed.data.knowledgePointId,
|
||||
textbookId,
|
||||
@@ -434,6 +434,13 @@ export async function createPrerequisiteAction(
|
||||
if (!kpBelongs) {
|
||||
return { success: false, message: t("kpNotBelong") };
|
||||
}
|
||||
const prereqBelongs = await verifyKnowledgePointBelongsToTextbook(
|
||||
parsed.data.prerequisiteKpId,
|
||||
textbookId,
|
||||
);
|
||||
if (!prereqBelongs) {
|
||||
return { success: false, message: t("kpNotBelong") };
|
||||
}
|
||||
|
||||
// 循环检测
|
||||
const existingEdges = await getPrerequisiteEdgesForTextbook(textbookId);
|
||||
@@ -475,6 +482,22 @@ export async function deletePrerequisiteAction(
|
||||
return { success: false, message: t("invalidInput") };
|
||||
}
|
||||
|
||||
// 归属校验:防止跨教材越权删除前置依赖
|
||||
const kpBelongs = await verifyKnowledgePointBelongsToTextbook(
|
||||
parsed.data.knowledgePointId,
|
||||
textbookId,
|
||||
);
|
||||
if (!kpBelongs) {
|
||||
return { success: false, message: t("kpNotBelong") };
|
||||
}
|
||||
const prereqBelongs = await verifyKnowledgePointBelongsToTextbook(
|
||||
parsed.data.prerequisiteKpId,
|
||||
textbookId,
|
||||
);
|
||||
if (!prereqBelongs) {
|
||||
return { success: false, message: t("kpNotBelong") };
|
||||
}
|
||||
|
||||
await deletePrerequisite(parsed.data);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: t("prerequisiteDeleted") };
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* 教材模块埋点接口(预留)。
|
||||
*
|
||||
@@ -5,8 +7,6 @@
|
||||
* 通过 React Context 注入,组件内调用 `useTextbookAnalytics()` 获取。
|
||||
*/
|
||||
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react"
|
||||
|
||||
export interface TextbookAnalytics {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import { ChevronRight, FileText, Folder, Plus, Trash2, GripVertical } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core"
|
||||
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { CreateChapterDialog } from "./create-chapter-dialog"
|
||||
import { deleteChapterAction, reorderChaptersAction } from "../actions"
|
||||
import { findChapterParent } from "../utils"
|
||||
|
||||
interface SortableChapterItemProps {
|
||||
chapter: Chapter
|
||||
@@ -39,6 +41,7 @@ interface SortableChapterItemProps {
|
||||
}
|
||||
|
||||
function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: SortableChapterItemProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
const [isOpen, setIsOpen] = useState(level === 0)
|
||||
const hasChildren = chapter.children && chapter.children.length > 0
|
||||
const isSelected = chapter.id === selectedId
|
||||
@@ -68,7 +71,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
||||
isDragging && "opacity-50"
|
||||
)}>
|
||||
{canEdit && (
|
||||
<div {...attributes} {...listeners} className="mr-1 cursor-grab opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-muted rounded">
|
||||
<div {...attributes} {...listeners} className="mr-1 cursor-grab opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-muted rounded" aria-label={t("dialog.chapter.dragHandle")}>
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
@@ -87,7 +90,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">Toggle</span>
|
||||
<span className="sr-only">{t("dialog.chapter.toggle")}</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
) : (
|
||||
@@ -115,7 +118,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
||||
e.stopPropagation()
|
||||
onCreateSub(chapter.id)
|
||||
}}
|
||||
title="Add Subchapter"
|
||||
aria-label={t("dialog.chapter.addSubchapter")}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -127,7 +130,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
||||
e.stopPropagation()
|
||||
onDelete(chapter)
|
||||
}}
|
||||
title="Delete Chapter"
|
||||
aria-label={t("dialog.chapter.delete")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -193,6 +196,7 @@ interface ChapterSidebarListProps {
|
||||
}
|
||||
|
||||
export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId, canEdit = true }: ChapterSidebarListProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [createParentId, setCreateParentId] = useState<string | undefined>(undefined)
|
||||
|
||||
@@ -209,32 +213,8 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
// Find which list the items belong to
|
||||
// Since we only support sibling reordering for now, we assume active and over are in the same list
|
||||
// We need a helper to find the parent of an item in the tree
|
||||
const findParent = (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 = findParent(item.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const activeParent = findParent(chapters, active.id as string)
|
||||
|
||||
// If parents don't match (and neither is root), we can't reorder easily in this simplified version
|
||||
// But actually, we need to check if they are in the same list.
|
||||
// If both are root items (activeParent is null), they are siblings.
|
||||
|
||||
// Simplified logic: We trust dnd-kit's SortableContext to only allow valid drops if we restricted it?
|
||||
// No, dnd-kit allows dropping anywhere by default unless restricted.
|
||||
|
||||
// We need to find the list that contains the 'active' item
|
||||
// And the list that contains the 'over' item.
|
||||
// If they are the same list, we reorder.
|
||||
// v2-P2: 复用 utils.ts 的 findChapterParent,消除重复代码
|
||||
const activeParent = findChapterParent(chapters, String(active.id))
|
||||
|
||||
let activeList: Chapter[] = chapters
|
||||
let activeParentId: string | null = null
|
||||
@@ -254,28 +234,43 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
||||
if (activeList.some(c => c.id === over.id)) {
|
||||
const newIndex = activeList.findIndex((item) => item.id === over.id)
|
||||
|
||||
await reorderChaptersAction(active.id as string, newIndex, activeParentId, textbookId)
|
||||
toast.success("Order updated")
|
||||
try {
|
||||
const result = await reorderChaptersAction(String(active.id), newIndex, activeParentId, textbookId)
|
||||
if (result.success) {
|
||||
toast.success(t("dialog.chapter.orderUpdated"))
|
||||
} else {
|
||||
toast.error(result.message || t("dialog.chapter.orderUpdated"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to reorder chapters", e)
|
||||
toast.error(t("dialog.chapter.orderUpdated"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
setIsDeleting(true)
|
||||
const res = await deleteChapterAction(deleteTarget.id, textbookId)
|
||||
setIsDeleting(false)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setShowDeleteDialog(false)
|
||||
setDeleteTarget(null)
|
||||
} else {
|
||||
toast.error(res.message)
|
||||
try {
|
||||
const res = await deleteChapterAction(deleteTarget.id, textbookId)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setShowDeleteDialog(false)
|
||||
setDeleteTarget(null)
|
||||
} else {
|
||||
toast.error(res.message)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to delete chapter", e)
|
||||
toast.error(t("reader.deleteFailed"))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteRequest = (chapter: Chapter) => {
|
||||
if (chapter.children && chapter.children.length > 0) {
|
||||
toast.error("Cannot delete chapter with subchapters")
|
||||
toast.error(t("dialog.chapter.cannotDeleteWithSubchapters"))
|
||||
return
|
||||
}
|
||||
setDeleteTarget(chapter)
|
||||
@@ -313,7 +308,7 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
||||
textbookId={textbookId}
|
||||
onDelete={handleDeleteRequest}
|
||||
onCreateSub={handleCreateSubRequest}
|
||||
canEdit={true}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
|
||||
<CreateChapterDialog
|
||||
@@ -329,16 +324,15 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Chapter?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("dialog.chapter.deleteTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete <span className="font-medium text-foreground">{deleteTarget?.title}</span>.
|
||||
This action cannot be undone.
|
||||
{t("dialog.chapter.deleteDesc", { title: deleteTarget?.title ?? "" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("dialog.chapter.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={isDeleting}>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
{isDeleting ? t("dialog.chapter.deleting") : t("dialog.chapter.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -50,12 +50,17 @@ export function CreateChapterDialog({
|
||||
const setOpen = onOpenChange ?? setUncontrolledOpen
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
const result = await createChapterAction(textbookId, parentId, null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
try {
|
||||
const result = await createChapterAction(textbookId, parentId, null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to create chapter", e)
|
||||
toast.error(t("reader.createFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { memo } from "react"
|
||||
import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { GraphEdgeData } from "../types"
|
||||
|
||||
function GraphPrerequisiteEdgeComponent({
|
||||
id,
|
||||
@@ -23,7 +24,9 @@ function GraphPrerequisiteEdgeComponent({
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
const isHighlighted = (data as { isHighlighted?: boolean } | undefined)?.isHighlighted ?? false
|
||||
// EdgeProps.data 类型为 Record<string, unknown>,经 unknown 安全转换读取 GraphEdgeData
|
||||
const edgeData = data as unknown as GraphEdgeData | undefined
|
||||
const isHighlighted = edgeData?.isHighlighted ?? false
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
|
||||
@@ -14,13 +14,34 @@ import {
|
||||
} from "@xyflow/react"
|
||||
import "@xyflow/react/dist/style.css"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Share2 } from "lucide-react"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import type { GraphViewMode, GraphNodeData } from "../types"
|
||||
import { computeGraphLayout } from "../graph-layout"
|
||||
import { useGraphData } from "../hooks/use-graph-data"
|
||||
import {
|
||||
createPrerequisiteAction,
|
||||
deletePrerequisiteAction,
|
||||
} from "../actions"
|
||||
import { GraphKpNode } from "./graph-kp-node"
|
||||
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
|
||||
import { GraphToolbar } from "./graph-toolbar"
|
||||
@@ -51,8 +72,12 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
||||
const [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [selectedKpId, setSelectedKpId] = useState<string | null>(null)
|
||||
// 添加前置依赖对话框状态
|
||||
const [addPrereqOpen, setAddPrereqOpen] = useState(false)
|
||||
const [newPrereqId, setNewPrereqId] = useState<string>("")
|
||||
const [isSavingPrereq, setIsSavingPrereq] = useState(false)
|
||||
|
||||
const { data, isLoading, error } = useGraphData(textbookId, viewMode)
|
||||
const { data, isLoading, error, reload } = useGraphData(textbookId, viewMode)
|
||||
|
||||
const availableViewModes: GraphViewMode[] = isTeacher
|
||||
? ["structure", "class-mastery"]
|
||||
@@ -173,6 +198,51 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
||||
reactFlow.fitView({ nodes: [{ id: kpId }], duration: 300 })
|
||||
}, [reactFlow])
|
||||
|
||||
// 添加前置依赖
|
||||
const handleAddPrerequisite = useCallback(async () => {
|
||||
if (!selectedKpId || !newPrereqId || !textbookId) return
|
||||
setIsSavingPrereq(true)
|
||||
const formData = new FormData()
|
||||
formData.set("knowledgePointId", selectedKpId)
|
||||
formData.set("prerequisiteKpId", newPrereqId)
|
||||
formData.set("textbookId", textbookId)
|
||||
const result = await createPrerequisiteAction(formData)
|
||||
setIsSavingPrereq(false)
|
||||
if (result.success) {
|
||||
toast.success(t("graph.detail.prerequisiteAdded"))
|
||||
setAddPrereqOpen(false)
|
||||
setNewPrereqId("")
|
||||
reload()
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
}, [selectedKpId, newPrereqId, textbookId, t, reload])
|
||||
|
||||
// 删除前置依赖
|
||||
const handleRemovePrerequisite = useCallback(async (prereqId: string) => {
|
||||
if (!selectedKpId || !textbookId) return
|
||||
const formData = new FormData()
|
||||
formData.set("knowledgePointId", selectedKpId)
|
||||
formData.set("prerequisiteKpId", prereqId)
|
||||
formData.set("textbookId", textbookId)
|
||||
const result = await deletePrerequisiteAction(formData)
|
||||
if (result.success) {
|
||||
toast.success(t("graph.detail.prerequisiteRemoved"))
|
||||
reload()
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
}, [selectedKpId, textbookId, t, reload])
|
||||
|
||||
// 可选的前置知识点(排除自身和已是前置的)
|
||||
const availablePrereqs = useMemo(() => {
|
||||
if (!data || !selectedKpId) return []
|
||||
const existing = new Set(data.knowledgePoints.find((kp) => kp.id === selectedKpId)?.prerequisiteIds ?? [])
|
||||
return data.knowledgePoints.filter((kp) =>
|
||||
kp.id !== selectedKpId && !existing.has(kp.id),
|
||||
)
|
||||
}, [data, selectedKpId])
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
@@ -255,15 +325,44 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
||||
textbookId={textbookId}
|
||||
onClose={() => setSelectedKpId(null)}
|
||||
onJumpToKp={onJumpToKp}
|
||||
onAddPrerequisite={() => {
|
||||
// 后续迭代:打开添加前置对话框
|
||||
}}
|
||||
onRemovePrerequisite={(_prereqId: string) => {
|
||||
// 后续迭代:调用 deletePrerequisiteAction
|
||||
}}
|
||||
onAddPrerequisite={() => setAddPrereqOpen(true)}
|
||||
onRemovePrerequisite={handleRemovePrerequisite}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加前置依赖对话框 */}
|
||||
<Dialog open={addPrereqOpen} onOpenChange={setAddPrereqOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("graph.detail.addPrerequisiteTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("graph.detail.addPrerequisiteDesc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Select value={newPrereqId} onValueChange={setNewPrereqId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("graph.detail.selectPrerequisite")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availablePrereqs.map((kp) => (
|
||||
<SelectItem key={kp.id} value={kp.id}>
|
||||
{kp.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddPrereqOpen(false)}>
|
||||
{t("graph.detail.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddPrerequisite}
|
||||
disabled={!newPrereqId || isSavingPrereq}
|
||||
>
|
||||
{isSavingPrereq ? t("graph.detail.saving") : t("graph.detail.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
50
src/modules/textbooks/components/teacher-textbook-reader.tsx
Normal file
50
src/modules/textbooks/components/teacher-textbook-reader.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { TextbookReader, type TextbookReaderProps } from "./textbook-reader"
|
||||
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
|
||||
import type { KnowledgePoint } from "../types"
|
||||
|
||||
/**
|
||||
* 教师端 TextbookReader 包装组件。
|
||||
*
|
||||
* 教师详情页是 Server Component,不能直接向 Client Component(TextbookReader)
|
||||
* 传递函数 prop(renderQuestionCreator)。此包装组件在客户端层组装
|
||||
* renderQuestionCreator,避免违反 Next.js App Router 的 Server→Client 序列化约束。
|
||||
*/
|
||||
export function TeacherTextbookReader({
|
||||
chapters,
|
||||
textbookId,
|
||||
}: {
|
||||
chapters: TextbookReaderProps["chapters"]
|
||||
textbookId: string
|
||||
}) {
|
||||
const t = useTranslations("textbooks")
|
||||
const renderQuestionCreator: TextbookReaderProps["renderQuestionCreator"] = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetKp,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
targetKp: KnowledgePoint | null
|
||||
}): ReactNode => (
|
||||
<CreateQuestionDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
defaultKnowledgePointIds={targetKp ? [targetKp.id] : []}
|
||||
defaultContent={targetKp ? t("reader.questionCreatorDefaultContent", { name: targetKp.name }) : ""}
|
||||
defaultType="text"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<TextbookReader
|
||||
key={textbookId}
|
||||
chapters={chapters}
|
||||
textbookId={textbookId}
|
||||
renderQuestionCreator={renderQuestionCreator}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Book, MoreVertical, Edit, Trash2, BookOpen, GraduationCap, Building2 } from "lucide-react"
|
||||
import {
|
||||
@@ -17,9 +19,21 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
import type { Textbook } from "../types"
|
||||
import { getSubjectColor } from "../constants"
|
||||
import { getSubjectColor, getSubjectLabelKey, getGradeLabelKey } from "../constants"
|
||||
import { deleteTextbookAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface TextbookCardProps {
|
||||
textbook: Textbook
|
||||
@@ -29,8 +43,30 @@ interface TextbookCardProps {
|
||||
|
||||
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
const router = useRouter()
|
||||
const base = hrefBase || "/teacher/textbooks"
|
||||
const colorClass = getSubjectColor(textbook.subject)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const result = await deleteTextbookAction(textbook.id)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to delete textbook", e)
|
||||
toast.error(t("reader.deleteFailed"))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -38,14 +74,14 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
|
||||
<div className={cn("relative h-32 w-full overflow-hidden p-5", colorClass)}>
|
||||
<div className="relative z-10 flex h-full flex-col justify-between">
|
||||
<Badge variant="secondary" className="w-fit bg-background/80 border border-border/60 shadow-sm">
|
||||
{textbook.subject}
|
||||
{t(`subject.${getSubjectLabelKey(textbook.subject)}`)}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-background text-foreground shadow-sm ring-1 ring-border/60">
|
||||
<Book className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="text-xs font-medium text-foreground/70">
|
||||
{textbook.grade || t("card.gradeNA")}
|
||||
{textbook.grade ? t(`grade.${getGradeLabelKey(textbook.grade)}`) : t("card.gradeNA")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,7 +99,7 @@ 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 || t("card.gradeNA")}</span>
|
||||
<span>{textbook.grade ? t(`grade.${getGradeLabelKey(textbook.grade)}`) : t("card.gradeNA")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
@@ -86,7 +122,7 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-muted-foreground/60 mr-2">
|
||||
<span className="text-xs text-muted-foreground/60 mr-2">
|
||||
{t("card.updated")} {formatDate(textbook.updatedAt)}
|
||||
</span>
|
||||
{!hideActions && (
|
||||
@@ -104,7 +140,10 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
|
||||
{t("card.editContent")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("card.delete")}
|
||||
</DropdownMenuItem>
|
||||
@@ -113,6 +152,30 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("dialog.settings.deleteConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("dialog.settings.deleteConfirmDesc")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>{t("dialog.knowledge.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? t("dialog.settings.processing") : t("dialog.settings.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,12 +42,17 @@ export function TextbookFormDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
const result = await createTextbookAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
try {
|
||||
const result = await createTextbookAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to create textbook", e)
|
||||
toast.error(t("reader.createFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ export function TextbookReader({
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to save chapter content", e)
|
||||
toast.error("Failed to save content")
|
||||
toast.error(t("reader.saveFailed"))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
@@ -52,13 +52,19 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
|
||||
|
||||
const handleUpdate = async (formData: FormData) => {
|
||||
setLoading(true)
|
||||
const result = await updateTextbookAction(textbook.id, null, formData)
|
||||
setLoading(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
try {
|
||||
const result = await updateTextbookAction(textbook.id, null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to update textbook", e)
|
||||
toast.error(t("reader.updateFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,14 +72,20 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
|
||||
const handleDelete = async () => {
|
||||
setDeleteDialogOpen(false)
|
||||
setLoading(true)
|
||||
const result = await deleteTextbookAction(textbook.id)
|
||||
try {
|
||||
const result = await deleteTextbookAction(textbook.id)
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
router.push("/teacher/textbooks")
|
||||
} else {
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
router.push("/teacher/textbooks")
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to delete textbook", e)
|
||||
toast.error(t("reader.deleteFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
toast.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,11 @@
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"saveFailed": "Failed to save",
|
||||
"createFailed": "Failed to create",
|
||||
"updateFailed": "Failed to update",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"questionCreatorDefaultContent": "Please explain the knowledge point: {name}",
|
||||
"addKnowledgePoint": "Add Knowledge Point",
|
||||
"clickToViewKp": "Click to view knowledge point details",
|
||||
"noChapters": "No chapters",
|
||||
@@ -228,12 +233,20 @@
|
||||
"viewAllQuestions": "View all questions",
|
||||
"editPrerequisite": "Edit prerequisites",
|
||||
"addPrerequisite": "Add prerequisite",
|
||||
"addPrerequisiteTitle": "Add Prerequisite Knowledge Point",
|
||||
"addPrerequisiteDesc": "Select a knowledge point as the prerequisite of the current one.",
|
||||
"selectPrerequisite": "Select a prerequisite knowledge point",
|
||||
"removePrerequisite": "Remove",
|
||||
"noPrerequisites": "No prerequisite knowledge points",
|
||||
"noSuccessors": "No successor knowledge points",
|
||||
"masteryNotAssessed": "Not assessed",
|
||||
"correctRate": "Correct rate",
|
||||
"totalQuestions": "Total questions"
|
||||
"totalQuestions": "Total questions",
|
||||
"prerequisiteAdded": "Prerequisite added",
|
||||
"prerequisiteRemoved": "Prerequisite removed",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"toolbar": {
|
||||
"search": "Search knowledge points",
|
||||
|
||||
@@ -48,6 +48,11 @@
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"saveFailed": "保存失败",
|
||||
"createFailed": "创建失败",
|
||||
"updateFailed": "更新失败",
|
||||
"deleteFailed": "删除失败",
|
||||
"questionCreatorDefaultContent": "请讲解知识点:{name}",
|
||||
"addKnowledgePoint": "添加知识点",
|
||||
"clickToViewKp": "点击查看知识点详情",
|
||||
"noChapters": "暂无章节",
|
||||
@@ -228,12 +233,20 @@
|
||||
"viewAllQuestions": "查看全部题目",
|
||||
"editPrerequisite": "编辑前置依赖",
|
||||
"addPrerequisite": "添加前置",
|
||||
"addPrerequisiteTitle": "添加前置知识点",
|
||||
"addPrerequisiteDesc": "选择一个知识点作为当前知识点的前置依赖。",
|
||||
"selectPrerequisite": "请选择前置知识点",
|
||||
"removePrerequisite": "移除",
|
||||
"noPrerequisites": "暂无前置知识点",
|
||||
"noSuccessors": "暂无后置知识点",
|
||||
"masteryNotAssessed": "未测评",
|
||||
"correctRate": "正确率",
|
||||
"totalQuestions": "总题数"
|
||||
"totalQuestions": "总题数",
|
||||
"prerequisiteAdded": "前置依赖已添加",
|
||||
"prerequisiteRemoved": "前置依赖已移除",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"saving": "保存中..."
|
||||
},
|
||||
"toolbar": {
|
||||
"search": "搜索知识点",
|
||||
|
||||
Reference in New Issue
Block a user