feat: enhance textbook reader with anchor text support and improve knowledge point management

This commit is contained in:
SpecialX
2026-01-16 10:22:16 +08:00
parent 9bfc621d3f
commit bb4555f611
44 changed files with 6284 additions and 2090 deletions

View File

@@ -1,16 +1,47 @@
"use client"
import { useMemo, useState } from "react"
import { useMemo, useState, useEffect, useRef } from "react"
import ReactMarkdown from "react-markdown"
import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import { useQueryState, parseAsString } from "nuqs"
import { ChevronRight, FileText, Folder } from "lucide-react"
import { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, ChevronDown, ChevronUp } from "lucide-react"
import { toast } from "sonner"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible"
import type { Chapter } from "../types"
import type { Chapter, KnowledgePoint } from "../types"
import { createKnowledgePointAction, updateChapterContentAction, deleteKnowledgePointAction, updateKnowledgePointAction } from "../actions"
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
import { cn } from "@/shared/lib/utils"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Button } from "@/shared/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { Badge } from "@/shared/components/ui/badge"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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 {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/shared/components/ui/context-menu"
import { ChapterSidebarList } from "./chapter-sidebar-list"
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
function buildChapterIndex(chapters: Chapter[]) {
const index = new Map<string, Chapter>()
@@ -26,131 +57,543 @@ function buildChapterIndex(chapters: Chapter[]) {
return index
}
function ReaderChapterItem({
chapter,
level = 0,
selectedId,
onSelect,
}: {
chapter: Chapter
level?: number
selectedId: string | null
onSelect: (chapterId: string) => void
}) {
const hasChildren = Boolean(chapter.children && chapter.children.length > 0)
const [open, setOpen] = useState(level === 0)
const isSelected = selectedId === chapter.id
return (
<div className={cn(level > 0 && "ml-2 border-l pl-2")}>
<div
className={cn(
"flex items-center group py-1 rounded-md transition-colors",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
)}
>
{hasChildren ? (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 p-0 hover:bg-muted"
onClick={() => setOpen((v) => !v)}
>
<ChevronRight className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-90")} />
</Button>
) : (
<div className="w-6 shrink-0" />
)}
<button
type="button"
className={cn(
"flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5 text-sm text-left cursor-pointer",
level === 0 ? "font-medium" : "text-muted-foreground",
isSelected && "text-accent-foreground font-medium"
)}
onClick={() => onSelect(chapter.id)}
>
{hasChildren ? (
<Folder className={cn("h-4 w-4", open ? "text-primary" : "text-muted-foreground/70")} />
) : (
<FileText className="h-4 w-4 text-muted-foreground/50" />
)}
<span className="truncate flex-1 min-w-0">{chapter.title}</span>
</button>
</div>
{hasChildren && open ? (
<div className="pt-1">
{chapter.children!.map((child) => (
<ReaderChapterItem
key={child.id}
chapter={child}
level={level + 1}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
</div>
) : null}
</div>
)
}
export function TextbookReader({ chapters }: { chapters: Chapter[] }) {
export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false, textbookId }: { chapters: Chapter[]; knowledgePoints?: KnowledgePoint[]; canEdit?: boolean; textbookId?: string }) {
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
const [activeTab, setActiveTab] = useState("chapters")
const [highlightedKpId, setHighlightedKpId] = useState<string | null>(null)
// Selection & Creation State
const [selectedText, setSelectedText] = useState("")
const selectionRef = useRef("") // Store selection temporarily to avoid re-renders on pointer down
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const contentRef = useRef<HTMLDivElement>(null)
// Editing State
const [isEditing, setIsEditing] = useState(false)
const [editContent, setEditContent] = useState("")
const [isSaving, setIsSaving] = useState(false)
// Knowledge Point Edit State
const [editingKp, setEditingKp] = useState<KnowledgePoint | null>(null)
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
// Question Creation State
const [questionDialogOpen, setQuestionDialogOpen] = useState(false)
const [targetKpForQuestion, setTargetKpForQuestion] = useState<KnowledgePoint | null>(null)
const index = useMemo(() => buildChapterIndex(chapters), [chapters])
const selected = chapterId ? index.get(chapterId) ?? null : null
const selectedId = selected?.id ?? null
const handleSelect = (id: string) => setChapterId(id)
const handleSelect = (chapter: Chapter) => {
setChapterId(chapter.id)
setIsEditing(false)
}
// Handle Text Selection via Context Menu
// We capture selection on PointerDown (Right Click) to ensure we get the state before any context menu logic runs.
// Using onContextMenu directly caused conflicts with Radix UI's ContextMenuTrigger in some cases.
const handleContentPointerDown = (e: React.PointerEvent) => {
// Only capture on right click (button 2)
if (e.button !== 2) return
const selection = window.getSelection()
if (!selection || selection.isCollapsed) {
selectionRef.current = ""
return
}
// Check if selection is within content area
if (contentRef.current && contentRef.current.contains(selection.anchorNode)) {
// Store in ref, don't trigger re-render yet
selectionRef.current = selection.toString().trim()
} else {
selectionRef.current = ""
}
}
const handleContextMenuChange = (open: boolean) => {
if (!open) return
// When menu opens, sync ref to state to update UI
if (selectionRef.current) {
setSelectedText(selectionRef.current)
} else {
// Fallback: If pointer down didn't capture (e.g. keyboard), try now
const selection = window.getSelection()
if (selection && !selection.isCollapsed && contentRef.current && contentRef.current.contains(selection.anchorNode)) {
const text = selection.toString().trim()
selectionRef.current = text
setSelectedText(text)
} else {
setSelectedText("")
}
}
}
const handleCreateKnowledgePoint = async (formData: FormData) => {
if (!selectedId || !selected) return
setIsCreating(true)
try {
const result = await createKnowledgePointAction(
selectedId,
selected.textbookId,
null,
formData
)
if (result.success) {
toast.success("知识点已创建")
setCreateDialogOpen(false)
setActiveTab("knowledge")
// Clear selection
window.getSelection()?.removeAllRanges()
setSelectedText("")
} else {
toast.error(result.message || "创建知识点失败")
}
} catch {
toast.error("发生错误")
} finally {
setIsCreating(false)
}
}
const handleSaveContent = async () => {
if (!selectedId || !textbookId) return
setIsSaving(true)
const result = await updateChapterContentAction(selectedId, editContent, textbookId)
setIsSaving(false)
if (result.success) {
toast.success(result.message)
setIsEditing(false)
// Optimistic update might be tricky here without full reload, but let's assume parent revalidates or we rely on router refresh
// For now, we manually update the local state if needed, but since we use `chapters` prop which comes from server,
// we ideally want to trigger a refresh.
// However, for this component, we can just let the user see the new content if we render `editContent` or rely on props update.
// But `chapters` prop won't update automatically unless we router.refresh().
// Let's rely on the fact that `selected` comes from `chapters` which might be stale until refresh.
// A full solution would use `router.refresh()`.
// For now, we can update the `selected.content` in place? No, it's a prop.
// We will rely on router refresh in the parent or just simple UI feedback.
// Actually, let's trigger a router refresh if possible, but we don't have router here.
// We'll just exit edit mode. The content might look old until refresh.
// To fix this, we can locally override content.
if (selected) selected.content = editContent
} else {
toast.error(result.message)
}
}
const startEditing = () => {
if (selected) {
setEditContent(selected.content || "")
setIsEditing(true)
}
}
const handleDeleteKnowledgePoint = async (kpId: string, e: React.MouseEvent) => {
e.stopPropagation()
if (!confirm("确定要删除这个知识点吗?")) return
if (!textbookId) return
try {
const result = await deleteKnowledgePointAction(kpId, textbookId)
if (result.success) {
toast.success(result.message)
if (highlightedKpId === kpId) {
setHighlightedKpId(null)
}
} else {
toast.error(result.message)
}
} catch {
toast.error("删除失败")
}
}
const handleUpdateKnowledgePoint = async (formData: FormData) => {
if (!editingKp || !textbookId) return
setIsUpdatingKp(true)
try {
const result = await updateKnowledgePointAction(editingKp.id, textbookId, null, formData)
if (result.success) {
toast.success(result.message)
setEditKpDialogOpen(false)
setEditingKp(null)
} else {
toast.error(result.message)
}
} catch {
toast.error("更新失败")
} finally {
setIsUpdatingKp(false)
}
}
// Filter KPs for the current chapter
const currentChapterKPs = useMemo(() => {
if (!selectedId) return []
return knowledgePoints.filter(kp => kp.chapterId === selectedId)
}, [knowledgePoints, selectedId])
// Pre-process content to mark knowledge points
const processedContent = useMemo(() => {
if (!selected?.content) return ""
let content = selected.content
// Sort KPs by name length descending to handle overlapping names
const sortedKPs = [...currentChapterKPs].sort((a, b) => b.name.length - a.name.length)
// We use a temporary replacement strategy to avoid nested replacements
// This is simple but works for most cases
// We replace "Name" with "[Name](kp://id)"
for (const kp of sortedKPs) {
// Escape regex special characters
const escapedName = kp.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Case insensitive match, but preserve original text casing
// We use a simplified lookahead to avoid replacing inside existing links if possible,
// but perfect markdown parsing is hard with regex.
// For now, we assume KPs don't overlap in a way that breaks things often.
const regex = new RegExp(`(${escapedName})`, 'gi')
// We only replace if not already part of a link (simplified check)
// A robust parser would be better, but regex is acceptable for this level
content = content.replace(regex, `[$1](#kp-${kp.id})`)
}
return content
}, [selected?.content, currentChapterKPs])
// Scroll to highlighted KP
useEffect(() => {
if (highlightedKpId) {
// Find first element by data attribute
const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" })
// Add temporary highlight effect
el.classList.add("ring-2", "ring-primary", "ring-offset-2")
setTimeout(() => {
el.classList.remove("ring-2", "ring-primary", "ring-offset-2")
}, 2000)
}
}
}, [highlightedKpId])
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 h-full">
<div className="lg:col-span-4 lg:border-r lg:pr-6 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
<h3 className="font-semibold">Chapters</h3>
</div>
<ScrollArea className="flex-1 min-h-0 px-2">
<div className="space-y-1">
{chapters.map((chapter) => (
<ReaderChapterItem
key={chapter.id}
chapter={chapter}
selectedId={selectedId}
onSelect={handleSelect}
/>
))}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="chapters" className="gap-2">
<List className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
<Tag className="h-4 w-4" />
{currentChapterKPs.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">{currentChapterKPs.length}</Badge>
)}
</TabsTrigger>
</TabsList>
</div>
</ScrollArea>
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-1 pb-4">
<ChapterSidebarList
chapters={chapters}
selectedChapterId={selectedId || undefined}
onSelectChapter={handleSelect}
textbookId={textbookId || ""}
canEdit={canEdit}
/>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
{!selectedId ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : currentChapterKPs.length === 0 ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : (
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-2 pb-4">
{currentChapterKPs.map((kp) => (
<div
key={kp.id}
className={cn(
"p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer",
highlightedKpId === kp.id && "border-primary bg-primary/5"
)}
onClick={() => setHighlightedKpId(kp.id)}
>
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
<div className="flex items-center gap-1">
<Badge variant="outline" className="text-[10px] h-5 px-1">Lv.{kp.level}</Badge>
{canEdit && (
<div className="flex items-center gap-1 ml-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
setTargetKpForQuestion(kp)
setQuestionDialogOpen(true)
}}
title="创建相关题目"
>
<PlusCircle className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
setEditingKp(kp)
setEditKpDialogOpen(true)
}}
title="编辑知识点"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => handleDeleteKnowledgePoint(kp.id, e)}
title="删除知识点"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
</div>
{kp.description && (
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
{kp.description}
</p>
)}
</div>
))}
</div>
</ScrollArea>
)}
</TabsContent>
</Tabs>
</div>
<div className="lg:col-span-8 flex flex-col min-h-0">
<div className="lg:col-span-8 flex flex-col min-h-0 relative">
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form action={handleCreateKnowledgePoint}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Input id="name" name="name" defaultValue={selectedText} required />
</div>
<div className="grid gap-2">
<Label htmlFor="description"></Label>
<Textarea id="description" name="description" placeholder="请输入描述..." />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)} disabled={isCreating}>
</Button>
<Button type="submit" disabled={isCreating}>
{isCreating ? "创建中..." : "创建"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={editKpDialogOpen} onOpenChange={setEditKpDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form action={handleUpdateKnowledgePoint}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name"></Label>
<Input id="edit-name" name="name" defaultValue={editingKp?.name} required />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description"></Label>
<Textarea id="edit-description" name="description" defaultValue={editingKp?.description || ""} placeholder="请输入描述..." />
</div>
<div className="space-y-2 border rounded-md p-3 bg-muted/20">
<div className="flex items-center justify-between">
<Label htmlFor="edit-anchorText" className="text-muted-foreground text-xs flex items-center gap-1">
()
</Label>
</div>
<div className="pt-2">
<Input
key={editingKp?.id} // Force re-render when kp changes
id="edit-anchorText"
name="anchorText"
defaultValue={editingKp?.anchorText || editingKp?.name}
className="text-sm font-mono"
required
/>
<p className="text-[10px] text-muted-foreground mt-1">
</p>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditKpDialogOpen(false)} disabled={isUpdatingKp}>
</Button>
<Button type="submit" disabled={isUpdatingKp}>
{isUpdatingKp ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<CreateQuestionDialog
open={questionDialogOpen}
onOpenChange={setQuestionDialogOpen}
defaultKnowledgePointIds={targetKpForQuestion ? [targetKpForQuestion.id] : []}
defaultContent={targetKpForQuestion ? `Please explain the knowledge point: ${targetKpForQuestion.name}` : ""}
defaultType="text"
/>
{selected ? (
<>
<div className="flex items-center justify-between mb-4 pb-2 border-b px-2 shrink-0">
<h2 className="text-xl font-bold tracking-tight line-clamp-1">{selected.title}</h2>
{canEdit && (
<div className="flex gap-2">
{isEditing ? (
<>
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)} disabled={isSaving}>
</Button>
<Button size="sm" onClick={handleSaveContent} disabled={isSaving}>
<Save className="mr-2 h-4 w-4" />
{isSaving ? "保存中..." : "保存"}
</Button>
</>
) : (
<Button size="sm" variant="outline" onClick={startEditing}>
<Edit2 className="mr-2 h-4 w-4" />
</Button>
)}
</div>
)}
</div>
<ScrollArea className="flex-1 min-h-0 px-2">
<div className="p-4 min-h-full">
{selected.content ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>{selected.content}</ReactMarkdown>
{isEditing ? (
<div className="h-full">
<RichTextEditor
value={editContent}
onChange={setEditContent}
className="min-h-[500px] border-none shadow-none"
/>
</div>
) : (
<div className="text-muted-foreground italic py-8 text-center">No content available.</div>
)}
</div>
) : (
<ContextMenu onOpenChange={handleContextMenuChange}>
<ContextMenuTrigger asChild>
<div
className="p-4 min-h-full"
ref={contentRef}
onPointerDown={handleContentPointerDown}
>
{selected.content ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
a: ({ href, children, ...props }) => {
if (href?.startsWith("#kp-")) {
const id = href.replace("#kp-", "")
const isHighlighted = highlightedKpId === id
return (
<span
data-kp-id={id}
className={cn(
"font-medium text-primary cursor-pointer hover:underline decoration-dashed underline-offset-4 transition-all duration-300",
isHighlighted && "bg-yellow-300 dark:bg-yellow-600 text-black dark:text-white rounded px-1 py-0.5 shadow-sm scale-110 inline-block mx-0.5 font-bold ring-2 ring-yellow-400/50 dark:ring-yellow-500/50"
)}
onClick={(e) => {
e.preventDefault()
setHighlightedKpId(id)
setActiveTab("knowledge")
}}
title="点击查看知识点详情"
>
{children}
</span>
)
}
return <a href={href} {...props}>{children}</a>
}
}}
>
{processedContent}
</ReactMarkdown>
</div>
) : (
<div className="text-muted-foreground italic py-8 text-center"></div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
disabled={!selectedText}
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
</ScrollArea>
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
Select a chapter to start reading.
</div>
)}
</div>
</div>
)
}