-
+ if (!data || data.knowledgePoints.length === 0) {
+ return (
+
+ )
+ }
+
+ const selectedKp = selectedKpId ? data.knowledgePoints.find((kp) => kp.id === selectedKpId) : null
+ const selectedMastery = selectedKpId ? data.masteryMap[selectedKpId] ?? null : null
+
+ return (
+
+
+
+
+
+
+
+ {
+ // node.data 是 Record;从 unknown 安全转换读取 graphData
+ const graphData = (node.data as unknown as { graphData?: { chapterColor: string } })?.graphData
+ return graphData?.chapterColor ?? "#6b7280"
+ }}
+ />
+
+
+
+
+ {selectedKp && (
+
+ setSelectedKpId(null)}
+ onJumpToKp={onJumpToKp}
+ onAddPrerequisite={() => {
+ // 后续迭代:打开添加前置对话框
+ }}
+ onRemovePrerequisite={(_prereqId: string) => {
+ // 后续迭代:调用 deletePrerequisiteAction
+ }}
+ />
+
+ )}
)
}
+
+export function KnowledgeGraph(props: KnowledgeGraphProps) {
+ return (
+
+
+
+ )
+}
diff --git a/src/modules/textbooks/components/textbook-reader.tsx b/src/modules/textbooks/components/textbook-reader.tsx
index 0042fb5..d0088b4 100644
--- a/src/modules/textbooks/components/textbook-reader.tsx
+++ b/src/modules/textbooks/components/textbook-reader.tsx
@@ -1,18 +1,19 @@
"use client"
-import { useMemo, useState, useEffect, type ReactNode } from "react"
+import { useMemo, useState, useEffect, useRef, type ReactNode } from "react"
import { useQueryState, parseAsString } from "nuqs"
-import { Tag, List, Share2 } from "lucide-react"
+import { Tag, List, Share2, Menu } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import type { Chapter, KnowledgePoint } from "../types"
-import { updateChapterContentAction } from "../actions"
+import { updateChapterContentAction, getKnowledgePointsByChapterAction } from "../actions"
import { Permissions } from "@/shared/types/permissions"
import { usePermission } from "@/shared/hooks/use-permission"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { Badge } from "@/shared/components/ui/badge"
+import { Button } from "@/shared/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
@@ -33,20 +34,30 @@ import {
type QuestionCreatorRenderProps,
} from "./knowledge-point-dialogs"
import { TextbookSectionErrorBoundary } from "./section-error-boundary"
+import { EmptyState } from "@/shared/components/ui/empty-state"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+} from "@/shared/components/ui/sheet"
import { useTextSelection } from "../hooks/use-text-selection"
import { useKnowledgePointActions } from "../hooks/use-knowledge-point-actions"
-import { buildChapterIndex } from "../utils"
+import { buildChapterIndex, highlightKnowledgePoints } from "../utils"
export interface TextbookReaderProps {
chapters: Chapter[]
- knowledgePoints?: KnowledgePoint[]
+ /**
+ * 教材 ID,用于按章节懒加载知识点(P2-3)。
+ * 必传,否则知识点面板将始终为空。
+ */
+ textbookId: string
/**
* 是否可编辑。已废弃——改由内部 usePermission() 自动判断。
* 保留 prop 仅为向后兼容,传入值会被忽略。
* @deprecated 改用权限系统自动判断
*/
canEdit?: boolean
- textbookId?: string
/**
* 题目创建器渲染函数(P0-1 解耦)。
* 由页面层注入 questions 模块的 CreateQuestionDialog 实现。
@@ -57,7 +68,6 @@ export interface TextbookReaderProps {
export function TextbookReader({
chapters,
- knowledgePoints = [],
textbookId,
renderQuestionCreator,
}: TextbookReaderProps) {
@@ -71,6 +81,8 @@ export function TextbookReader({
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
const [activeTab, setActiveTab] = useState("chapters")
const [highlightedKpId, setHighlightedKpId] = useState
(null)
+ // P2-4 移动端抽屉式侧栏
+ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editContent, setEditContent] = useState("")
@@ -92,10 +104,42 @@ export function TextbookReader({
const selected = chapterId ? index.get(chapterId) ?? null : null
const selectedId = selected?.id ?? null
- const currentChapterKPs = useMemo(() => {
- if (!selectedId) return []
- return knowledgePoints.filter((kp) => kp.chapterId === selectedId)
- }, [knowledgePoints, selectedId])
+ // P2-3 知识点懒加载:按章节通过 Server Action 按需加载,避免一次性拉取全部知识点
+ // 使用缓存 + 派生值模式,避免在 effect 主体中同步 setState
+ // v2-P2: textbookId 变化时通过页面层 key={textbookId} 重置整个 reader,无需手动清理缓存
+ const [kpsByChapter, setKpsByChapter] = useState>({})
+ const requestedChaptersRef = useRef>(new Set())
+ // 用 useMemo 包裹以稳定引用,避免下游 useMemo 因 [] 引用变化而重复计算
+ const currentChapterKPs = useMemo(
+ () => (selectedId ? kpsByChapter[selectedId] ?? [] : []),
+ [selectedId, kpsByChapter]
+ )
+ // 加载状态派生:选中了章节但缓存中尚无数据时视为加载中
+ const isLoadingKPs = selectedId !== null && kpsByChapter[selectedId] === undefined
+
+ useEffect(() => {
+ if (!selectedId || !textbookId) {
+ return
+ }
+ // 已请求过的章节不重复请求(缓存命中)
+ if (requestedChaptersRef.current.has(selectedId)) {
+ return
+ }
+ requestedChaptersRef.current.add(selectedId)
+ let cancelled = false
+ getKnowledgePointsByChapterAction(selectedId, textbookId)
+ .then((result) => {
+ if (cancelled) return
+ const data = result.success ? result.data : undefined
+ setKpsByChapter((prev) => ({ ...prev, [selectedId]: data ?? [] }))
+ })
+ .catch(() => {
+ if (!cancelled) setKpsByChapter((prev) => ({ ...prev, [selectedId]: [] }))
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [selectedId, textbookId])
const {
editingKp,
@@ -137,15 +181,21 @@ export function TextbookReader({
const handleSaveContent = async () => {
if (!selectedId || !textbookId) return
setIsSaving(true)
- const result = await updateChapterContentAction(selectedId, editContent, textbookId)
- setIsSaving(false)
+ try {
+ const result = await updateChapterContentAction(selectedId, editContent, textbookId)
- if (result.success) {
- toast.success(result.message)
- setIsEditing(false)
- setLocalContent(editContent)
- } else {
- toast.error(result.message)
+ if (result.success) {
+ toast.success(result.message)
+ setIsEditing(false)
+ setLocalContent(editContent)
+ } else {
+ toast.error(result.message)
+ }
+ } catch (e) {
+ console.error("Failed to save chapter content", e)
+ toast.error("Failed to save content")
+ } finally {
+ setIsSaving(false)
}
}
@@ -160,137 +210,165 @@ export function TextbookReader({
setChapterId(chapter.id)
setIsEditing(false)
setLocalContent(null)
+ // P2-4 移动端选择章节后关闭抽屉
+ setMobileSidebarOpen(false)
}
const effectiveContent = localContent ?? selected?.content
+ // P2-2 性能优化:单遍 alternation 正则替换,避免 O(n×m) 多遍扫描
const processedContent = useMemo(() => {
if (!effectiveContent) return ""
- let content = effectiveContent
- const sortedKPs = [...currentChapterKPs].sort((a, b) => b.name.length - a.name.length)
-
- for (const kp of sortedKPs) {
- const escapedName = kp.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
- const regex = new RegExp(`(${escapedName})`, "gi")
- content = content.replace(regex, `[$1](#kp-${kp.id})`)
- }
-
- return content
+ return highlightKnowledgePoints(effectiveContent, currentChapterKPs)
}, [effectiveContent, currentChapterKPs])
useEffect(() => {
- if (highlightedKpId) {
- const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
- if (el) {
- el.scrollIntoView({ behavior: "smooth", block: "center" })
- el.classList.add("ring-2", "ring-primary", "ring-offset-2")
- setTimeout(() => {
- el.classList.remove("ring-2", "ring-primary", "ring-offset-2")
- }, 2000)
- }
+ if (!highlightedKpId) return
+ const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
+ if (!el) return
+
+ el.scrollIntoView({ behavior: "smooth", block: "center" })
+ el.classList.add("ring-2", "ring-primary", "ring-offset-2")
+ const timer = setTimeout(() => {
+ el.classList.remove("ring-2", "ring-primary", "ring-offset-2")
+ }, 2000)
+ return () => {
+ clearTimeout(timer)
}
}, [highlightedKpId])
- return (
-
-
-
-
-
-
-
- {t("reader.tabs.chapters")}
-
-
-
- {t("reader.tabs.knowledge")}
- {currentChapterKPs.length > 0 && (
-
- {currentChapterKPs.length}
-
- )}
-
-
-
- {t("reader.tabs.graph")}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {!selectedId ? (
-
- {t("reader.selectChapterKnowledge")}
-
- ) : (
- {
- setEditingKp(kp)
- setEditKpDialogOpen(true)
- }}
- onDelete={requestDeleteKnowledgePoint}
- onCreateQuestion={(kp) => {
- setTargetKpForQuestion(kp)
- setQuestionDialogOpen(true)
- }}
- />
- )}
-
-
-
-
-
- {!selectedId ? (
-
- {t("reader.selectChapterGraph")}
-
- ) : (
-
- )}
-
-
-
+ // P2-4 侧边栏内容(章节/知识点/图谱 Tabs),桌面端内联、移动端抽屉复用同一份
+ const sidebarContent = (
+
+
+
+
+
+ {t("reader.tabs.chapters")}
+
+
+
+ {t("reader.tabs.knowledge")}
+ {currentChapterKPs.length > 0 && (
+
+ {currentChapterKPs.length}
+
+ )}
+
+
+
+ {t("reader.tabs.graph")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!selectedId ? (
+
+ ) : isLoadingKPs ? (
+
+ {t("reader.loadingKnowledge")}
+
+ ) : (
+ {
+ setEditingKp(kp)
+ setEditKpDialogOpen(true)
+ }}
+ onDelete={requestDeleteKnowledgePoint}
+ onCreateQuestion={(kp) => {
+ setTargetKpForQuestion(kp)
+ setQuestionDialogOpen(true)
+ }}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+ )
+
+ return (
+
+ {/* P2-4 桌面端侧栏:lg 及以上内联显示 */}
+
+ {sidebarContent}
+
+
+ {/* P2-4 移动端侧栏:lg 以下用 Sheet 抽屉式展示 */}
+
+
+
+ {t("reader.sidebar")}
+
+ {sidebarContent}
+
+
+
+ {/* P2-4 移动端侧栏触发按钮 */}
+
+
+ {selected && (
+
+ {selected.title}
+
+ )}
+
+
@@ -334,7 +412,6 @@ export function TextbookReader({
editContent={editContent}
setEditContent={setEditContent}
canEdit={canEdit}
- knowledgePoints={currentChapterKPs}
highlightedKpId={highlightedKpId}
onHighlight={setHighlightedKpId}
onSwitchToKnowledgeTab={() => setActiveTab("knowledge")}
@@ -342,10 +419,7 @@ export function TextbookReader({
onPointerDown={handleContentPointerDown}
onContextMenuChange={handleContextMenuChange}
selectedText={selectedText}
- createDialogOpen={createDialogOpen}
setCreateDialogOpen={setCreateDialogOpen}
- isCreating={isCreating}
- onCreateKnowledgePoint={onCreateKnowledgePoint}
startEditing={startEditing}
cancelEditing={() => setIsEditing(false)}
saveContent={handleSaveContent}
diff --git a/src/modules/textbooks/data-access-graph.ts b/src/modules/textbooks/data-access-graph.ts
new file mode 100644
index 0000000..b63b4fa
--- /dev/null
+++ b/src/modules/textbooks/data-access-graph.ts
@@ -0,0 +1,203 @@
+import "server-only"
+
+import { cache } from "react"
+import { and, asc, eq, inArray, sql, count } from "drizzle-orm"
+
+import { db } from "@/shared/db"
+import {
+ chapters,
+ knowledgePoints,
+ knowledgePointPrerequisites,
+ questionsToKnowledgePoints,
+ knowledgePointMastery,
+} from "@/shared/db/schema"
+import type { KpWithRelations, MasteryInfo } from "./types"
+
+/**
+ * 获取教材下全书知识点(含前置依赖 + 关联题目数 + 章节标题)。
+ *
+ * 一次查询聚合,避免 N+1。
+ */
+export const getKnowledgePointsWithRelations = cache(async (
+ textbookId: string,
+): Promise => {
+ // 1. 查询全书知识点 + 章节标题
+ const kpRows = await db
+ .select({
+ id: knowledgePoints.id,
+ name: knowledgePoints.name,
+ description: knowledgePoints.description,
+ parentId: knowledgePoints.parentId,
+ chapterId: knowledgePoints.chapterId,
+ level: knowledgePoints.level,
+ order: knowledgePoints.order,
+ chapterTitle: chapters.title,
+ })
+ .from(knowledgePoints)
+ .innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
+ .where(eq(chapters.textbookId, textbookId))
+ .orderBy(asc(chapters.order), asc(knowledgePoints.order), asc(knowledgePoints.name))
+
+ if (kpRows.length === 0) return []
+
+ const kpIds = kpRows.map((r) => r.id)
+
+ // 2. 查询关联题目数(批量聚合)
+ const questionCountRows = await db
+ .select({
+ knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
+ count: count(),
+ })
+ .from(questionsToKnowledgePoints)
+ .where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds))
+ .groupBy(questionsToKnowledgePoints.knowledgePointId)
+
+ const questionCountMap = new Map()
+ for (const r of questionCountRows) {
+ questionCountMap.set(r.knowledgePointId, Number(r.count))
+ }
+
+ // 3. 查询前置依赖(批量)
+ const prereqRows = await db
+ .select({
+ knowledgePointId: knowledgePointPrerequisites.knowledgePointId,
+ prerequisiteKpId: knowledgePointPrerequisites.prerequisiteKpId,
+ })
+ .from(knowledgePointPrerequisites)
+ .where(inArray(knowledgePointPrerequisites.knowledgePointId, kpIds))
+
+ const prereqMap = new Map()
+ for (const r of prereqRows) {
+ const arr = prereqMap.get(r.knowledgePointId) ?? []
+ arr.push(r.prerequisiteKpId)
+ prereqMap.set(r.knowledgePointId, arr)
+ }
+
+ // 4. 组装结果
+ return kpRows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ description: r.description,
+ parentId: r.parentId,
+ chapterId: r.chapterId,
+ level: r.level ?? 0,
+ order: r.order ?? 0,
+ chapterTitle: r.chapterTitle,
+ questionCount: questionCountMap.get(r.id) ?? 0,
+ prerequisiteIds: prereqMap.get(r.id) ?? [],
+ }))
+})
+
+/**
+ * 获取学生在某教材下所有知识点的掌握度。
+ */
+export const getStudentKpMastery = cache(async (
+ studentId: string,
+ textbookId: string,
+): Promise