feat(textbooks): 教材模块审计重构 — 跨模块解耦 + 权限 + i18n + 错误边界 + 纯函数抽取

P0 修复:
- 解耦跨模块 UI 依赖:knowledge-point-dialogs 不再直接 import questions,
  改为 renderQuestionCreator render prop 由页面注入
- 接入 usePermission Hook 替换 canEdit 硬编码
- 全模块 i18n 改造:新增 en/zh-CN 翻译文件,替换所有硬编码文案
- Server Action 资源归属校验:新增 verifyChapterBelongsToTextbook/
  verifyKnowledgePointBelongsToTextbook,在 reorder/update/delete/create 中校验

P1 改进:
- 补齐 Error Boundary:4 个 error.tsx + TextbookSectionErrorBoundary 区块包裹
- 抽取纯函数到 utils.ts/graph-layout.ts/constants.ts 并补单测(26 用例全通过)
- 消除重复组件:删除 knowledge-point-panel/create-knowledge-point-dialog
- 修复类型断言:chapter.children! → 守卫式访问
- 图谱 a11y:添加 role/aria-label/aria-pressed
- 统一删除确认:confirm() → AlertDialog
- 数据范围过滤:getTextbooksWithScope 支持学生端按年级过滤

P2 预留:
- TextbookAnalytics 埋点接口 + Provider + Hook

同步 005 架构数据 JSON:补充 getTextbooksWithScope/verify*/ChapterTreeNode 等
This commit is contained in:
SpecialX
2026-06-22 16:25:59 +08:00
parent 45ee1ae43c
commit 22d3f07fcf
35 changed files with 2043 additions and 792 deletions

View File

@@ -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 }",

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

View File

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

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

View File

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

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

View File

@@ -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>

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

View File

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

View File

@@ -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" };

View File

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

View File

@@ -136,9 +136,9 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
<CollapsibleContent> <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}

View File

@@ -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
/> />

View File

@@ -1,95 +0,0 @@
"use client"
import { useState } from "react"
import { Plus } from "lucide-react"
import { useFormStatus } from "react-dom"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { createKnowledgePointAction } from "../actions"
import { toast } from "sonner"
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Adding..." : "Add Point"}
</Button>
)
}
interface CreateKnowledgePointDialogProps {
chapterId: string
textbookId: string
}
export function CreateKnowledgePointDialog({ chapterId, textbookId }: CreateKnowledgePointDialogProps) {
const [open, setOpen] = useState(false)
const handleSubmit = async (formData: FormData) => {
const result = await createKnowledgePointAction(chapterId, textbookId, null, formData)
if (result.success) {
toast.success(result.message)
setOpen(false)
} else {
toast.error(result.message)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Plus className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add Knowledge Point</DialogTitle>
<DialogDescription>
Link a key concept to this chapter.
</DialogDescription>
</DialogHeader>
<form action={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">
Name
</Label>
<Input
id="name"
name="name"
placeholder="e.g. Pythagorean Theorem"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">
Description
</Label>
<Textarea
id="description"
name="description"
placeholder="Brief explanation..."
className="h-20"
/>
</div>
</div>
<DialogFooter>
<SubmitButton />
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,120 +1,9 @@
"use client" "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
className="relative"
style={{ width: graphLayout.width, height: graphLayout.height }}
>
<svg <svg
width={graphLayout.width} width={layout.width}
height={graphLayout.height} height={layout.height}
className="absolute inset-0" role="img"
aria-label={t("reader.tabs.graph")}
className="mx-auto"
> >
{graphLayout.edges.map((edge) => ( <title>{t("reader.tabs.graph")}</title>
{/* 边 */}
{layout.edges.map((edge) => (
<line <line
key={edge.id} key={edge.id}
x1={edge.x1} x1={edge.x1}
y1={edge.y1} y1={edge.y1}
x2={edge.x2} x2={edge.x2}
y2={edge.y2} y2={edge.y2}
stroke="hsl(var(--border))" stroke="currentColor"
strokeWidth={2} strokeOpacity={0.3}
strokeWidth={1.5}
/> />
))} ))}
</svg>
{graphLayout.nodes.map((node) => ( {/* 节点 */}
{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 <button
key={node.id}
type="button" 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)} 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}
> >
<div className="font-medium truncate">{node.name}</div> <span className="line-clamp-2">{node.name}</span>
{node.description && (
<div className="text-[10px] text-muted-foreground truncate">
{node.description}
</div>
)}
</button> </button>
))} </foreignObject>
</g>
)
})}
</svg>
</div> </div>
</ScrollArea>
) )
} }

View File

@@ -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"
/>
</> </>
) )
} }

View File

@@ -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,9 +57,13 @@ 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">
{canCreateQuestion && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -62,11 +72,12 @@ export function KnowledgePointList({
e.stopPropagation() e.stopPropagation()
onCreateQuestion(kp) onCreateQuestion(kp)
}} }}
title="创建相关题目" title={t("dialog.knowledge.createQuestion")}
aria-label="创建相关题目" aria-label={t("dialog.knowledge.createQuestion")}
> >
<PlusCircle className="h-3 w-3" /> <PlusCircle className="h-3 w-3" />
</Button> </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>

View File

@@ -1,157 +0,0 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Card, CardContent } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Tag, Trash2 } from "lucide-react"
import { KnowledgePoint } from "../types"
import { CreateKnowledgePointDialog } from "./create-knowledge-point-dialog"
import { deleteKnowledgePointAction } from "../actions"
import { toast } from "sonner"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
interface KnowledgePointPanelProps {
knowledgePoints: KnowledgePoint[]
selectedChapterId: string | null
textbookId: string
}
export function KnowledgePointPanel({
knowledgePoints,
selectedChapterId,
textbookId
}: KnowledgePointPanelProps) {
const router = useRouter()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<KnowledgePoint | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const requestDelete = (kp: KnowledgePoint) => {
setDeleteTarget(kp)
setShowDeleteDialog(true)
}
const handleDelete = async () => {
if (!deleteTarget) return
setIsDeleting(true)
try {
const result = await deleteKnowledgePointAction(deleteTarget.id, textbookId)
if (result.success) {
toast.success(result.message)
setShowDeleteDialog(false)
setDeleteTarget(null)
router.refresh()
} else {
toast.error(result.message)
}
} catch {
toast.error("Failed to delete knowledge point")
} finally {
setIsDeleting(false)
}
}
// Filter KPs for the selected chapter
const chapterKPs = selectedChapterId
? knowledgePoints.filter(kp => kp.chapterId === selectedChapterId)
: []
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold text-sm uppercase tracking-wider text-muted-foreground flex items-center gap-2">
<Tag className="h-4 w-4" />
Knowledge Points
</h3>
{selectedChapterId && (
<CreateKnowledgePointDialog
chapterId={selectedChapterId}
textbookId={textbookId}
/>
)}
</div>
<ScrollArea className="flex-1">
<div className="p-4 space-y-3">
{selectedChapterId ? (
chapterKPs.length > 0 ? (
<>
{chapterKPs.map((kp) => (
<Card key={kp.id} className="relative group hover:shadow-sm transition-shadow">
<CardContent className="p-3">
<div className="flex justify-between items-start gap-2">
<div className="space-y-1">
<div className="font-medium text-sm leading-tight text-foreground">
{kp.name}
</div>
{kp.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{kp.description}
</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 -mr-1 -mt-1 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
onClick={() => requestDelete(kp)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center space-y-3">
<div className="p-3 rounded-full bg-muted/50">
<Tag className="h-6 w-6 text-muted-foreground/40" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">No points yet</p>
<p className="text-xs text-muted-foreground/60 max-w-[160px]">
Add knowledge points to tag content in this chapter.
</p>
</div>
</div>
)
) : (
<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground space-y-2">
<p className="text-sm">Select a chapter to manage knowledge points</p>
</div>
)}
</div>
</ScrollArea>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Knowledge Point?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the knowledge point
<span className="font-medium text-foreground"> {deleteTarget?.name}</span>.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,67 @@
"use client"
/**
* 教材模块内联 Error Boundary。
*
* 用于包裹独立数据区块(章节树、内容区、知识点区、图谱区),
* 隔离故障域,避免单点错误导致整个阅读器白屏。
*/
import { Component, type ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
interface TextbookSectionErrorBoundaryProps {
children: ReactNode
/** 自定义降级 UI 标题 */
fallbackTitle?: string
/** 自定义降级 UI 描述 */
fallbackDescription?: string
/** 重试按钮文案 */
retryLabel?: string
}
interface TextbookSectionErrorBoundaryState {
hasError: boolean
}
export class TextbookSectionErrorBoundary extends Component<
TextbookSectionErrorBoundaryProps,
TextbookSectionErrorBoundaryState
> {
constructor(props: TextbookSectionErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(): TextbookSectionErrorBoundaryState {
return { hasError: true }
}
handleReset = (): void => {
this.setState({ hasError: false })
}
render(): ReactNode {
if (this.state.hasError) {
return (
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 p-6 text-center">
<AlertCircle className="h-8 w-8 text-muted-foreground" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{this.props.fallbackTitle ?? "区块加载失败"}
</p>
<p className="text-xs text-muted-foreground">
{this.props.fallbackDescription ?? "请重试或刷新页面"}
</p>
</div>
<Button size="sm" variant="outline" onClick={this.handleReset}>
{this.props.retryLabel ?? "重试"}
</Button>
</div>
)
}
return this.props.children
}
}

View File

@@ -1,41 +1,36 @@
import Link from "next/link"; "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,12 +63,12 @@ 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>
@@ -85,31 +80,33 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
<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>
@@ -117,5 +114,5 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
</div> </div>
</CardFooter> </CardFooter>
</Card> </Card>
); )
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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&apos;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>

View File

@@ -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,12 +212,17 @@ 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">
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
<ScrollArea className="flex-1 h-full px-2"> <ScrollArea className="flex-1 h-full px-2">
<div className="space-y-1 pb-4"> <div className="space-y-1 pb-4">
<ChapterSidebarList <ChapterSidebarList
@@ -215,17 +234,24 @@ export function TextbookReader({
/> />
</div> </div>
</ScrollArea> </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">
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
{!selectedId ? ( {!selectedId ? (
<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.selectChapterKnowledge")}
</div> </div>
) : ( ) : (
<KnowledgePointList <KnowledgePointList
knowledgePoints={currentChapterKPs} knowledgePoints={currentChapterKPs}
canEdit={canEdit} canEdit={canEdit}
canCreateQuestion={canCreateQuestion}
highlightedKpId={highlightedKpId} highlightedKpId={highlightedKpId}
onHighlight={setHighlightedKpId} onHighlight={setHighlightedKpId}
onEdit={(kp) => { onEdit={(kp) => {
@@ -239,12 +265,18 @@ export function TextbookReader({
}} }}
/> />
)} )}
</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">
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
{!selectedId ? ( {!selectedId ? (
<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.selectChapterGraph")}
</div> </div>
) : ( ) : (
<KnowledgeGraph <KnowledgeGraph
@@ -253,6 +285,7 @@ export function TextbookReader({
onHighlight={setHighlightedKpId} 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,8 +320,14 @@ export function TextbookReader({
questionDialogOpen={questionDialogOpen} questionDialogOpen={questionDialogOpen}
setQuestionDialogOpen={setQuestionDialogOpen} setQuestionDialogOpen={setQuestionDialogOpen}
targetKpForQuestion={targetKpForQuestion} targetKpForQuestion={targetKpForQuestion}
renderQuestionCreator={canCreateQuestion ? renderQuestionCreator : undefined}
/> />
<TextbookSectionErrorBoundary
fallbackTitle={t("error.loadFailed")}
fallbackDescription={t("error.loadFailedDesc")}
retryLabel={t("error.retry")}
>
<TextbookContentPanel <TextbookContentPanel
selected={selected} selected={selected}
isEditing={isEditing} isEditing={isEditing}
@@ -313,6 +352,7 @@ export function TextbookReader({
isSaving={isSaving} isSaving={isSaving}
processedContent={processedContent} processedContent={processedContent}
/> />
</TextbookSectionErrorBoundary>
</div> </div>
</div> </div>
) )

View File

@@ -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"
@@ -144,17 +158,34 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
<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>
) )
} }

View File

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

View File

@@ -19,57 +19,21 @@ import type {
UpdateKnowledgePointInput, 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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
"use client" "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)
} }

View File

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

View File

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

View File

@@ -212,7 +212,7 @@ export const getUserWithRole = cache(
* session and verifying the "student" role via JOIN users + usersToRoles + roles. * 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. */

View 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"
}
}

View 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": "更新失败"
}
}