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:
@@ -4220,6 +4220,34 @@
|
|||||||
"usedBy": [
|
"usedBy": [
|
||||||
"questions/data-access.getKnowledgePointOptions"
|
"questions/data-access.getKnowledgePointOptions"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getTextbooksWithScope",
|
||||||
|
"signature": "(query?, subject?, grade?, scope?: TextbookQueryScope) => Promise<Textbook[]>",
|
||||||
|
"purpose": "P1-1 新增:按数据范围获取教材列表,学生端强制按年级过滤",
|
||||||
|
"usedBy": [
|
||||||
|
"student/learning/textbooks/page.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "verifyChapterBelongsToTextbook",
|
||||||
|
"signature": "(chapterId, textbookId) => Promise<boolean>",
|
||||||
|
"purpose": "P0-4 新增:资源归属校验,防止跨教材越权操作章节",
|
||||||
|
"usedBy": [
|
||||||
|
"reorderChaptersAction",
|
||||||
|
"updateChapterContentAction",
|
||||||
|
"deleteChapterAction",
|
||||||
|
"createKnowledgePointAction"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "verifyKnowledgePointBelongsToTextbook",
|
||||||
|
"signature": "(kpId, textbookId) => Promise<boolean>",
|
||||||
|
"purpose": "P0-4 新增:资源归属校验,防止跨教材越权操作知识点",
|
||||||
|
"usedBy": [
|
||||||
|
"updateKnowledgePointAction",
|
||||||
|
"deleteKnowledgePointAction"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"hooks": [
|
"hooks": [
|
||||||
@@ -4251,6 +4279,15 @@
|
|||||||
"questions (知识点关联)"
|
"questions (知识点关联)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "ChapterTreeNode",
|
||||||
|
"definition": "Chapter & { children: ChapterTreeNode[] }",
|
||||||
|
"purpose": "P1-5 新增:buildChapterTree 返回类型,强制 children 为非空数组",
|
||||||
|
"usedBy": [
|
||||||
|
"textbooks/utils.buildChapterTree",
|
||||||
|
"textbooks/components/chapter-sidebar-list.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "KnowledgePoint",
|
"name": "KnowledgePoint",
|
||||||
"definition": "{ id, name, description?, anchorText?, parentId?, chapterId?, level, order }",
|
"definition": "{ id, name, description?, anchorText?, parentId?, chapterId?, level, order }",
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function StudentTextbookDetailError({
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title={t("error.loadFailed")}
|
||||||
|
description={t("error.loadFailedDesc")}
|
||||||
|
action={{
|
||||||
|
label: t("error.retry"),
|
||||||
|
onClick: () => reset(),
|
||||||
|
}}
|
||||||
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { BookOpen } from "lucide-react"
|
import { BookOpen } from "lucide-react"
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
|
|||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||||
|
import { getGradeNameById } from "@/modules/school/data-access"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ export default async function StudentTextbookDetailPage({
|
|||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}) {
|
}) {
|
||||||
|
const t = await getTranslations("textbooks")
|
||||||
const student = await getCurrentStudentUser()
|
const student = await getCurrentStudentUser()
|
||||||
if (!student) return notFound()
|
if (!student) return notFound()
|
||||||
|
|
||||||
@@ -28,6 +31,13 @@ export default async function StudentTextbookDetailPage({
|
|||||||
|
|
||||||
if (!textbook) notFound()
|
if (!textbook) notFound()
|
||||||
|
|
||||||
|
// P1-1 数据范围过滤:校验教材年级与学生年级匹配
|
||||||
|
// student.gradeId 是 grades 表 id,需解析为年级名称后才能与 textbooks.grade 字符串比较
|
||||||
|
const studentGradeName = student.gradeId ? await getGradeNameById(student.gradeId) : null
|
||||||
|
if (studentGradeName && textbook.grade && textbook.grade !== studentGradeName) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-4rem-3rem)] flex-col overflow-hidden bg-muted/5">
|
<div className="flex h-[calc(100vh-4rem-3rem)] flex-col overflow-hidden bg-muted/5">
|
||||||
<div className="flex items-center justify-between border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 px-6 shrink-0 z-10">
|
<div className="flex items-center justify-between border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 px-6 shrink-0 z-10">
|
||||||
@@ -48,13 +58,14 @@ export default async function StudentTextbookDetailPage({
|
|||||||
<div className="h-full flex items-center justify-center rounded-lg border border-dashed bg-card">
|
<div className="h-full flex items-center justify-center rounded-lg border border-dashed bg-card">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
title="No chapters"
|
title={t("reader.noChapters")}
|
||||||
description="This textbook has no chapters yet."
|
description={t("reader.noChaptersDesc")}
|
||||||
className="border-none shadow-none"
|
className="border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full min-h-0 max-w-[1600px] mx-auto w-full">
|
<div className="h-full min-h-0 max-w-[1600px] mx-auto w-full">
|
||||||
|
{/* 学生端不传 renderQuestionCreator,无题目创建权限 */}
|
||||||
<TextbookReader chapters={chapters} knowledgePoints={knowledgePoints} />
|
<TextbookReader chapters={chapters} knowledgePoints={knowledgePoints} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
29
src/app/(dashboard)/student/learning/textbooks/error.tsx
Normal file
29
src/app/(dashboard)/student/learning/textbooks/error.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function StudentTextbooksError({
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title={t("error.loadFailed")}
|
||||||
|
description={t("error.loadFailedDesc")}
|
||||||
|
action={{
|
||||||
|
label: t("error.retry"),
|
||||||
|
onClick: () => reset(),
|
||||||
|
}}
|
||||||
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import { BookOpen, UserX } from "lucide-react"
|
import { BookOpen, UserX } from "lucide-react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { getTextbooks } from "@/modules/textbooks/data-access"
|
import { getTextbooksWithScope } from "@/modules/textbooks/data-access"
|
||||||
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
|
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
|
||||||
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
|
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
|
||||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||||
|
import { getGradeNameById } from "@/modules/school/data-access"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
const getParam = (params: SearchParams, key: string) => {
|
const getParam = (params: SearchParams, key: string): string | undefined => {
|
||||||
const v = params[key]
|
const v = params[key]
|
||||||
return Array.isArray(v) ? v[0] : v
|
return Array.isArray(v) ? v[0] : v
|
||||||
}
|
}
|
||||||
@@ -20,28 +22,39 @@ export default async function StudentTextbooksPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}) {
|
}) {
|
||||||
|
const t = await getTranslations("textbooks")
|
||||||
const [student, sp] = await Promise.all([getCurrentStudentUser(), searchParams])
|
const [student, sp] = await Promise.all([getCurrentStudentUser(), searchParams])
|
||||||
|
|
||||||
if (!student) {
|
if (!student) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="space-y-8">
|
||||||
<EmptyState title="No user found" description="Create a student user to see textbooks." icon={UserX} />
|
<EmptyState
|
||||||
|
title={t("student.noUser")}
|
||||||
|
description={t("student.noUserDesc")}
|
||||||
|
icon={UserX}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const q = getParam(sp, "q") || undefined
|
const q = getParam(sp, "q")
|
||||||
const subject = getParam(sp, "subject") || undefined
|
const subject = getParam(sp, "subject")
|
||||||
const grade = getParam(sp, "grade") || undefined
|
const grade = getParam(sp, "grade")
|
||||||
|
|
||||||
const textbooks = await getTextbooks(q, subject, grade)
|
// P1-1 数据范围过滤:学生端强制按学生所在年级过滤
|
||||||
|
// student.gradeId 是 grades 表 id,需通过 getGradeNameById 解析为年级名称(如 "Grade 7"),
|
||||||
|
// 才能与 textbooks.grade 字符串字段匹配
|
||||||
|
const studentGradeName = student.gradeId ? await getGradeNameById(student.gradeId) : null
|
||||||
|
const textbooks = await getTextbooksWithScope(q, subject, grade, {
|
||||||
|
grade: studentGradeName ?? undefined,
|
||||||
|
})
|
||||||
const hasFilters = Boolean(q || (subject && subject !== "all") || (grade && grade !== "all"))
|
const hasFilters = Boolean(q || (subject && subject !== "all") || (grade && grade !== "all"))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{t("student.list.title")}</h2>
|
||||||
<p className="text-muted-foreground">Browse your course textbooks.</p>
|
<p className="text-muted-foreground">{t("student.list.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextbookFilters />
|
<TextbookFilters />
|
||||||
@@ -49,19 +62,31 @@ export default async function StudentTextbooksPage({
|
|||||||
{textbooks.length === 0 ? (
|
{textbooks.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
|
title={hasFilters ? t("student.list.empty.withFilters") : t("student.list.empty.withoutFilters")}
|
||||||
description={hasFilters ? "Try clearing filters or adjusting keywords." : "No textbooks are available right now."}
|
description={
|
||||||
action={hasFilters ? { label: "Clear filters", href: "/student/learning/textbooks" } : undefined}
|
hasFilters
|
||||||
|
? t("student.list.empty.withFiltersDesc")
|
||||||
|
: t("student.list.empty.withoutFiltersDesc")
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
hasFilters
|
||||||
|
? { label: t("list.clearFilters"), href: "/student/learning/textbooks" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className="bg-card"
|
className="bg-card"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{textbooks.map((textbook) => (
|
{textbooks.map((textbook) => (
|
||||||
<TextbookCard key={textbook.id} textbook={textbook} hrefBase="/student/learning/textbooks" hideActions />
|
<TextbookCard
|
||||||
|
key={textbook.id}
|
||||||
|
textbook={textbook}
|
||||||
|
hrefBase="/student/learning/textbooks"
|
||||||
|
hideActions
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
src/app/(dashboard)/teacher/textbooks/[id]/error.tsx
Normal file
29
src/app/(dashboard)/teacher/textbooks/[id]/error.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function TeacherTextbookDetailError({
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title={t("error.loadFailed")}
|
||||||
|
description={t("error.loadFailedDesc")}
|
||||||
|
action={{
|
||||||
|
label: t("error.retry"),
|
||||||
|
onClick: () => reset(),
|
||||||
|
}}
|
||||||
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX, ReactNode } from "react"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { ArrowLeft } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"
|
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"
|
||||||
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
|
import { TextbookReader, type TextbookReaderProps } from "@/modules/textbooks/components/textbook-reader"
|
||||||
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog"
|
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog"
|
||||||
|
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
|
||||||
|
import type { KnowledgePoint } from "@/modules/textbooks/types"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -16,6 +19,7 @@ export default async function TextbookDetailPage({
|
|||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
const t = await getTranslations("textbooks")
|
||||||
|
|
||||||
const [textbook, chapters, knowledgePoints] = await Promise.all([
|
const [textbook, chapters, knowledgePoints] = await Promise.all([
|
||||||
getTextbookById(id),
|
getTextbookById(id),
|
||||||
@@ -27,6 +31,25 @@ export default async function TextbookDetailPage({
|
|||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P0-1 在页面层注入 questions 模块的 CreateQuestionDialog 实现
|
||||||
|
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 ? `Please explain the knowledge point: ${targetKp.name}` : ""}
|
||||||
|
defaultType="text"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
|
<div className="flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
|
||||||
{/* Header / Nav (Fixed height) */}
|
{/* Header / Nav (Fixed height) */}
|
||||||
@@ -34,7 +57,7 @@ export default async function TextbookDetailPage({
|
|||||||
<Button asChild variant="ghost" size="sm">
|
<Button asChild variant="ghost" size="sm">
|
||||||
<Link href="/teacher/textbooks">
|
<Link href="/teacher/textbooks">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
返回教材列表
|
{t("reader.back")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -57,7 +80,7 @@ export default async function TextbookDetailPage({
|
|||||||
chapters={chapters}
|
chapters={chapters}
|
||||||
knowledgePoints={knowledgePoints}
|
knowledgePoints={knowledgePoints}
|
||||||
textbookId={id}
|
textbookId={id}
|
||||||
canEdit={true}
|
renderQuestionCreator={renderQuestionCreator}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
29
src/app/(dashboard)/teacher/textbooks/error.tsx
Normal file
29
src/app/(dashboard)/teacher/textbooks/error.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function TeacherTextbooksError({
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title={t("error.loadFailed")}
|
||||||
|
description={t("error.loadFailedDesc")}
|
||||||
|
action={{
|
||||||
|
label: t("error.retry"),
|
||||||
|
onClick: () => reset(),
|
||||||
|
}}
|
||||||
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
import { BookOpen } from "lucide-react"
|
import { BookOpen } from "lucide-react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
|
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
|
||||||
import { TextbookFormDialog } from "@/modules/textbooks/components/textbook-form-dialog"
|
import { TextbookFormDialog } from "@/modules/textbooks/components/textbook-form-dialog"
|
||||||
import { getTextbooks } from "@/modules/textbooks/data-access"
|
import { getTextbooks } from "@/modules/textbooks/data-access"
|
||||||
@@ -10,7 +11,13 @@ import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
async function TextbooksResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
async function TextbooksResults({
|
||||||
|
searchParams,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
t: Awaited<ReturnType<typeof getTranslations<"textbooks">>>
|
||||||
|
}): Promise<JSX.Element> {
|
||||||
const params = await searchParams
|
const params = await searchParams
|
||||||
|
|
||||||
const q = getParam(params, "q") || undefined
|
const q = getParam(params, "q") || undefined
|
||||||
@@ -25,9 +32,17 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise<Search
|
|||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
|
title={hasFilters ? t("list.empty.withFilters") : t("list.empty.withoutFilters")}
|
||||||
description={hasFilters ? "Try clearing filters or adjusting keywords." : "Create your first textbook to start organizing chapters."}
|
description={
|
||||||
action={hasFilters ? { label: "Clear filters", href: "/teacher/textbooks" } : undefined}
|
hasFilters
|
||||||
|
? t("list.empty.withFiltersDesc")
|
||||||
|
: t("list.empty.withoutFiltersDesc")
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
hasFilters
|
||||||
|
? { label: t("list.clearFilters"), href: "/teacher/textbooks" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className="min-h-[400px] border-muted-foreground/10"
|
className="min-h-[400px] border-muted-foreground/10"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -42,16 +57,20 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise<Search
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function TextbooksPage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
export default async function TextbooksPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}): Promise<JSX.Element> {
|
||||||
|
const t = await getTranslations("textbooks")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-8">
|
<div className="space-y-6 p-8">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Textbooks</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{t("list.title")}</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">{t("list.subtitle")}</p>
|
||||||
Manage your digital curriculum resources and chapters.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<TextbookFormDialog />
|
<TextbookFormDialog />
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +80,7 @@ export default async function TextbooksPage({ searchParams }: { searchParams: Pr
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<Suspense fallback={<div className="h-[360px] w-full animate-pulse rounded-md bg-muted" />}>
|
<Suspense fallback={<div className="h-[360px] w-full animate-pulse rounded-md bg-muted" />}>
|
||||||
<TextbooksResults searchParams={searchParams} />
|
<TextbooksResults searchParams={searchParams} t={t} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
updateKnowledgePoint,
|
updateKnowledgePoint,
|
||||||
updateTextbook,
|
updateTextbook,
|
||||||
deleteTextbook,
|
deleteTextbook,
|
||||||
reorderChapters
|
reorderChapters,
|
||||||
|
verifyChapterBelongsToTextbook,
|
||||||
|
verifyKnowledgePointBelongsToTextbook,
|
||||||
} from "./data-access";
|
} from "./data-access";
|
||||||
import {
|
import {
|
||||||
CreateTextbookSchema,
|
CreateTextbookSchema,
|
||||||
@@ -38,6 +40,11 @@ export async function reorderChaptersAction(
|
|||||||
): Promise<ActionState> {
|
): Promise<ActionState> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
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);
|
await reorderChapters(chapterId, newIndex, parentId);
|
||||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||||
return { success: true, message: "Chapters reordered successfully" };
|
return { success: true, message: "Chapters reordered successfully" };
|
||||||
@@ -203,6 +210,11 @@ export async function updateChapterContentAction(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
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);
|
await updateChapterContent(parsed.data);
|
||||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||||
return { success: true, message: "Content updated successfully" };
|
return { success: true, message: "Content updated successfully" };
|
||||||
@@ -220,6 +232,11 @@ export async function deleteChapterAction(
|
|||||||
): Promise<ActionState> {
|
): Promise<ActionState> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.TEXTBOOK_DELETE);
|
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);
|
await deleteChapter(chapterId);
|
||||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||||
return { success: true, message: "Chapter deleted successfully" };
|
return { success: true, message: "Chapter deleted successfully" };
|
||||||
@@ -254,6 +271,11 @@ export async function createKnowledgePointAction(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.TEXTBOOK_CREATE);
|
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);
|
await createKnowledgePoint(parsed.data);
|
||||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||||
return { success: true, message: "Knowledge point created successfully" };
|
return { success: true, message: "Knowledge point created successfully" };
|
||||||
@@ -271,6 +293,11 @@ export async function deleteKnowledgePointAction(
|
|||||||
): Promise<ActionState> {
|
): Promise<ActionState> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.TEXTBOOK_DELETE);
|
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);
|
await deleteKnowledgePoint(kpId);
|
||||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||||
return { success: true, message: "Knowledge point deleted successfully" };
|
return { success: true, message: "Knowledge point deleted successfully" };
|
||||||
@@ -305,6 +332,11 @@ export async function updateKnowledgePointAction(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
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);
|
await updateKnowledgePoint(parsed.data);
|
||||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||||
return { success: true, message: "Knowledge point updated successfully" };
|
return { success: true, message: "Knowledge point updated successfully" };
|
||||||
|
|||||||
43
src/modules/textbooks/analytics.tsx
Normal file
43
src/modules/textbooks/analytics.tsx
Normal 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)
|
||||||
|
}
|
||||||
@@ -136,9 +136,9 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
|||||||
|
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
{hasChildren && (
|
{hasChildren && chapter.children && (
|
||||||
<RecursiveSortableList
|
<RecursiveSortableList
|
||||||
items={chapter.children!}
|
items={chapter.children}
|
||||||
level={level + 1}
|
level={level + 1}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Plus } from "lucide-react"
|
import { Plus } from "lucide-react"
|
||||||
import { useFormStatus } from "react-dom"
|
import { useFormStatus } from "react-dom"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -20,9 +21,10 @@ import { toast } from "sonner"
|
|||||||
|
|
||||||
function SubmitButton() {
|
function SubmitButton() {
|
||||||
const { pending } = useFormStatus()
|
const { pending } = useFormStatus()
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
return (
|
return (
|
||||||
<Button type="submit" disabled={pending}>
|
<Button type="submit" disabled={pending}>
|
||||||
{pending ? "Creating..." : "Create Chapter"}
|
{pending ? t("dialog.chapter.creating") : t("dialog.chapter.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -35,7 +37,14 @@ interface CreateChapterDialogProps {
|
|||||||
onOpenChange?: (open: boolean) => void
|
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 [uncontrolledOpen, setUncontrolledOpen] = useState(false)
|
||||||
const open = controlledOpen ?? uncontrolledOpen
|
const open = controlledOpen ?? uncontrolledOpen
|
||||||
const setOpen = onOpenChange ?? setUncontrolledOpen
|
const setOpen = onOpenChange ?? setUncontrolledOpen
|
||||||
@@ -54,9 +63,13 @@ export function CreateChapterDialog({ textbookId, parentId, trigger, open: contr
|
|||||||
trigger === null
|
trigger === null
|
||||||
? null
|
? null
|
||||||
: trigger || (
|
: 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" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="sr-only">Add Chapter</span>
|
<span className="sr-only">{t("dialog.chapter.createTitle")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,21 +78,19 @@ export function CreateChapterDialog({ textbookId, parentId, trigger, open: contr
|
|||||||
{triggerNode ? <DialogTrigger asChild>{triggerNode}</DialogTrigger> : null}
|
{triggerNode ? <DialogTrigger asChild>{triggerNode}</DialogTrigger> : null}
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add New Chapter</DialogTitle>
|
<DialogTitle>{t("dialog.chapter.createTitle")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{t("dialog.chapter.createDesc")}</DialogDescription>
|
||||||
Create a new chapter or section.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form action={handleSubmit}>
|
<form action={handleSubmit}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="title" className="text-right">
|
<Label htmlFor="title" className="text-right">
|
||||||
Title
|
{t("field.title")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
name="title"
|
name="title"
|
||||||
placeholder="e.g. Chapter 1: Introduction"
|
placeholder={t("dialog.chapter.titlePlaceholder")}
|
||||||
className="col-span-3"
|
className="col-span-3"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Plus } from "lucide-react"
|
|
||||||
import { useFormStatus } from "react-dom"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/shared/components/ui/dialog"
|
|
||||||
import { Input } from "@/shared/components/ui/input"
|
|
||||||
import { Label } from "@/shared/components/ui/label"
|
|
||||||
import { Textarea } from "@/shared/components/ui/textarea"
|
|
||||||
import { createKnowledgePointAction } from "../actions"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
function SubmitButton() {
|
|
||||||
const { pending } = useFormStatus()
|
|
||||||
return (
|
|
||||||
<Button type="submit" disabled={pending}>
|
|
||||||
{pending ? "Adding..." : "Add Point"}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreateKnowledgePointDialogProps {
|
|
||||||
chapterId: string
|
|
||||||
textbookId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateKnowledgePointDialog({ chapterId, textbookId }: CreateKnowledgePointDialogProps) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
|
|
||||||
const handleSubmit = async (formData: FormData) => {
|
|
||||||
const result = await createKnowledgePointAction(chapterId, textbookId, null, formData)
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(result.message)
|
|
||||||
setOpen(false)
|
|
||||||
} else {
|
|
||||||
toast.error(result.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add Knowledge Point</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Link a key concept to this chapter.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form action={handleSubmit}>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="name">
|
|
||||||
Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
placeholder="e.g. Pythagorean Theorem"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="description">
|
|
||||||
Description
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
placeholder="Brief explanation..."
|
|
||||||
className="h-20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<SubmitButton />
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,120 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import type { KnowledgePoint } from "../types"
|
import type { KnowledgePoint } from "../types"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { computeGraphLayout, NODE_WIDTH, NODE_HEIGHT } from "../graph-layout"
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KnowledgeGraphProps {
|
interface KnowledgeGraphProps {
|
||||||
knowledgePoints: KnowledgePoint[]
|
knowledgePoints: KnowledgePoint[]
|
||||||
@@ -122,60 +11,71 @@ interface KnowledgeGraphProps {
|
|||||||
onHighlight: (id: string) => void
|
onHighlight: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KnowledgeGraph({ knowledgePoints, selectedId, onHighlight }: KnowledgeGraphProps) {
|
export function KnowledgeGraph({
|
||||||
const graphLayout = useMemo(() => computeGraphLayout(knowledgePoints), [knowledgePoints])
|
knowledgePoints,
|
||||||
|
selectedId,
|
||||||
|
onHighlight,
|
||||||
|
}: KnowledgeGraphProps) {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
|
|
||||||
|
const layout = useMemo(() => computeGraphLayout(knowledgePoints), [knowledgePoints])
|
||||||
|
|
||||||
if (knowledgePoints.length === 0) {
|
if (knowledgePoints.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||||
该章节暂无知识点。
|
{t("reader.emptyKnowledge")}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="flex-1 h-full px-2">
|
<div className="h-full w-full overflow-auto p-4">
|
||||||
<div
|
<svg
|
||||||
className="relative"
|
width={layout.width}
|
||||||
style={{ width: graphLayout.width, height: graphLayout.height }}
|
height={layout.height}
|
||||||
|
role="img"
|
||||||
|
aria-label={t("reader.tabs.graph")}
|
||||||
|
className="mx-auto"
|
||||||
>
|
>
|
||||||
<svg
|
<title>{t("reader.tabs.graph")}</title>
|
||||||
width={graphLayout.width}
|
{/* 边 */}
|
||||||
height={graphLayout.height}
|
{layout.edges.map((edge) => (
|
||||||
className="absolute inset-0"
|
<line
|
||||||
>
|
key={edge.id}
|
||||||
{graphLayout.edges.map((edge) => (
|
x1={edge.x1}
|
||||||
<line
|
y1={edge.y1}
|
||||||
key={edge.id}
|
x2={edge.x2}
|
||||||
x1={edge.x1}
|
y2={edge.y2}
|
||||||
y1={edge.y1}
|
stroke="currentColor"
|
||||||
x2={edge.x2}
|
strokeOpacity={0.3}
|
||||||
y2={edge.y2}
|
strokeWidth={1.5}
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
{/* 节点 */}
|
||||||
|
{layout.nodes.map((node) => {
|
||||||
|
const isSelected = selectedId === node.id
|
||||||
|
return (
|
||||||
|
<g key={node.id} transform={`translate(${node.x}, ${node.y})`}>
|
||||||
|
<foreignObject width={NODE_WIDTH} height={NODE_HEIGHT}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onHighlight(node.id)}
|
||||||
|
className={`flex h-full w-full items-center justify-center rounded-lg border-2 px-3 text-center text-xs font-medium transition-colors cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-border bg-card text-card-foreground hover:border-primary/50 hover:bg-accent"
|
||||||
|
}`}
|
||||||
|
aria-label={`${t("reader.clickToViewKp")}: ${node.name}`}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
>
|
||||||
|
<span className="line-clamp-2">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
</foreignObject>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import type { KnowledgePoint } from "../types"
|
import type { KnowledgePoint } from "../types"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -13,9 +15,20 @@ import {
|
|||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import { Textarea } from "@/shared/components/ui/textarea"
|
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
|
// Create KP dialog
|
||||||
createDialogOpen: boolean
|
createDialogOpen: boolean
|
||||||
setCreateDialogOpen: (open: boolean) => void
|
setCreateDialogOpen: (open: boolean) => void
|
||||||
@@ -30,10 +43,16 @@ interface KnowledgePointDialogsProps {
|
|||||||
isUpdatingKp: boolean
|
isUpdatingKp: boolean
|
||||||
onUpdateKnowledgePoint: (formData: FormData) => Promise<void>
|
onUpdateKnowledgePoint: (formData: FormData) => Promise<void>
|
||||||
|
|
||||||
// Question dialog
|
// Question dialog(通过 render prop 注入,解耦 questions 模块)
|
||||||
questionDialogOpen: boolean
|
questionDialogOpen: boolean
|
||||||
setQuestionDialogOpen: (open: boolean) => void
|
setQuestionDialogOpen: (open: boolean) => void
|
||||||
targetKpForQuestion: KnowledgePoint | null
|
targetKpForQuestion: KnowledgePoint | null
|
||||||
|
/**
|
||||||
|
* 题目创建器渲染函数。
|
||||||
|
* 由页面层注入实际的 questions 模块组件,模块内部不直接 import questions。
|
||||||
|
* 若不传则不渲染题目创建入口(学生端等无权限场景)。
|
||||||
|
*/
|
||||||
|
renderQuestionCreator?: (props: QuestionCreatorRenderProps) => ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KnowledgePointDialogs({
|
export function KnowledgePointDialogs({
|
||||||
@@ -50,34 +69,35 @@ export function KnowledgePointDialogs({
|
|||||||
questionDialogOpen,
|
questionDialogOpen,
|
||||||
setQuestionDialogOpen,
|
setQuestionDialogOpen,
|
||||||
targetKpForQuestion,
|
targetKpForQuestion,
|
||||||
|
renderQuestionCreator,
|
||||||
}: KnowledgePointDialogsProps) {
|
}: KnowledgePointDialogsProps) {
|
||||||
|
const t = useTranslations("textbooks.dialog.knowledge")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>添加知识点</DialogTitle>
|
<DialogTitle>{t("createTitle")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{t("createDesc")}</DialogDescription>
|
||||||
从选中的文本创建知识点。
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form action={onCreateKnowledgePoint as (formData: FormData) => void}>
|
<form action={onCreateKnowledgePoint as (formData: FormData) => void}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">名称</Label>
|
<Label htmlFor="name">{t("name")}</Label>
|
||||||
<Input id="name" name="name" defaultValue={selectedText} required />
|
<Input id="name" name="name" defaultValue={selectedText} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="description">描述(可选)</Label>
|
<Label htmlFor="description">{t("description")}</Label>
|
||||||
<Textarea id="description" name="description" placeholder="请输入描述..." />
|
<Textarea id="description" name="description" placeholder={t("descriptionPlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)} disabled={isCreating}>
|
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)} disabled={isCreating}>
|
||||||
取消
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isCreating}>
|
<Button type="submit" disabled={isCreating}>
|
||||||
{isCreating ? "创建中..." : "创建"}
|
{isCreating ? t("creating") : t("create")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -87,26 +107,32 @@ export function KnowledgePointDialogs({
|
|||||||
<Dialog open={editKpDialogOpen} onOpenChange={setEditKpDialogOpen}>
|
<Dialog open={editKpDialogOpen} onOpenChange={setEditKpDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>编辑知识点</DialogTitle>
|
<DialogTitle>{t("editTitle")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{t("editDesc")}</DialogDescription>
|
||||||
修改知识点的名称和描述。
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form action={onUpdateKnowledgePoint}>
|
<form action={onUpdateKnowledgePoint}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<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 />
|
<Input id="edit-name" name="name" defaultValue={editingKp?.name} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="edit-description">描述(可选)</Label>
|
<Label htmlFor="edit-description">{t("description")}</Label>
|
||||||
<Textarea id="edit-description" name="description" defaultValue={editingKp?.description || ""} placeholder="请输入描述..." />
|
<Textarea
|
||||||
|
id="edit-description"
|
||||||
|
name="description"
|
||||||
|
defaultValue={editingKp?.description || ""}
|
||||||
|
placeholder={t("descriptionPlaceholder")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 border rounded-md p-3 bg-muted/20">
|
<div className="space-y-2 border rounded-md p-3 bg-muted/20">
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
@@ -118,31 +144,32 @@ export function KnowledgePointDialogs({
|
|||||||
className="text-sm font-mono"
|
className="text-sm font-mono"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-muted-foreground mt-1">
|
<p className="text-[10px] text-muted-foreground mt-1">{t("anchorTextHint")}</p>
|
||||||
修改此字段会改变文中被高亮匹配的文字。通常保持与原文一致。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<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>
|
||||||
<Button type="submit" disabled={isUpdatingKp}>
|
<Button type="submit" disabled={isUpdatingKp}>
|
||||||
{isUpdatingKp ? "保存中..." : "保存"}
|
{isUpdatingKp ? t("saving") : t("save")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<CreateQuestionDialog
|
{renderQuestionCreator?.({
|
||||||
open={questionDialogOpen}
|
open: questionDialogOpen,
|
||||||
onOpenChange={setQuestionDialogOpen}
|
onOpenChange: setQuestionDialogOpen,
|
||||||
defaultKnowledgePointIds={targetKpForQuestion ? [targetKpForQuestion.id] : []}
|
targetKp: targetKpForQuestion,
|
||||||
defaultContent={targetKpForQuestion ? `Please explain the knowledge point: ${targetKpForQuestion.name}` : ""}
|
})}
|
||||||
defaultType="text"
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { PlusCircle, Pencil, Trash2 } from "lucide-react"
|
import { PlusCircle, Pencil, Trash2 } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import type { KnowledgePoint } from "../types"
|
import type { KnowledgePoint } from "../types"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
@@ -11,6 +12,8 @@ import { Button } from "@/shared/components/ui/button"
|
|||||||
interface KnowledgePointListProps {
|
interface KnowledgePointListProps {
|
||||||
knowledgePoints: KnowledgePoint[]
|
knowledgePoints: KnowledgePoint[]
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
|
/** 是否有创建题目权限(控制"创建相关题目"按钮可见性) */
|
||||||
|
canCreateQuestion?: boolean
|
||||||
highlightedKpId: string | null
|
highlightedKpId: string | null
|
||||||
onHighlight: (id: string) => void
|
onHighlight: (id: string) => void
|
||||||
onEdit: (kp: KnowledgePoint) => void
|
onEdit: (kp: KnowledgePoint) => void
|
||||||
@@ -21,16 +24,19 @@ interface KnowledgePointListProps {
|
|||||||
export function KnowledgePointList({
|
export function KnowledgePointList({
|
||||||
knowledgePoints,
|
knowledgePoints,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
canCreateQuestion = false,
|
||||||
highlightedKpId,
|
highlightedKpId,
|
||||||
onHighlight,
|
onHighlight,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onCreateQuestion,
|
onCreateQuestion,
|
||||||
}: KnowledgePointListProps) {
|
}: KnowledgePointListProps) {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
|
|
||||||
if (knowledgePoints.length === 0) {
|
if (knowledgePoints.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||||
该章节暂无知识点。
|
{t("reader.emptyKnowledge")}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -51,22 +57,27 @@ export function KnowledgePointList({
|
|||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
|
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
|
||||||
<div className="flex items-center gap-1">
|
<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 && (
|
{canEdit && (
|
||||||
<div className="flex items-center gap-1 ml-1">
|
<div className="flex items-center gap-1 ml-1">
|
||||||
<Button
|
{canCreateQuestion && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
size="icon"
|
||||||
onClick={(e) => {
|
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||||
e.stopPropagation()
|
onClick={(e) => {
|
||||||
onCreateQuestion(kp)
|
e.stopPropagation()
|
||||||
}}
|
onCreateQuestion(kp)
|
||||||
title="创建相关题目"
|
}}
|
||||||
aria-label="创建相关题目"
|
title={t("dialog.knowledge.createQuestion")}
|
||||||
>
|
aria-label={t("dialog.knowledge.createQuestion")}
|
||||||
<PlusCircle className="h-3 w-3" />
|
>
|
||||||
</Button>
|
<PlusCircle className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -75,8 +86,8 @@ export function KnowledgePointList({
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onEdit(kp)
|
onEdit(kp)
|
||||||
}}
|
}}
|
||||||
title="编辑知识点"
|
title={t("dialog.knowledge.editKp")}
|
||||||
aria-label="编辑知识点"
|
aria-label={t("dialog.knowledge.editKp")}
|
||||||
>
|
>
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -85,8 +96,8 @@ export function KnowledgePointList({
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||||
onClick={(e) => onDelete(kp.id, e)}
|
onClick={(e) => onDelete(kp.id, e)}
|
||||||
title="删除知识点"
|
title={t("dialog.knowledge.deleteKp")}
|
||||||
aria-label="删除知识点"
|
aria-label={t("dialog.knowledge.deleteKp")}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { Tag, Trash2 } from "lucide-react"
|
|
||||||
import { KnowledgePoint } from "../types"
|
|
||||||
import { CreateKnowledgePointDialog } from "./create-knowledge-point-dialog"
|
|
||||||
import { deleteKnowledgePointAction } from "../actions"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/shared/components/ui/alert-dialog"
|
|
||||||
|
|
||||||
interface KnowledgePointPanelProps {
|
|
||||||
knowledgePoints: KnowledgePoint[]
|
|
||||||
selectedChapterId: string | null
|
|
||||||
textbookId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KnowledgePointPanel({
|
|
||||||
knowledgePoints,
|
|
||||||
selectedChapterId,
|
|
||||||
textbookId
|
|
||||||
}: KnowledgePointPanelProps) {
|
|
||||||
const router = useRouter()
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<KnowledgePoint | null>(null)
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
|
||||||
|
|
||||||
const requestDelete = (kp: KnowledgePoint) => {
|
|
||||||
setDeleteTarget(kp)
|
|
||||||
setShowDeleteDialog(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!deleteTarget) return
|
|
||||||
setIsDeleting(true)
|
|
||||||
try {
|
|
||||||
const result = await deleteKnowledgePointAction(deleteTarget.id, textbookId)
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(result.message)
|
|
||||||
setShowDeleteDialog(false)
|
|
||||||
setDeleteTarget(null)
|
|
||||||
router.refresh()
|
|
||||||
} else {
|
|
||||||
toast.error(result.message)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to delete knowledge point")
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter KPs for the selected chapter
|
|
||||||
const chapterKPs = selectedChapterId
|
|
||||||
? knowledgePoints.filter(kp => kp.chapterId === selectedChapterId)
|
|
||||||
: []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
<div className="flex items-center justify-between p-4 border-b">
|
|
||||||
<h3 className="font-semibold text-sm uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
|
||||||
<Tag className="h-4 w-4" />
|
|
||||||
Knowledge Points
|
|
||||||
</h3>
|
|
||||||
{selectedChapterId && (
|
|
||||||
<CreateKnowledgePointDialog
|
|
||||||
chapterId={selectedChapterId}
|
|
||||||
textbookId={textbookId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
<div className="p-4 space-y-3">
|
|
||||||
{selectedChapterId ? (
|
|
||||||
chapterKPs.length > 0 ? (
|
|
||||||
<>
|
|
||||||
{chapterKPs.map((kp) => (
|
|
||||||
<Card key={kp.id} className="relative group hover:shadow-sm transition-shadow">
|
|
||||||
<CardContent className="p-3">
|
|
||||||
<div className="flex justify-between items-start gap-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="font-medium text-sm leading-tight text-foreground">
|
|
||||||
{kp.name}
|
|
||||||
</div>
|
|
||||||
{kp.description && (
|
|
||||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
|
||||||
{kp.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 -mr-1 -mt-1 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() => requestDelete(kp)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center space-y-3">
|
|
||||||
<div className="p-3 rounded-full bg-muted/50">
|
|
||||||
<Tag className="h-6 w-6 text-muted-foreground/40" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">No points yet</p>
|
|
||||||
<p className="text-xs text-muted-foreground/60 max-w-[160px]">
|
|
||||||
Add knowledge points to tag content in this chapter.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground space-y-2">
|
|
||||||
<p className="text-sm">Select a chapter to manage knowledge points</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete Knowledge Point?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This action cannot be undone. This will permanently delete the knowledge point
|
|
||||||
<span className="font-medium text-foreground"> {deleteTarget?.name}</span>.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={isDeleting}>
|
|
||||||
{isDeleting ? "Deleting..." : "Delete"}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
67
src/modules/textbooks/components/section-error-boundary.tsx
Normal file
67
src/modules/textbooks/components/section-error-boundary.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教材模块内联 Error Boundary。
|
||||||
|
*
|
||||||
|
* 用于包裹独立数据区块(章节树、内容区、知识点区、图谱区),
|
||||||
|
* 隔离故障域,避免单点错误导致整个阅读器白屏。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, type ReactNode } from "react"
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
|
||||||
|
interface TextbookSectionErrorBoundaryProps {
|
||||||
|
children: ReactNode
|
||||||
|
/** 自定义降级 UI 标题 */
|
||||||
|
fallbackTitle?: string
|
||||||
|
/** 自定义降级 UI 描述 */
|
||||||
|
fallbackDescription?: string
|
||||||
|
/** 重试按钮文案 */
|
||||||
|
retryLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextbookSectionErrorBoundaryState {
|
||||||
|
hasError: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TextbookSectionErrorBoundary extends Component<
|
||||||
|
TextbookSectionErrorBoundaryProps,
|
||||||
|
TextbookSectionErrorBoundaryState
|
||||||
|
> {
|
||||||
|
constructor(props: TextbookSectionErrorBoundaryProps) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): TextbookSectionErrorBoundaryState {
|
||||||
|
return { hasError: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = (): void => {
|
||||||
|
this.setState({ hasError: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): ReactNode {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<AlertCircle className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{this.props.fallbackTitle ?? "区块加载失败"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{this.props.fallbackDescription ?? "请重试或刷新页面"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={this.handleReset}>
|
||||||
|
{this.props.retryLabel ?? "重试"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,41 +1,36 @@
|
|||||||
import Link from "next/link";
|
"use client"
|
||||||
import { Book, MoreVertical, Edit, Trash2, BookOpen, GraduationCap, Building2 } from "lucide-react";
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { Book, MoreVertical, Edit, Trash2, BookOpen, GraduationCap, Building2 } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
} from "@/shared/components/ui/card";
|
} from "@/shared/components/ui/card"
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/shared/components/ui/dropdown-menu";
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
import { cn, formatDate } from "@/shared/lib/utils";
|
import { cn, formatDate } from "@/shared/lib/utils"
|
||||||
import { Textbook } from "../types";
|
import type { Textbook } from "../types"
|
||||||
|
import { getSubjectColor } from "../constants"
|
||||||
|
|
||||||
interface TextbookCardProps {
|
interface TextbookCardProps {
|
||||||
textbook: Textbook;
|
textbook: Textbook
|
||||||
hrefBase?: string;
|
hrefBase?: string
|
||||||
hideActions?: boolean;
|
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) {
|
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
|
||||||
const base = hrefBase || "/teacher/textbooks";
|
const t = useTranslations("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 base = hrefBase || "/teacher/textbooks"
|
||||||
|
const colorClass = getSubjectColor(textbook.subject)
|
||||||
|
|
||||||
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">
|
||||||
@@ -50,7 +45,7 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
|
|||||||
<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 || "Grade N/A"}
|
{textbook.grade || t("card.gradeNA")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 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 || "Grade N/A"}</span>
|
<span>{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" />
|
||||||
<span className="truncate max-w-[120px]" title={textbook.publisher || ""}>
|
<span className="truncate max-w-[120px]" title={textbook.publisher || ""}>
|
||||||
{textbook.publisher || "Publisher N/A"}
|
{textbook.publisher || t("card.publisherNA")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Link>
|
</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 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">
|
<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" />
|
<BookOpen className="h-3.5 w-3.5" />
|
||||||
</div>
|
</div>
|
||||||
<span>{textbook._count?.chapters || 0} Chapters</span>
|
<span>
|
||||||
|
{textbook._count?.chapters || 0} {t("card.chapters")}
|
||||||
|
</span>
|
||||||
</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-[10px] text-muted-foreground/60 mr-2">
|
||||||
Updated {formatDate(textbook.updatedAt)}
|
{t("card.updated")} {formatDate(textbook.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
{!hideActions && (
|
{!hideActions && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 -mr-2">
|
<Button variant="ghost" size="icon" className="h-6 w-6 -mr-2">
|
||||||
<MoreVertical className="h-3.5 w-3.5" />
|
<MoreVertical className="h-3.5 w-3.5" />
|
||||||
<span className="sr-only">More options</span>
|
<span className="sr-only">{t("card.moreOptions")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`${base}/${textbook.id}`}>
|
<Link href={`${base}/${textbook.id}`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit Content
|
{t("card.editContent")}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
{t("card.delete")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import remarkBreaks from "remark-breaks"
|
|||||||
import remarkGfm from "remark-gfm"
|
import remarkGfm from "remark-gfm"
|
||||||
import rehypeSanitize from "rehype-sanitize"
|
import rehypeSanitize from "rehype-sanitize"
|
||||||
import { Edit2, Save, Plus } from "lucide-react"
|
import { Edit2, Save, Plus } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import type { Chapter, KnowledgePoint } from "../types"
|
import type { Chapter, KnowledgePoint } from "../types"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
@@ -63,10 +64,12 @@ export function TextbookContentPanel({
|
|||||||
isSaving,
|
isSaving,
|
||||||
processedContent,
|
processedContent,
|
||||||
}: TextbookContentPanelProps) {
|
}: TextbookContentPanelProps) {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
|
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
|
||||||
请选择一个章节开始阅读。
|
{t("reader.selectChapter")}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -80,17 +83,17 @@ export function TextbookContentPanel({
|
|||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" variant="ghost" onClick={cancelEditing} disabled={isSaving}>
|
<Button size="sm" variant="ghost" onClick={cancelEditing} disabled={isSaving}>
|
||||||
取消
|
{t("reader.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={saveContent} disabled={isSaving}>
|
<Button size="sm" onClick={saveContent} disabled={isSaving}>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
{isSaving ? "保存中..." : "保存"}
|
{isSaving ? t("reader.saving") : t("reader.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button size="sm" variant="outline" onClick={startEditing}>
|
<Button size="sm" variant="outline" onClick={startEditing}>
|
||||||
<Edit2 className="mr-2 h-4 w-4" />
|
<Edit2 className="mr-2 h-4 w-4" />
|
||||||
编辑内容
|
{t("reader.editContent")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +138,7 @@ export function TextbookContentPanel({
|
|||||||
onHighlight(id)
|
onHighlight(id)
|
||||||
onSwitchToKnowledgeTab()
|
onSwitchToKnowledgeTab()
|
||||||
}}
|
}}
|
||||||
title="点击查看知识点详情"
|
title={t("reader.clickToViewKp")}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
@@ -149,7 +152,9 @@ export function TextbookContentPanel({
|
|||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
@@ -159,7 +164,7 @@ export function TextbookContentPanel({
|
|||||||
onClick={() => setCreateDialogOpen(true)}
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
添加知识点
|
{t("reader.addKnowledgePoint")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -10,8 +11,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||||
|
import { SUBJECTS, GRADES } from "../constants"
|
||||||
|
|
||||||
export function TextbookFilters() {
|
export function TextbookFilters() {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||||
const [subject, setSubject] = useQueryState("subject", parseAsString.withDefault("all"))
|
const [subject, setSubject] = useQueryState("subject", parseAsString.withDefault("all"))
|
||||||
const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all"))
|
const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all"))
|
||||||
@@ -31,38 +34,35 @@ export function TextbookFilters() {
|
|||||||
<FilterSearchInput
|
<FilterSearchInput
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(v) => setSearch(v || null)}
|
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">
|
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||||
<Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}>
|
<Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}>
|
||||||
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
|
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
|
||||||
<SelectValue placeholder="Subject" />
|
<SelectValue placeholder={t("field.subject")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Subjects</SelectItem>
|
<SelectItem value="all">{t("filters.allSubjects")}</SelectItem>
|
||||||
<SelectItem value="Mathematics">Mathematics</SelectItem>
|
{SUBJECTS.map((s) => (
|
||||||
<SelectItem value="Physics">Physics</SelectItem>
|
<SelectItem key={s.value} value={s.value}>
|
||||||
<SelectItem value="Chemistry">Chemistry</SelectItem>
|
{t(`subject.${s.labelKey}`)}
|
||||||
<SelectItem value="Biology">Biology</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="English">English</SelectItem>
|
))}
|
||||||
<SelectItem value="History">History</SelectItem>
|
|
||||||
<SelectItem value="Geography">Geography</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={grade} onValueChange={(val) => setGrade(val === "all" ? null : val)}>
|
<Select value={grade} onValueChange={(val) => setGrade(val === "all" ? null : val)}>
|
||||||
<SelectTrigger className="w-[130px] bg-background border-muted-foreground/20">
|
<SelectTrigger className="w-[130px] bg-background border-muted-foreground/20">
|
||||||
<SelectValue placeholder="Grade" />
|
<SelectValue placeholder={t("field.grade")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Grades</SelectItem>
|
<SelectItem value="all">{t("filters.allGrades")}</SelectItem>
|
||||||
<SelectItem value="Grade 7">Grade 7</SelectItem>
|
{GRADES.map((g) => (
|
||||||
<SelectItem value="Grade 8">Grade 8</SelectItem>
|
<SelectItem key={g.value} value={g.value}>
|
||||||
<SelectItem value="Grade 9">Grade 9</SelectItem>
|
{t(`grade.${g.labelKey}`)}
|
||||||
<SelectItem value="Grade 10">Grade 10</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="Grade 11">Grade 11</SelectItem>
|
))}
|
||||||
<SelectItem value="Grade 12">Grade 12</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Plus } from "lucide-react"
|
import { Plus } from "lucide-react"
|
||||||
import { useFormStatus } from "react-dom"
|
import { useFormStatus } from "react-dom"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -23,22 +24,23 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { createTextbookAction } from "../actions"
|
import { createTextbookAction } from "../actions"
|
||||||
|
import { SUBJECTS, GRADES } from "../constants"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
function SubmitButton() {
|
function SubmitButton() {
|
||||||
const { pending } = useFormStatus()
|
const { pending } = useFormStatus()
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
return (
|
return (
|
||||||
<Button type="submit" disabled={pending}>
|
<Button type="submit" disabled={pending}>
|
||||||
{pending ? "Saving..." : "Save changes"}
|
{pending ? t("dialog.create.saving") : t("dialog.create.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextbookFormDialog() {
|
export function TextbookFormDialog() {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
const [open, setOpen] = useState(false)
|
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 handleSubmit = async (formData: FormData) => {
|
||||||
const result = await createTextbookAction(null, formData)
|
const result = await createTextbookAction(null, formData)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -54,72 +56,70 @@ export function TextbookFormDialog() {
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button>
|
<Button>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Textbook
|
{t("list.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add New Textbook</DialogTitle>
|
<DialogTitle>{t("dialog.create.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{t("dialog.create.description")}</DialogDescription>
|
||||||
Create a new digital textbook. Click save when you're done.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form action={handleSubmit}>
|
<form action={handleSubmit}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="title" className="text-right">
|
<Label htmlFor="title" className="text-right">
|
||||||
Title
|
{t("field.title")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
name="title"
|
name="title"
|
||||||
placeholder="e.g. Advanced Calculus"
|
placeholder={t("field.titlePlaceholder")}
|
||||||
className="col-span-3"
|
className="col-span-3"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="subject" className="text-right">
|
<Label htmlFor="subject" className="text-right">
|
||||||
Subject
|
{t("field.subject")}
|
||||||
</Label>
|
</Label>
|
||||||
<Select name="subject" required>
|
<Select name="subject" required>
|
||||||
<SelectTrigger className="col-span-3">
|
<SelectTrigger className="col-span-3">
|
||||||
<SelectValue placeholder="Select subject" />
|
<SelectValue placeholder={t("field.subjectPlaceholder")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="Mathematics">Mathematics</SelectItem>
|
{SUBJECTS.map((s) => (
|
||||||
<SelectItem value="Physics">Physics</SelectItem>
|
<SelectItem key={s.value} value={s.value}>
|
||||||
<SelectItem value="Chemistry">Chemistry</SelectItem>
|
{t(`subject.${s.labelKey}`)}
|
||||||
<SelectItem value="Biology">Biology</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="English">English</SelectItem>
|
))}
|
||||||
<SelectItem value="History">History</SelectItem>
|
|
||||||
<SelectItem value="Geography">Geography</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="grade" className="text-right">
|
<Label htmlFor="grade" className="text-right">
|
||||||
Grade
|
{t("field.grade")}
|
||||||
</Label>
|
</Label>
|
||||||
<Select name="grade" required>
|
<Select name="grade" required>
|
||||||
<SelectTrigger className="col-span-3">
|
<SelectTrigger className="col-span-3">
|
||||||
<SelectValue placeholder="Select grade" />
|
<SelectValue placeholder={t("field.gradePlaceholder")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="Grade 10">Grade 10</SelectItem>
|
{GRADES.map((g) => (
|
||||||
<SelectItem value="Grade 11">Grade 11</SelectItem>
|
<SelectItem key={g.value} value={g.value}>
|
||||||
<SelectItem value="Grade 12">Grade 12</SelectItem>
|
{t(`grade.${g.labelKey}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="publisher" className="text-right">
|
<Label htmlFor="publisher" className="text-right">
|
||||||
Publisher
|
{t("field.publisher")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="publisher"
|
id="publisher"
|
||||||
name="publisher"
|
name="publisher"
|
||||||
placeholder="e.g. Next Education"
|
placeholder={t("field.publisherPlaceholder")}
|
||||||
className="col-span-3"
|
className="col-span-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState, useEffect } from "react"
|
import { useMemo, useState, useEffect, type ReactNode } from "react"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { Tag, List, Share2 } from "lucide-react"
|
import { Tag, List, Share2 } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import type { Chapter, KnowledgePoint } from "../types"
|
import type { Chapter, KnowledgePoint } from "../types"
|
||||||
import { updateChapterContentAction } from "../actions"
|
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 { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
@@ -25,35 +28,46 @@ import { ChapterSidebarList } from "./chapter-sidebar-list"
|
|||||||
import { KnowledgeGraph } from "./knowledge-graph"
|
import { KnowledgeGraph } from "./knowledge-graph"
|
||||||
import { KnowledgePointList } from "./knowledge-point-list"
|
import { KnowledgePointList } from "./knowledge-point-list"
|
||||||
import { TextbookContentPanel } from "./textbook-content-panel"
|
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 { useTextSelection } from "../hooks/use-text-selection"
|
||||||
import { useKnowledgePointActions } from "../hooks/use-knowledge-point-actions"
|
import { useKnowledgePointActions } from "../hooks/use-knowledge-point-actions"
|
||||||
|
import { buildChapterIndex } from "../utils"
|
||||||
|
|
||||||
function buildChapterIndex(chapters: Chapter[]) {
|
export interface TextbookReaderProps {
|
||||||
const index = new Map<string, Chapter>()
|
chapters: Chapter[]
|
||||||
|
knowledgePoints?: KnowledgePoint[]
|
||||||
const walk = (nodes: Chapter[]) => {
|
/**
|
||||||
for (const node of nodes) {
|
* 是否可编辑。已废弃——改由内部 usePermission() 自动判断。
|
||||||
index.set(node.id, node)
|
* 保留 prop 仅为向后兼容,传入值会被忽略。
|
||||||
if (node.children && node.children.length > 0) walk(node.children)
|
* @deprecated 改用权限系统自动判断
|
||||||
}
|
*/
|
||||||
}
|
canEdit?: boolean
|
||||||
|
textbookId?: string
|
||||||
walk(chapters)
|
/**
|
||||||
return index
|
* 题目创建器渲染函数(P0-1 解耦)。
|
||||||
|
* 由页面层注入 questions 模块的 CreateQuestionDialog 实现。
|
||||||
|
* 不传则不渲染题目创建入口。
|
||||||
|
*/
|
||||||
|
renderQuestionCreator?: (props: QuestionCreatorRenderProps) => ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextbookReader({
|
export function TextbookReader({
|
||||||
chapters,
|
chapters,
|
||||||
knowledgePoints = [],
|
knowledgePoints = [],
|
||||||
canEdit = false,
|
|
||||||
textbookId,
|
textbookId,
|
||||||
}: {
|
renderQuestionCreator,
|
||||||
chapters: Chapter[]
|
}: TextbookReaderProps) {
|
||||||
knowledgePoints?: KnowledgePoint[]
|
const t = useTranslations("textbooks")
|
||||||
canEdit?: boolean
|
const { hasPermission } = usePermission()
|
||||||
textbookId?: string
|
|
||||||
}) {
|
// P0-2 前端权限改由 usePermission 判断,不再接受外部 canEdit 硬编码
|
||||||
|
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
|
||||||
|
const canCreateQuestion = hasPermission(Permissions.QUESTION_CREATE)
|
||||||
|
|
||||||
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
|
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
|
||||||
const [activeTab, setActiveTab] = useState("chapters")
|
const [activeTab, setActiveTab] = useState("chapters")
|
||||||
const [highlightedKpId, setHighlightedKpId] = useState<string | null>(null)
|
const [highlightedKpId, setHighlightedKpId] = useState<string | null>(null)
|
||||||
@@ -185,11 +199,11 @@ export function TextbookReader({
|
|||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="chapters" className="gap-2">
|
<TabsTrigger value="chapters" className="gap-2">
|
||||||
<List className="h-4 w-4" />
|
<List className="h-4 w-4" />
|
||||||
章节目录
|
{t("reader.tabs.chapters")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
|
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
|
||||||
<Tag className="h-4 w-4" />
|
<Tag className="h-4 w-4" />
|
||||||
知识点
|
{t("reader.tabs.knowledge")}
|
||||||
{currentChapterKPs.length > 0 && (
|
{currentChapterKPs.length > 0 && (
|
||||||
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">
|
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">
|
||||||
{currentChapterKPs.length}
|
{currentChapterKPs.length}
|
||||||
@@ -198,61 +212,80 @@ export function TextbookReader({
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="graph" className="gap-2" disabled={!selectedId}>
|
<TabsTrigger value="graph" className="gap-2" disabled={!selectedId}>
|
||||||
<Share2 className="h-4 w-4" />
|
<Share2 className="h-4 w-4" />
|
||||||
图谱
|
{t("reader.tabs.graph")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
|
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
|
||||||
<ScrollArea className="flex-1 h-full px-2">
|
<TextbookSectionErrorBoundary
|
||||||
<div className="space-y-1 pb-4">
|
fallbackTitle={t("error.loadFailed")}
|
||||||
<ChapterSidebarList
|
fallbackDescription={t("error.loadFailedDesc")}
|
||||||
chapters={chapters}
|
retryLabel={t("error.retry")}
|
||||||
selectedChapterId={selectedId || undefined}
|
>
|
||||||
onSelectChapter={handleSelect}
|
<ScrollArea className="flex-1 h-full px-2">
|
||||||
textbookId={textbookId || ""}
|
<div className="space-y-1 pb-4">
|
||||||
canEdit={canEdit}
|
<ChapterSidebarList
|
||||||
/>
|
chapters={chapters}
|
||||||
</div>
|
selectedChapterId={selectedId || undefined}
|
||||||
</ScrollArea>
|
onSelectChapter={handleSelect}
|
||||||
|
textbookId={textbookId || ""}
|
||||||
|
canEdit={canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TextbookSectionErrorBoundary>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
|
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
|
||||||
{!selectedId ? (
|
<TextbookSectionErrorBoundary
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
fallbackTitle={t("error.loadFailed")}
|
||||||
请选择一个章节查看知识点。
|
fallbackDescription={t("error.loadFailedDesc")}
|
||||||
</div>
|
retryLabel={t("error.retry")}
|
||||||
) : (
|
>
|
||||||
<KnowledgePointList
|
{!selectedId ? (
|
||||||
knowledgePoints={currentChapterKPs}
|
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||||
canEdit={canEdit}
|
{t("reader.selectChapterKnowledge")}
|
||||||
highlightedKpId={highlightedKpId}
|
</div>
|
||||||
onHighlight={setHighlightedKpId}
|
) : (
|
||||||
onEdit={(kp) => {
|
<KnowledgePointList
|
||||||
setEditingKp(kp)
|
knowledgePoints={currentChapterKPs}
|
||||||
setEditKpDialogOpen(true)
|
canEdit={canEdit}
|
||||||
}}
|
canCreateQuestion={canCreateQuestion}
|
||||||
onDelete={requestDeleteKnowledgePoint}
|
highlightedKpId={highlightedKpId}
|
||||||
onCreateQuestion={(kp) => {
|
onHighlight={setHighlightedKpId}
|
||||||
setTargetKpForQuestion(kp)
|
onEdit={(kp) => {
|
||||||
setQuestionDialogOpen(true)
|
setEditingKp(kp)
|
||||||
}}
|
setEditKpDialogOpen(true)
|
||||||
/>
|
}}
|
||||||
)}
|
onDelete={requestDeleteKnowledgePoint}
|
||||||
|
onCreateQuestion={(kp) => {
|
||||||
|
setTargetKpForQuestion(kp)
|
||||||
|
setQuestionDialogOpen(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TextbookSectionErrorBoundary>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
|
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
|
||||||
{!selectedId ? (
|
<TextbookSectionErrorBoundary
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
fallbackTitle={t("error.loadFailed")}
|
||||||
请选择一个章节查看知识图谱。
|
fallbackDescription={t("error.loadFailedDesc")}
|
||||||
</div>
|
retryLabel={t("error.retry")}
|
||||||
) : (
|
>
|
||||||
<KnowledgeGraph
|
{!selectedId ? (
|
||||||
knowledgePoints={currentChapterKPs}
|
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||||
selectedId={highlightedKpId}
|
{t("reader.selectChapterGraph")}
|
||||||
onHighlight={setHighlightedKpId}
|
</div>
|
||||||
/>
|
) : (
|
||||||
)}
|
<KnowledgeGraph
|
||||||
|
knowledgePoints={currentChapterKPs}
|
||||||
|
selectedId={highlightedKpId}
|
||||||
|
onHighlight={setHighlightedKpId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TextbookSectionErrorBoundary>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,14 +294,14 @@ export function TextbookReader({
|
|||||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
<AlertDialogTitle>{t("dialog.knowledge.deleteTitle")}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>{t("dialog.knowledge.deleteDesc")}</AlertDialogDescription>
|
||||||
确定要删除这个知识点吗?此操作无法撤销。
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
<AlertDialogCancel>{t("dialog.knowledge.cancel")}</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={confirmDeleteKnowledgePoint}>删除</AlertDialogAction>
|
<AlertDialogAction onClick={confirmDeleteKnowledgePoint}>
|
||||||
|
{t("dialog.knowledge.delete")}
|
||||||
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
@@ -287,32 +320,39 @@ export function TextbookReader({
|
|||||||
questionDialogOpen={questionDialogOpen}
|
questionDialogOpen={questionDialogOpen}
|
||||||
setQuestionDialogOpen={setQuestionDialogOpen}
|
setQuestionDialogOpen={setQuestionDialogOpen}
|
||||||
targetKpForQuestion={targetKpForQuestion}
|
targetKpForQuestion={targetKpForQuestion}
|
||||||
|
renderQuestionCreator={canCreateQuestion ? renderQuestionCreator : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextbookContentPanel
|
<TextbookSectionErrorBoundary
|
||||||
selected={selected}
|
fallbackTitle={t("error.loadFailed")}
|
||||||
isEditing={isEditing}
|
fallbackDescription={t("error.loadFailedDesc")}
|
||||||
editContent={editContent}
|
retryLabel={t("error.retry")}
|
||||||
setEditContent={setEditContent}
|
>
|
||||||
canEdit={canEdit}
|
<TextbookContentPanel
|
||||||
knowledgePoints={currentChapterKPs}
|
selected={selected}
|
||||||
highlightedKpId={highlightedKpId}
|
isEditing={isEditing}
|
||||||
onHighlight={setHighlightedKpId}
|
editContent={editContent}
|
||||||
onSwitchToKnowledgeTab={() => setActiveTab("knowledge")}
|
setEditContent={setEditContent}
|
||||||
contentRef={contentRef}
|
canEdit={canEdit}
|
||||||
onPointerDown={handleContentPointerDown}
|
knowledgePoints={currentChapterKPs}
|
||||||
onContextMenuChange={handleContextMenuChange}
|
highlightedKpId={highlightedKpId}
|
||||||
selectedText={selectedText}
|
onHighlight={setHighlightedKpId}
|
||||||
createDialogOpen={createDialogOpen}
|
onSwitchToKnowledgeTab={() => setActiveTab("knowledge")}
|
||||||
setCreateDialogOpen={setCreateDialogOpen}
|
contentRef={contentRef}
|
||||||
isCreating={isCreating}
|
onPointerDown={handleContentPointerDown}
|
||||||
onCreateKnowledgePoint={onCreateKnowledgePoint}
|
onContextMenuChange={handleContextMenuChange}
|
||||||
startEditing={startEditing}
|
selectedText={selectedText}
|
||||||
cancelEditing={() => setIsEditing(false)}
|
createDialogOpen={createDialogOpen}
|
||||||
saveContent={handleSaveContent}
|
setCreateDialogOpen={setCreateDialogOpen}
|
||||||
isSaving={isSaving}
|
isCreating={isCreating}
|
||||||
processedContent={processedContent}
|
onCreateKnowledgePoint={onCreateKnowledgePoint}
|
||||||
/>
|
startEditing={startEditing}
|
||||||
|
cancelEditing={() => setIsEditing(false)}
|
||||||
|
saveContent={handleSaveContent}
|
||||||
|
isSaving={isSaving}
|
||||||
|
processedContent={processedContent}
|
||||||
|
/>
|
||||||
|
</TextbookSectionErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Edit } from "lucide-react"
|
import { Edit } from "lucide-react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -13,6 +14,16 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/shared/components/ui/dialog"
|
} 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 { Input } from "@/shared/components/ui/input"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import {
|
import {
|
||||||
@@ -23,8 +34,9 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { updateTextbookAction, deleteTextbookAction } from "../actions"
|
import { updateTextbookAction, deleteTextbookAction } from "../actions"
|
||||||
|
import { SUBJECTS, GRADES } from "../constants"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Textbook } from "../types"
|
import type { Textbook } from "../types"
|
||||||
|
|
||||||
interface TextbookSettingsDialogProps {
|
interface TextbookSettingsDialogProps {
|
||||||
textbook: Textbook
|
textbook: Textbook
|
||||||
@@ -32,8 +44,10 @@ interface TextbookSettingsDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDialogProps) {
|
export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDialogProps) {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const handleUpdate = async (formData: FormData) => {
|
const handleUpdate = async (formData: FormData) => {
|
||||||
@@ -48,15 +62,15 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P1-8 用 AlertDialog 替换浏览器原生 confirm()
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!confirm("Are you sure you want to delete this textbook? This action cannot be undone.")) return
|
setDeleteDialogOpen(false)
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await deleteTextbookAction(textbook.id)
|
const result = await deleteTextbookAction(textbook.id)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message)
|
toast.success(result.message)
|
||||||
router.push("/teacher/textbooks") // Redirect after delete
|
router.push("/teacher/textbooks")
|
||||||
} else {
|
} else {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
toast.error(result.message)
|
toast.error(result.message)
|
||||||
@@ -69,23 +83,21 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
|
|||||||
{trigger || (
|
{trigger || (
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Settings
|
{t("dialog.settings.trigger")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Textbook Settings</DialogTitle>
|
<DialogTitle>{t("dialog.settings.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{t("dialog.settings.description")}</DialogDescription>
|
||||||
Update textbook details or delete this textbook.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form action={handleUpdate}>
|
<form action={handleUpdate}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="title" className="text-right">
|
<Label htmlFor="title" className="text-right">
|
||||||
Title
|
{t("field.title")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
@@ -97,39 +109,41 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="subject" className="text-right">
|
<Label htmlFor="subject" className="text-right">
|
||||||
Subject
|
{t("field.subject")}
|
||||||
</Label>
|
</Label>
|
||||||
<Select name="subject" defaultValue={textbook.subject} required>
|
<Select name="subject" defaultValue={textbook.subject} required>
|
||||||
<SelectTrigger className="col-span-3">
|
<SelectTrigger className="col-span-3">
|
||||||
<SelectValue placeholder="Select subject" />
|
<SelectValue placeholder={t("field.subjectPlaceholder")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="Mathematics">Mathematics</SelectItem>
|
{SUBJECTS.map((s) => (
|
||||||
<SelectItem value="Physics">Physics</SelectItem>
|
<SelectItem key={s.value} value={s.value}>
|
||||||
<SelectItem value="History">History</SelectItem>
|
{t(`subject.${s.labelKey}`)}
|
||||||
<SelectItem value="English">English</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="Chemistry">Chemistry</SelectItem>
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="grade" className="text-right">
|
<Label htmlFor="grade" className="text-right">
|
||||||
Grade
|
{t("field.grade")}
|
||||||
</Label>
|
</Label>
|
||||||
<Select name="grade" defaultValue={textbook.grade || undefined} required>
|
<Select name="grade" defaultValue={textbook.grade || undefined} required>
|
||||||
<SelectTrigger className="col-span-3">
|
<SelectTrigger className="col-span-3">
|
||||||
<SelectValue placeholder="Select grade" />
|
<SelectValue placeholder={t("field.gradePlaceholder")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="Grade 10">Grade 10</SelectItem>
|
{GRADES.map((g) => (
|
||||||
<SelectItem value="Grade 11">Grade 11</SelectItem>
|
<SelectItem key={g.value} value={g.value}>
|
||||||
<SelectItem value="Grade 12">Grade 12</SelectItem>
|
{t(`grade.${g.labelKey}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="publisher" className="text-right">
|
<Label htmlFor="publisher" className="text-right">
|
||||||
Publisher
|
{t("field.publisher")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="publisher"
|
id="publisher"
|
||||||
@@ -139,22 +153,39 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="flex justify-between sm:justify-between">
|
<DialogFooter className="flex justify-between sm:justify-between">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleDelete}
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? "Processing..." : "Delete Textbook"}
|
{loading ? t("dialog.settings.processing") : t("dialog.settings.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
{loading ? "Saving..." : "Save Changes"}
|
{loading ? t("dialog.settings.processing") : t("dialog.settings.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</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>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
75
src/modules/textbooks/constants.ts
Normal file
75
src/modules/textbooks/constants.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -19,57 +19,21 @@ import type {
|
|||||||
UpdateKnowledgePointInput,
|
UpdateKnowledgePointInput,
|
||||||
UpdateTextbookInput,
|
UpdateTextbookInput,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
|
import {
|
||||||
|
buildChapterTree,
|
||||||
|
normalizeOptional,
|
||||||
|
sortChapters,
|
||||||
|
} from "./utils"
|
||||||
|
|
||||||
const normalizeOptional = (v: string | null | undefined): string | null => {
|
export { buildChapterTree, normalizeOptional, sortChapters }
|
||||||
const trimmed = v?.trim()
|
|
||||||
if (!trimmed) return null
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortChapters = (a: Chapter, b: Chapter): number => {
|
/**
|
||||||
const ao = a.order ?? 0
|
* 数据范围过滤参数。
|
||||||
const bo = b.order ?? 0
|
* 学生端应传入 grade 按年级过滤;教师/admin 端不传则返回全量。
|
||||||
if (ao !== bo) return ao - bo
|
*/
|
||||||
return a.title.localeCompare(b.title)
|
export interface TextbookQueryScope {
|
||||||
}
|
/** 按年级过滤(学生端传入学生所在年级) */
|
||||||
|
grade?: string
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTextbooks = cache(async (query?: string, subject?: string, grade?: string): Promise<Textbook[]> => {
|
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
|
// Cross-module query interfaces — read-only access for other modules
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
95
src/modules/textbooks/graph-layout.test.ts
Normal file
95
src/modules/textbooks/graph-layout.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
141
src/modules/textbooks/graph-layout.ts
Normal file
141
src/modules/textbooks/graph-layout.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import type { KnowledgePoint } from "../types"
|
import type { KnowledgePoint } from "../types"
|
||||||
@@ -18,6 +19,7 @@ export function useKnowledgePointActions(
|
|||||||
setHighlightedKpId: (id: string | null) => void,
|
setHighlightedKpId: (id: string | null) => void,
|
||||||
onKpCreated?: () => void,
|
onKpCreated?: () => void,
|
||||||
) {
|
) {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
const [editingKp, setEditingKp] = useState<KnowledgePoint | null>(null)
|
const [editingKp, setEditingKp] = useState<KnowledgePoint | null>(null)
|
||||||
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
|
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
|
||||||
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
|
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
|
||||||
@@ -37,16 +39,16 @@ export function useKnowledgePointActions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success("知识点已创建")
|
toast.success(t("action.kpCreateSuccess"))
|
||||||
onKpCreated?.()
|
onKpCreated?.()
|
||||||
window.getSelection()?.removeAllRanges()
|
window.getSelection()?.removeAllRanges()
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "创建知识点失败")
|
toast.error(result.message || t("action.kpCreateFailed"))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("发生错误")
|
toast.error(t("action.errorOccurred"))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,7 +77,7 @@ export function useKnowledgePointActions(
|
|||||||
toast.error(result.message)
|
toast.error(result.message)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("删除失败")
|
toast.error(t("action.deleteFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setPendingDeleteKpId(null)
|
setPendingDeleteKpId(null)
|
||||||
}
|
}
|
||||||
@@ -95,7 +97,7 @@ export function useKnowledgePointActions(
|
|||||||
toast.error(result.message)
|
toast.error(result.message)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("更新失败")
|
toast.error(t("action.updateFailedGeneric"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsUpdatingKp(false)
|
setIsUpdatingKp(false)
|
||||||
}
|
}
|
||||||
|
|||||||
181
src/modules/textbooks/utils.test.ts
Normal file
181
src/modules/textbooks/utils.test.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
129
src/modules/textbooks/utils.ts
Normal file
129
src/modules/textbooks/utils.ts
Normal 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[] }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建章节索引 Map(id → 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
|
||||||
|
}
|
||||||
@@ -212,7 +212,7 @@ export const getUserWithRole = cache(
|
|||||||
* session and verifying the "student" role via JOIN users + usersToRoles + roles.
|
* 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.
|
* 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 session = await auth()
|
||||||
const userId = String(session?.user?.id ?? "").trim()
|
const userId = String(session?.user?.id ?? "").trim()
|
||||||
if (!userId) return null
|
if (!userId) return null
|
||||||
@@ -220,7 +220,15 @@ export const getCurrentStudentUser = cache(async (): Promise<{ id: string; name:
|
|||||||
const student = await getUserWithRole(userId, "student")
|
const student = await getUserWithRole(userId, "student")
|
||||||
|
|
||||||
if (!student) return null
|
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. */
|
/** Returns a map of userId -> { name, email } for the given user ids. */
|
||||||
|
|||||||
186
src/shared/i18n/messages/en/textbooks.json
Normal file
186
src/shared/i18n/messages/en/textbooks.json
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
{
|
||||||
|
"list": {
|
||||||
|
"title": "Textbooks",
|
||||||
|
"subtitle": "Manage your digital curriculum resources and chapters.",
|
||||||
|
"add": "Add Textbook",
|
||||||
|
"empty": {
|
||||||
|
"withFilters": "No textbooks match your filters",
|
||||||
|
"withoutFilters": "No textbooks yet",
|
||||||
|
"withFiltersDesc": "Try clearing filters or adjusting keywords.",
|
||||||
|
"withoutFiltersDesc": "Create your first textbook to start organizing chapters."
|
||||||
|
},
|
||||||
|
"clearFilters": "Clear filters",
|
||||||
|
"chapters": "Chapters"
|
||||||
|
},
|
||||||
|
"student": {
|
||||||
|
"list": {
|
||||||
|
"title": "Textbooks",
|
||||||
|
"subtitle": "Browse your course textbooks.",
|
||||||
|
"empty": {
|
||||||
|
"withFilters": "No textbooks match your filters",
|
||||||
|
"withoutFilters": "No textbooks yet",
|
||||||
|
"withFiltersDesc": "Try clearing filters or adjusting keywords.",
|
||||||
|
"withoutFiltersDesc": "No textbooks are available right now."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"noUser": "No user found",
|
||||||
|
"noUserDesc": "Create a student user to see textbooks."
|
||||||
|
},
|
||||||
|
"reader": {
|
||||||
|
"back": "Back to textbooks",
|
||||||
|
"tabs": {
|
||||||
|
"chapters": "Chapters",
|
||||||
|
"knowledge": "Knowledge Points",
|
||||||
|
"graph": "Graph"
|
||||||
|
},
|
||||||
|
"selectChapter": "Please select a chapter to start reading.",
|
||||||
|
"selectChapterKnowledge": "Please select a chapter to view knowledge points.",
|
||||||
|
"selectChapterGraph": "Please select a chapter to view the knowledge graph.",
|
||||||
|
"emptyKnowledge": "No knowledge points in this chapter yet.",
|
||||||
|
"emptyContent": "No content yet",
|
||||||
|
"editContent": "Edit Content",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"addKnowledgePoint": "Add Knowledge Point",
|
||||||
|
"clickToViewKp": "Click to view knowledge point details",
|
||||||
|
"noChapters": "No chapters",
|
||||||
|
"noChaptersDesc": "This textbook has no chapters yet."
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"create": {
|
||||||
|
"title": "Add New Textbook",
|
||||||
|
"description": "Create a new digital textbook. Click save when you're done.",
|
||||||
|
"submit": "Save changes",
|
||||||
|
"saving": "Saving..."
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Textbook Settings",
|
||||||
|
"description": "Update textbook details or delete this textbook.",
|
||||||
|
"delete": "Delete Textbook",
|
||||||
|
"deleteConfirmTitle": "Delete Textbook?",
|
||||||
|
"deleteConfirmDesc": "This action cannot be undone. This will permanently delete the textbook and all its chapters and knowledge points.",
|
||||||
|
"save": "Save Changes",
|
||||||
|
"processing": "Processing...",
|
||||||
|
"trigger": "Settings"
|
||||||
|
},
|
||||||
|
"chapter": {
|
||||||
|
"createTitle": "Add New Chapter",
|
||||||
|
"createDesc": "Create a new chapter or section.",
|
||||||
|
"submit": "Create Chapter",
|
||||||
|
"creating": "Creating...",
|
||||||
|
"deleteTitle": "Delete Chapter?",
|
||||||
|
"deleteDesc": "This will permanently delete {title}. This action cannot be undone.",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleting": "Deleting...",
|
||||||
|
"cannotDeleteWithSubchapters": "Cannot delete chapter with subchapters",
|
||||||
|
"addSubchapter": "Add Subchapter",
|
||||||
|
"titlePlaceholder": "e.g. Chapter 1: Introduction"
|
||||||
|
},
|
||||||
|
"knowledge": {
|
||||||
|
"createTitle": "Add Knowledge Point",
|
||||||
|
"createDesc": "Create a knowledge point from the selected text.",
|
||||||
|
"editTitle": "Edit Knowledge Point",
|
||||||
|
"editDesc": "Modify the knowledge point name and description.",
|
||||||
|
"name": "Name",
|
||||||
|
"description": "Description (optional)",
|
||||||
|
"descriptionPlaceholder": "Enter description...",
|
||||||
|
"displayName": "Display Name",
|
||||||
|
"anchorText": "Advanced: Anchor Text (affects highlighting)",
|
||||||
|
"anchorTextHint": "Changing this field will change the text highlighted in the content. Usually keep it consistent with the original.",
|
||||||
|
"create": "Create",
|
||||||
|
"creating": "Creating...",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"deleteTitle": "Confirm Delete",
|
||||||
|
"deleteDesc": "Are you sure you want to delete this knowledge point? This action cannot be undone.",
|
||||||
|
"delete": "Delete",
|
||||||
|
"createQuestion": "Create Related Question",
|
||||||
|
"editKp": "Edit Knowledge Point",
|
||||||
|
"deleteKp": "Delete Knowledge Point"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"field": {
|
||||||
|
"title": "Title",
|
||||||
|
"subject": "Subject",
|
||||||
|
"grade": "Grade",
|
||||||
|
"publisher": "Publisher",
|
||||||
|
"titlePlaceholder": "e.g. Advanced Calculus",
|
||||||
|
"publisherPlaceholder": "e.g. Next Education",
|
||||||
|
"subjectPlaceholder": "Select subject",
|
||||||
|
"gradePlaceholder": "Select grade"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"searchPlaceholder": "Search by title, publisher...",
|
||||||
|
"allSubjects": "All Subjects",
|
||||||
|
"allGrades": "All Grades"
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"chapters": "Chapters",
|
||||||
|
"updated": "Updated",
|
||||||
|
"gradeNA": "Grade N/A",
|
||||||
|
"publisherNA": "Publisher N/A",
|
||||||
|
"editContent": "Edit Content",
|
||||||
|
"delete": "Delete",
|
||||||
|
"moreOptions": "More options"
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"knowledgePoints": "Knowledge Points",
|
||||||
|
"noPointsYet": "No points yet",
|
||||||
|
"noPointsDesc": "Add knowledge points to tag content in this chapter.",
|
||||||
|
"selectChapter": "Select a chapter to manage knowledge points",
|
||||||
|
"level": "Lv."
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"mathematics": "Mathematics",
|
||||||
|
"physics": "Physics",
|
||||||
|
"chemistry": "Chemistry",
|
||||||
|
"biology": "Biology",
|
||||||
|
"english": "English",
|
||||||
|
"history": "History",
|
||||||
|
"geography": "Geography"
|
||||||
|
},
|
||||||
|
"grade": {
|
||||||
|
"grade7": "Grade 7",
|
||||||
|
"grade8": "Grade 8",
|
||||||
|
"grade9": "Grade 9",
|
||||||
|
"grade10": "Grade 10",
|
||||||
|
"grade11": "Grade 11",
|
||||||
|
"grade12": "Grade 12"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "Failed to load textbook",
|
||||||
|
"loadFailedDesc": "An error occurred while loading the textbook content. Please try again.",
|
||||||
|
"retry": "Retry"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"createSuccess": "Textbook created successfully.",
|
||||||
|
"createFailed": "Failed to create textbook.",
|
||||||
|
"updateSuccess": "Textbook updated successfully.",
|
||||||
|
"updateFailed": "Failed to update textbook.",
|
||||||
|
"deleteSuccess": "Textbook deleted successfully.",
|
||||||
|
"deleteFailed": "Failed to delete textbook.",
|
||||||
|
"chapterCreateSuccess": "Chapter created successfully",
|
||||||
|
"chapterCreateFailed": "Failed to create chapter",
|
||||||
|
"chapterDeleteSuccess": "Chapter deleted successfully",
|
||||||
|
"chapterDeleteFailed": "Failed to delete chapter",
|
||||||
|
"contentUpdateSuccess": "Content updated successfully",
|
||||||
|
"contentUpdateFailed": "Failed to update content",
|
||||||
|
"kpCreateSuccess": "Knowledge point created successfully",
|
||||||
|
"kpCreateFailed": "Failed to create knowledge point",
|
||||||
|
"kpUpdateSuccess": "Knowledge point updated successfully",
|
||||||
|
"kpUpdateFailed": "Failed to update knowledge point",
|
||||||
|
"kpDeleteSuccess": "Knowledge point deleted successfully",
|
||||||
|
"kpDeleteFailed": "Failed to delete knowledge point",
|
||||||
|
"reorderSuccess": "Order updated",
|
||||||
|
"reorderFailed": "Failed to reorder chapters",
|
||||||
|
"fillRequired": "Please fill in all required fields.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"nameRequired": "Name is required",
|
||||||
|
"invalidContent": "Invalid chapter content data",
|
||||||
|
"errorOccurred": "An error occurred",
|
||||||
|
"deleteFailed": "Deletion failed",
|
||||||
|
"updateFailedGeneric": "Update failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/shared/i18n/messages/zh-CN/textbooks.json
Normal file
186
src/shared/i18n/messages/zh-CN/textbooks.json
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
{
|
||||||
|
"list": {
|
||||||
|
"title": "教材",
|
||||||
|
"subtitle": "管理数字课程资源与章节。",
|
||||||
|
"add": "新建教材",
|
||||||
|
"empty": {
|
||||||
|
"withFilters": "没有匹配的教材",
|
||||||
|
"withoutFilters": "暂无教材",
|
||||||
|
"withFiltersDesc": "请尝试清除筛选或调整关键词。",
|
||||||
|
"withoutFiltersDesc": "创建你的第一本教材以开始组织章节。"
|
||||||
|
},
|
||||||
|
"clearFilters": "清除筛选",
|
||||||
|
"chapters": "章节"
|
||||||
|
},
|
||||||
|
"student": {
|
||||||
|
"list": {
|
||||||
|
"title": "教材",
|
||||||
|
"subtitle": "浏览你的课程教材。",
|
||||||
|
"empty": {
|
||||||
|
"withFilters": "没有匹配的教材",
|
||||||
|
"withoutFilters": "暂无教材",
|
||||||
|
"withFiltersDesc": "请尝试清除筛选或调整关键词。",
|
||||||
|
"withoutFiltersDesc": "暂时没有可用的教材。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"noUser": "未找到用户",
|
||||||
|
"noUserDesc": "请创建学生用户以查看教材。"
|
||||||
|
},
|
||||||
|
"reader": {
|
||||||
|
"back": "返回教材列表",
|
||||||
|
"tabs": {
|
||||||
|
"chapters": "章节目录",
|
||||||
|
"knowledge": "知识点",
|
||||||
|
"graph": "图谱"
|
||||||
|
},
|
||||||
|
"selectChapter": "请选择一个章节开始阅读。",
|
||||||
|
"selectChapterKnowledge": "请选择一个章节查看知识点。",
|
||||||
|
"selectChapterGraph": "请选择一个章节查看知识图谱。",
|
||||||
|
"emptyKnowledge": "该章节暂无知识点。",
|
||||||
|
"emptyContent": "暂无内容",
|
||||||
|
"editContent": "编辑内容",
|
||||||
|
"cancel": "取消",
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"addKnowledgePoint": "添加知识点",
|
||||||
|
"clickToViewKp": "点击查看知识点详情",
|
||||||
|
"noChapters": "暂无章节",
|
||||||
|
"noChaptersDesc": "这本教材还没有章节。"
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"create": {
|
||||||
|
"title": "新建教材",
|
||||||
|
"description": "创建一本新的数字教材。完成后点击保存。",
|
||||||
|
"submit": "保存",
|
||||||
|
"saving": "保存中..."
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "教材设置",
|
||||||
|
"description": "更新教材信息或删除此教材。",
|
||||||
|
"delete": "删除教材",
|
||||||
|
"deleteConfirmTitle": "确认删除教材?",
|
||||||
|
"deleteConfirmDesc": "此操作无法撤销。将永久删除该教材及其所有章节和知识点。",
|
||||||
|
"save": "保存修改",
|
||||||
|
"processing": "处理中...",
|
||||||
|
"trigger": "设置"
|
||||||
|
},
|
||||||
|
"chapter": {
|
||||||
|
"createTitle": "新建章节",
|
||||||
|
"createDesc": "创建一个新章节或小节。",
|
||||||
|
"submit": "创建章节",
|
||||||
|
"creating": "创建中...",
|
||||||
|
"deleteTitle": "删除章节?",
|
||||||
|
"deleteDesc": "将永久删除 {title}。此操作无法撤销。",
|
||||||
|
"delete": "删除",
|
||||||
|
"deleting": "删除中...",
|
||||||
|
"cannotDeleteWithSubchapters": "无法删除含有子章节的章节",
|
||||||
|
"addSubchapter": "添加子章节",
|
||||||
|
"titlePlaceholder": "例如:第一章:入门"
|
||||||
|
},
|
||||||
|
"knowledge": {
|
||||||
|
"createTitle": "添加知识点",
|
||||||
|
"createDesc": "从选中的文本创建知识点。",
|
||||||
|
"editTitle": "编辑知识点",
|
||||||
|
"editDesc": "修改知识点的名称和描述。",
|
||||||
|
"name": "名称",
|
||||||
|
"description": "描述(可选)",
|
||||||
|
"descriptionPlaceholder": "请输入描述...",
|
||||||
|
"displayName": "显示名称",
|
||||||
|
"anchorText": "高级:关联文本(影响文中高亮)",
|
||||||
|
"anchorTextHint": "修改此字段会改变文中被高亮匹配的文字。通常保持与原文一致。",
|
||||||
|
"create": "创建",
|
||||||
|
"creating": "创建中...",
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"cancel": "取消",
|
||||||
|
"deleteTitle": "确认删除",
|
||||||
|
"deleteDesc": "确定要删除这个知识点吗?此操作无法撤销。",
|
||||||
|
"delete": "删除",
|
||||||
|
"createQuestion": "创建相关题目",
|
||||||
|
"editKp": "编辑知识点",
|
||||||
|
"deleteKp": "删除知识点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"field": {
|
||||||
|
"title": "标题",
|
||||||
|
"subject": "学科",
|
||||||
|
"grade": "年级",
|
||||||
|
"publisher": "出版社",
|
||||||
|
"titlePlaceholder": "例如:高等数学",
|
||||||
|
"publisherPlaceholder": "例如:人教社",
|
||||||
|
"subjectPlaceholder": "选择学科",
|
||||||
|
"gradePlaceholder": "选择年级"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"searchPlaceholder": "按标题、出版社搜索...",
|
||||||
|
"allSubjects": "全部学科",
|
||||||
|
"allGrades": "全部年级"
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"chapters": "章节",
|
||||||
|
"updated": "更新于",
|
||||||
|
"gradeNA": "暂无年级",
|
||||||
|
"publisherNA": "暂无出版社",
|
||||||
|
"editContent": "编辑内容",
|
||||||
|
"delete": "删除",
|
||||||
|
"moreOptions": "更多操作"
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"knowledgePoints": "知识点",
|
||||||
|
"noPointsYet": "暂无知识点",
|
||||||
|
"noPointsDesc": "添加知识点以标记本章内容。",
|
||||||
|
"selectChapter": "选择一个章节以管理知识点",
|
||||||
|
"level": "等级"
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"mathematics": "数学",
|
||||||
|
"physics": "物理",
|
||||||
|
"chemistry": "化学",
|
||||||
|
"biology": "生物",
|
||||||
|
"english": "英语",
|
||||||
|
"history": "历史",
|
||||||
|
"geography": "地理"
|
||||||
|
},
|
||||||
|
"grade": {
|
||||||
|
"grade7": "七年级",
|
||||||
|
"grade8": "八年级",
|
||||||
|
"grade9": "九年级",
|
||||||
|
"grade10": "高一",
|
||||||
|
"grade11": "高二",
|
||||||
|
"grade12": "高三"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "教材加载失败",
|
||||||
|
"loadFailedDesc": "加载教材内容时发生错误,请重试。",
|
||||||
|
"retry": "重试"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"createSuccess": "教材创建成功。",
|
||||||
|
"createFailed": "创建教材失败。",
|
||||||
|
"updateSuccess": "教材更新成功。",
|
||||||
|
"updateFailed": "更新教材失败。",
|
||||||
|
"deleteSuccess": "教材删除成功。",
|
||||||
|
"deleteFailed": "删除教材失败。",
|
||||||
|
"chapterCreateSuccess": "章节创建成功",
|
||||||
|
"chapterCreateFailed": "创建章节失败",
|
||||||
|
"chapterDeleteSuccess": "章节删除成功",
|
||||||
|
"chapterDeleteFailed": "删除章节失败",
|
||||||
|
"contentUpdateSuccess": "内容更新成功",
|
||||||
|
"contentUpdateFailed": "更新内容失败",
|
||||||
|
"kpCreateSuccess": "知识点创建成功",
|
||||||
|
"kpCreateFailed": "创建知识点失败",
|
||||||
|
"kpUpdateSuccess": "知识点更新成功",
|
||||||
|
"kpUpdateFailed": "更新知识点失败",
|
||||||
|
"kpDeleteSuccess": "知识点删除成功",
|
||||||
|
"kpDeleteFailed": "删除知识点失败",
|
||||||
|
"reorderSuccess": "排序已更新",
|
||||||
|
"reorderFailed": "章节排序失败",
|
||||||
|
"fillRequired": "请填写所有必填字段。",
|
||||||
|
"titleRequired": "标题为必填项",
|
||||||
|
"nameRequired": "名称为必填项",
|
||||||
|
"invalidContent": "章节内容数据无效",
|
||||||
|
"errorOccurred": "发生错误",
|
||||||
|
"deleteFailed": "删除失败",
|
||||||
|
"updateFailedGeneric": "更新失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user