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",
|
"name": "GraphNodeData",
|
||||||
"type": "interface",
|
"type": "interface",
|
||||||
"definition": "{ id, name, chapterId, chapterTitle, level, questionCount, mastery?: MasteryInfo, isHighlighted?, isRelated?, searchText? } & Record<string, unknown>",
|
"definition": "{ kp: KpWithRelations, mastery: MasteryInfo | null, viewMode: GraphViewMode, isSelected: boolean, isHighlighted: boolean, chapterColor: string }",
|
||||||
"purpose": "Task 9 新增:React Flow 自定义节点数据(含索引签名满足 React Flow 约束)",
|
"purpose": "Task 9 新增:React Flow 自定义节点数据(传递给 graph-kp-node 组件)",
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
"components/graph-kp-node.tsx",
|
"components/graph-kp-node.tsx",
|
||||||
"components/knowledge-graph.tsx"
|
"components/knowledge-graph.tsx"
|
||||||
@@ -4518,7 +4518,7 @@
|
|||||||
{
|
{
|
||||||
"name": "GraphEdgeData",
|
"name": "GraphEdgeData",
|
||||||
"type": "interface",
|
"type": "interface",
|
||||||
"definition": "{ edgeType: 'parent-child' | 'prerequisite', isHighlighted? } & Record<string, unknown>",
|
"definition": "{ edgeType: 'parent' | 'prerequisite', isHighlighted: boolean }",
|
||||||
"purpose": "Task 9 新增:React Flow 自定义边数据",
|
"purpose": "Task 9 新增:React Flow 自定义边数据",
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
"components/graph-prerequisite-edge.tsx",
|
"components/graph-prerequisite-edge.tsx",
|
||||||
@@ -4528,8 +4528,8 @@
|
|||||||
{
|
{
|
||||||
"name": "MasteryLevel",
|
"name": "MasteryLevel",
|
||||||
"type": "type",
|
"type": "type",
|
||||||
"definition": "'unknown' | 'weak' | 'fair' | 'strong'",
|
"definition": "'low' | 'medium' | 'high' | 'unassessed'",
|
||||||
"purpose": "Task 9 新增:掌握度等级(红/黄/灰)",
|
"purpose": "Task 9 新增:掌握度等级(红/黄/绿/灰)",
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
"components/graph-kp-node.tsx"
|
"components/graph-kp-node.tsx"
|
||||||
]
|
]
|
||||||
@@ -13694,8 +13694,12 @@
|
|||||||
"algorithms": {
|
"algorithms": {
|
||||||
"sm2": {
|
"sm2": {
|
||||||
"name": "SM-2 间隔重复算法(简化版)",
|
"name": "SM-2 间隔重复算法(简化版)",
|
||||||
"description": "4 级评级(again/hard/good/easy),间隔 1/2/4/7 天起,指数增长(×1.2/×1.5/×2),连续 3 次答对标记为已掌握",
|
"file": "sm2-algorithm.ts",
|
||||||
"functions": ["calculateNewInterval", "calculateNewMastery", "deriveStatus", "calculateNextReviewAt"]
|
"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": {
|
"dataScope": {
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ export async function createPrerequisiteAction(
|
|||||||
return { success: false, message: t("invalidInput") };
|
return { success: false, message: t("invalidInput") };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 归属校验
|
// 归属校验:两个知识点都必须属于当前教材,防止跨教材越权
|
||||||
const kpBelongs = await verifyKnowledgePointBelongsToTextbook(
|
const kpBelongs = await verifyKnowledgePointBelongsToTextbook(
|
||||||
parsed.data.knowledgePointId,
|
parsed.data.knowledgePointId,
|
||||||
textbookId,
|
textbookId,
|
||||||
@@ -434,6 +434,13 @@ export async function createPrerequisiteAction(
|
|||||||
if (!kpBelongs) {
|
if (!kpBelongs) {
|
||||||
return { success: false, message: t("kpNotBelong") };
|
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);
|
const existingEdges = await getPrerequisiteEdgesForTextbook(textbookId);
|
||||||
@@ -475,6 +482,22 @@ export async function deletePrerequisiteAction(
|
|||||||
return { success: false, message: t("invalidInput") };
|
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);
|
await deletePrerequisite(parsed.data);
|
||||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||||
return { success: true, message: t("prerequisiteDeleted") };
|
return { success: true, message: t("prerequisiteDeleted") };
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 教材模块埋点接口(预留)。
|
* 教材模块埋点接口(预留)。
|
||||||
*
|
*
|
||||||
@@ -5,8 +7,6 @@
|
|||||||
* 通过 React Context 注入,组件内调用 `useTextbookAnalytics()` 获取。
|
* 通过 React Context 注入,组件内调用 `useTextbookAnalytics()` 获取。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { createContext, useContext, type ReactNode } from "react"
|
import { createContext, useContext, type ReactNode } from "react"
|
||||||
|
|
||||||
export interface TextbookAnalytics {
|
export interface TextbookAnalytics {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { ChevronRight, FileText, Folder, Plus, Trash2, GripVertical } from "lucide-react"
|
import { ChevronRight, FileText, Folder, Plus, Trash2, GripVertical } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core"
|
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core"
|
||||||
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"
|
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { CreateChapterDialog } from "./create-chapter-dialog"
|
import { CreateChapterDialog } from "./create-chapter-dialog"
|
||||||
import { deleteChapterAction, reorderChaptersAction } from "../actions"
|
import { deleteChapterAction, reorderChaptersAction } from "../actions"
|
||||||
|
import { findChapterParent } from "../utils"
|
||||||
|
|
||||||
interface SortableChapterItemProps {
|
interface SortableChapterItemProps {
|
||||||
chapter: Chapter
|
chapter: Chapter
|
||||||
@@ -39,6 +41,7 @@ interface SortableChapterItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: SortableChapterItemProps) {
|
function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: SortableChapterItemProps) {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
const [isOpen, setIsOpen] = useState(level === 0)
|
const [isOpen, setIsOpen] = useState(level === 0)
|
||||||
const hasChildren = chapter.children && chapter.children.length > 0
|
const hasChildren = chapter.children && chapter.children.length > 0
|
||||||
const isSelected = chapter.id === selectedId
|
const isSelected = chapter.id === selectedId
|
||||||
@@ -68,7 +71,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
|||||||
isDragging && "opacity-50"
|
isDragging && "opacity-50"
|
||||||
)}>
|
)}>
|
||||||
{canEdit && (
|
{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" />
|
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -87,7 +90,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
|||||||
isOpen && "rotate-90"
|
isOpen && "rotate-90"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="sr-only">Toggle</span>
|
<span className="sr-only">{t("dialog.chapter.toggle")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
) : (
|
) : (
|
||||||
@@ -115,7 +118,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onCreateSub(chapter.id)
|
onCreateSub(chapter.id)
|
||||||
}}
|
}}
|
||||||
title="Add Subchapter"
|
aria-label={t("dialog.chapter.addSubchapter")}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -127,7 +130,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onDelete(chapter)
|
onDelete(chapter)
|
||||||
}}
|
}}
|
||||||
title="Delete Chapter"
|
aria-label={t("dialog.chapter.delete")}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -193,9 +196,10 @@ interface ChapterSidebarListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId, canEdit = true }: ChapterSidebarListProps) {
|
export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId, canEdit = true }: ChapterSidebarListProps) {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||||
const [createParentId, setCreateParentId] = useState<string | undefined>(undefined)
|
const [createParentId, setCreateParentId] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Chapter | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<Chapter | null>(null)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
@@ -209,36 +213,12 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
|||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
if (!over || active.id === over.id) return
|
if (!over || active.id === over.id) return
|
||||||
|
|
||||||
// Find which list the items belong to
|
// v2-P2: 复用 utils.ts 的 findChapterParent,消除重复代码
|
||||||
// Since we only support sibling reordering for now, we assume active and over are in the same list
|
const activeParent = findChapterParent(chapters, String(active.id))
|
||||||
// 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.
|
|
||||||
|
|
||||||
let activeList: Chapter[] = chapters
|
let activeList: Chapter[] = chapters
|
||||||
let activeParentId: string | null = null
|
let activeParentId: string | null = null
|
||||||
|
|
||||||
if (activeParent) {
|
if (activeParent) {
|
||||||
activeList = activeParent.children || []
|
activeList = activeParent.children || []
|
||||||
activeParentId = activeParent.id
|
activeParentId = activeParent.id
|
||||||
@@ -246,36 +226,51 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
|||||||
// Check if active is in root
|
// Check if active is in root
|
||||||
if (!chapters.some(c => c.id === active.id)) {
|
if (!chapters.some(c => c.id === active.id)) {
|
||||||
// Should not happen if tree is consistent
|
// Should not happen if tree is consistent
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if over is in the same list
|
// Check if over is in the same list
|
||||||
if (activeList.some(c => c.id === over.id)) {
|
if (activeList.some(c => c.id === over.id)) {
|
||||||
const newIndex = activeList.findIndex((item) => item.id === over.id)
|
const newIndex = activeList.findIndex((item) => item.id === over.id)
|
||||||
|
|
||||||
await reorderChaptersAction(active.id as string, newIndex, activeParentId, textbookId)
|
try {
|
||||||
toast.success("Order updated")
|
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 () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteTarget) return
|
if (!deleteTarget) return
|
||||||
setIsDeleting(true)
|
setIsDeleting(true)
|
||||||
const res = await deleteChapterAction(deleteTarget.id, textbookId)
|
try {
|
||||||
setIsDeleting(false)
|
const res = await deleteChapterAction(deleteTarget.id, textbookId)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message)
|
toast.success(res.message)
|
||||||
setShowDeleteDialog(false)
|
setShowDeleteDialog(false)
|
||||||
setDeleteTarget(null)
|
setDeleteTarget(null)
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message)
|
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) => {
|
const handleDeleteRequest = (chapter: Chapter) => {
|
||||||
if (chapter.children && chapter.children.length > 0) {
|
if (chapter.children && chapter.children.length > 0) {
|
||||||
toast.error("Cannot delete chapter with subchapters")
|
toast.error(t("dialog.chapter.cannotDeleteWithSubchapters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setDeleteTarget(chapter)
|
setDeleteTarget(chapter)
|
||||||
@@ -313,7 +308,7 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
|||||||
textbookId={textbookId}
|
textbookId={textbookId}
|
||||||
onDelete={handleDeleteRequest}
|
onDelete={handleDeleteRequest}
|
||||||
onCreateSub={handleCreateSubRequest}
|
onCreateSub={handleCreateSubRequest}
|
||||||
canEdit={true}
|
canEdit={canEdit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateChapterDialog
|
<CreateChapterDialog
|
||||||
@@ -329,16 +324,15 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
|||||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete Chapter?</AlertDialogTitle>
|
<AlertDialogTitle>{t("dialog.chapter.deleteTitle")}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will permanently delete <span className="font-medium text-foreground">{deleteTarget?.title}</span>.
|
{t("dialog.chapter.deleteDesc", { title: deleteTarget?.title ?? "" })}
|
||||||
This action cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<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}>
|
<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>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -50,12 +50,17 @@ export function CreateChapterDialog({
|
|||||||
const setOpen = onOpenChange ?? setUncontrolledOpen
|
const setOpen = onOpenChange ?? setUncontrolledOpen
|
||||||
|
|
||||||
const handleSubmit = async (formData: FormData) => {
|
const handleSubmit = async (formData: FormData) => {
|
||||||
const result = await createChapterAction(textbookId, parentId, null, formData)
|
try {
|
||||||
if (result.success) {
|
const result = await createChapterAction(textbookId, parentId, null, formData)
|
||||||
toast.success(result.message)
|
if (result.success) {
|
||||||
setOpen(false)
|
toast.success(result.message)
|
||||||
} else {
|
setOpen(false)
|
||||||
toast.error(result.message)
|
} 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 { memo } from "react"
|
||||||
import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react"
|
import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import type { GraphEdgeData } from "../types"
|
||||||
|
|
||||||
function GraphPrerequisiteEdgeComponent({
|
function GraphPrerequisiteEdgeComponent({
|
||||||
id,
|
id,
|
||||||
@@ -23,7 +24,9 @@ function GraphPrerequisiteEdgeComponent({
|
|||||||
targetPosition,
|
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 (
|
return (
|
||||||
<BaseEdge
|
<BaseEdge
|
||||||
|
|||||||
@@ -14,13 +14,34 @@ import {
|
|||||||
} from "@xyflow/react"
|
} from "@xyflow/react"
|
||||||
import "@xyflow/react/dist/style.css"
|
import "@xyflow/react/dist/style.css"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { Share2 } from "lucide-react"
|
import { Share2 } from "lucide-react"
|
||||||
import { usePermission } from "@/shared/hooks/use-permission"
|
import { usePermission } from "@/shared/hooks/use-permission"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
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 type { GraphViewMode, GraphNodeData } from "../types"
|
||||||
import { computeGraphLayout } from "../graph-layout"
|
import { computeGraphLayout } from "../graph-layout"
|
||||||
import { useGraphData } from "../hooks/use-graph-data"
|
import { useGraphData } from "../hooks/use-graph-data"
|
||||||
|
import {
|
||||||
|
createPrerequisiteAction,
|
||||||
|
deletePrerequisiteAction,
|
||||||
|
} from "../actions"
|
||||||
import { GraphKpNode } from "./graph-kp-node"
|
import { GraphKpNode } from "./graph-kp-node"
|
||||||
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
|
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
|
||||||
import { GraphToolbar } from "./graph-toolbar"
|
import { GraphToolbar } from "./graph-toolbar"
|
||||||
@@ -51,8 +72,12 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
|||||||
const [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
|
const [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
|
||||||
const [searchText, setSearchText] = useState("")
|
const [searchText, setSearchText] = useState("")
|
||||||
const [selectedKpId, setSelectedKpId] = useState<string | null>(null)
|
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
|
const availableViewModes: GraphViewMode[] = isTeacher
|
||||||
? ["structure", "class-mastery"]
|
? ["structure", "class-mastery"]
|
||||||
@@ -173,6 +198,51 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
|||||||
reactFlow.fitView({ nodes: [{ id: kpId }], duration: 300 })
|
reactFlow.fitView({ nodes: [{ id: kpId }], duration: 300 })
|
||||||
}, [reactFlow])
|
}, [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) {
|
if (isLoading && !data) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
<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}
|
textbookId={textbookId}
|
||||||
onClose={() => setSelectedKpId(null)}
|
onClose={() => setSelectedKpId(null)}
|
||||||
onJumpToKp={onJumpToKp}
|
onJumpToKp={onJumpToKp}
|
||||||
onAddPrerequisite={() => {
|
onAddPrerequisite={() => setAddPrereqOpen(true)}
|
||||||
// 后续迭代:打开添加前置对话框
|
onRemovePrerequisite={handleRemovePrerequisite}
|
||||||
}}
|
|
||||||
onRemovePrerequisite={(_prereqId: string) => {
|
|
||||||
// 后续迭代:调用 deletePrerequisiteAction
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</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"
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Book, MoreVertical, Edit, Trash2, BookOpen, GraduationCap, Building2 } from "lucide-react"
|
import { Book, MoreVertical, Edit, Trash2, BookOpen, GraduationCap, Building2 } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
@@ -17,9 +19,21 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/shared/components/ui/dropdown-menu"
|
} 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 { cn, formatDate } from "@/shared/lib/utils"
|
||||||
import type { Textbook } from "../types"
|
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 {
|
interface TextbookCardProps {
|
||||||
textbook: Textbook
|
textbook: Textbook
|
||||||
@@ -29,8 +43,30 @@ interface TextbookCardProps {
|
|||||||
|
|
||||||
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
|
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
|
||||||
const t = useTranslations("textbooks")
|
const t = useTranslations("textbooks")
|
||||||
|
const router = useRouter()
|
||||||
const base = hrefBase || "/teacher/textbooks"
|
const base = hrefBase || "/teacher/textbooks"
|
||||||
const colorClass = getSubjectColor(textbook.subject)
|
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 (
|
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">
|
<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={cn("relative h-32 w-full overflow-hidden p-5", colorClass)}>
|
||||||
<div className="relative z-10 flex h-full flex-col justify-between">
|
<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">
|
<Badge variant="secondary" className="w-fit bg-background/80 border border-border/60 shadow-sm">
|
||||||
{textbook.subject}
|
{t(`subject.${getSubjectLabelKey(textbook.subject)}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<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" />
|
<Book className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs font-medium text-foreground/70">
|
<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>
|
</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 flex-wrap gap-y-1 gap-x-4 text-xs text-muted-foreground">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<GraduationCap className="h-3.5 w-3.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>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Building2 className="h-3.5 w-3.5" />
|
<Building2 className="h-3.5 w-3.5" />
|
||||||
@@ -86,7 +122,7 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<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)}
|
{t("card.updated")} {formatDate(textbook.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
{!hideActions && (
|
{!hideActions && (
|
||||||
@@ -104,7 +140,10 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
|
|||||||
{t("card.editContent")}
|
{t("card.editContent")}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</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" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{t("card.delete")}
|
{t("card.delete")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -113,6 +152,30 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardFooter>
|
</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>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,12 +42,17 @@ export function TextbookFormDialog() {
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = async (formData: FormData) => {
|
const handleSubmit = async (formData: FormData) => {
|
||||||
const result = await createTextbookAction(null, formData)
|
try {
|
||||||
if (result.success) {
|
const result = await createTextbookAction(null, formData)
|
||||||
toast.success(result.message)
|
if (result.success) {
|
||||||
setOpen(false)
|
toast.success(result.message)
|
||||||
} else {
|
setOpen(false)
|
||||||
toast.error(result.message)
|
} 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) {
|
} catch (e) {
|
||||||
console.error("Failed to save chapter content", e)
|
console.error("Failed to save chapter content", e)
|
||||||
toast.error("Failed to save content")
|
toast.error(t("reader.saveFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,13 +52,19 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
|
|||||||
|
|
||||||
const handleUpdate = async (formData: FormData) => {
|
const handleUpdate = async (formData: FormData) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await updateTextbookAction(textbook.id, null, formData)
|
try {
|
||||||
setLoading(false)
|
const result = await updateTextbookAction(textbook.id, null, formData)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message)
|
toast.success(result.message)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message)
|
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 () => {
|
const handleDelete = async () => {
|
||||||
setDeleteDialogOpen(false)
|
setDeleteDialogOpen(false)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await deleteTextbookAction(textbook.id)
|
try {
|
||||||
|
const result = await deleteTextbookAction(textbook.id)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message)
|
toast.success(result.message)
|
||||||
router.push("/teacher/textbooks")
|
router.push("/teacher/textbooks")
|
||||||
} else {
|
} else {
|
||||||
|
toast.error(result.message)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to delete textbook", e)
|
||||||
|
toast.error(t("reader.deleteFailed"))
|
||||||
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
toast.error(result.message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,11 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saving": "Saving...",
|
"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",
|
"addKnowledgePoint": "Add Knowledge Point",
|
||||||
"clickToViewKp": "Click to view knowledge point details",
|
"clickToViewKp": "Click to view knowledge point details",
|
||||||
"noChapters": "No chapters",
|
"noChapters": "No chapters",
|
||||||
@@ -228,12 +233,20 @@
|
|||||||
"viewAllQuestions": "View all questions",
|
"viewAllQuestions": "View all questions",
|
||||||
"editPrerequisite": "Edit prerequisites",
|
"editPrerequisite": "Edit prerequisites",
|
||||||
"addPrerequisite": "Add prerequisite",
|
"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",
|
"removePrerequisite": "Remove",
|
||||||
"noPrerequisites": "No prerequisite knowledge points",
|
"noPrerequisites": "No prerequisite knowledge points",
|
||||||
"noSuccessors": "No successor knowledge points",
|
"noSuccessors": "No successor knowledge points",
|
||||||
"masteryNotAssessed": "Not assessed",
|
"masteryNotAssessed": "Not assessed",
|
||||||
"correctRate": "Correct rate",
|
"correctRate": "Correct rate",
|
||||||
"totalQuestions": "Total questions"
|
"totalQuestions": "Total questions",
|
||||||
|
"prerequisiteAdded": "Prerequisite added",
|
||||||
|
"prerequisiteRemoved": "Prerequisite removed",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"saving": "Saving..."
|
||||||
},
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"search": "Search knowledge points",
|
"search": "Search knowledge points",
|
||||||
|
|||||||
@@ -48,6 +48,11 @@
|
|||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"saving": "保存中...",
|
"saving": "保存中...",
|
||||||
|
"saveFailed": "保存失败",
|
||||||
|
"createFailed": "创建失败",
|
||||||
|
"updateFailed": "更新失败",
|
||||||
|
"deleteFailed": "删除失败",
|
||||||
|
"questionCreatorDefaultContent": "请讲解知识点:{name}",
|
||||||
"addKnowledgePoint": "添加知识点",
|
"addKnowledgePoint": "添加知识点",
|
||||||
"clickToViewKp": "点击查看知识点详情",
|
"clickToViewKp": "点击查看知识点详情",
|
||||||
"noChapters": "暂无章节",
|
"noChapters": "暂无章节",
|
||||||
@@ -228,12 +233,20 @@
|
|||||||
"viewAllQuestions": "查看全部题目",
|
"viewAllQuestions": "查看全部题目",
|
||||||
"editPrerequisite": "编辑前置依赖",
|
"editPrerequisite": "编辑前置依赖",
|
||||||
"addPrerequisite": "添加前置",
|
"addPrerequisite": "添加前置",
|
||||||
|
"addPrerequisiteTitle": "添加前置知识点",
|
||||||
|
"addPrerequisiteDesc": "选择一个知识点作为当前知识点的前置依赖。",
|
||||||
|
"selectPrerequisite": "请选择前置知识点",
|
||||||
"removePrerequisite": "移除",
|
"removePrerequisite": "移除",
|
||||||
"noPrerequisites": "暂无前置知识点",
|
"noPrerequisites": "暂无前置知识点",
|
||||||
"noSuccessors": "暂无后置知识点",
|
"noSuccessors": "暂无后置知识点",
|
||||||
"masteryNotAssessed": "未测评",
|
"masteryNotAssessed": "未测评",
|
||||||
"correctRate": "正确率",
|
"correctRate": "正确率",
|
||||||
"totalQuestions": "总题数"
|
"totalQuestions": "总题数",
|
||||||
|
"prerequisiteAdded": "前置依赖已添加",
|
||||||
|
"prerequisiteRemoved": "前置依赖已移除",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "确认",
|
||||||
|
"saving": "保存中..."
|
||||||
},
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"search": "搜索知识点",
|
"search": "搜索知识点",
|
||||||
|
|||||||
Reference in New Issue
Block a user