745 lines
30 KiB
TypeScript
745 lines
30 KiB
TypeScript
"use client"
|
||
|
||
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 { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, Share2 } from "lucide-react"
|
||
import { toast } from "sonner"
|
||
|
||
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>()
|
||
|
||
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
|
||
}
|
||
|
||
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 = (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])
|
||
|
||
const graphLayout = useMemo(() => {
|
||
if (currentChapterKPs.length === 0) {
|
||
return { nodes: [], edges: [], width: 0, height: 0 }
|
||
}
|
||
|
||
const byId = new Map<string, KnowledgePoint>()
|
||
for (const kp of currentChapterKPs) byId.set(kp.id, kp)
|
||
|
||
const children = new Map<string, string[]>()
|
||
const roots: string[] = []
|
||
|
||
for (const kp of currentChapterKPs) {
|
||
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 currentChapterKPs) 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 currentChapterKPs) {
|
||
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 = currentChapterKPs.map((kp) => {
|
||
const pos = positions.get(kp.id) ?? { x: gapX, y: gapY }
|
||
return { ...kp, x: pos.x, y: pos.y }
|
||
})
|
||
|
||
const edges = currentChapterKPs
|
||
.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 }
|
||
}, [currentChapterKPs])
|
||
|
||
// 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">
|
||
<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-3">
|
||
<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>
|
||
<TabsTrigger value="graph" className="gap-2" disabled={!selectedId}>
|
||
<Share2 className="h-4 w-4" />
|
||
图谱
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
<TabsContent value="graph" 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="relative"
|
||
style={{ width: graphLayout.width, height: graphLayout.height }}
|
||
>
|
||
<svg
|
||
width={graphLayout.width}
|
||
height={graphLayout.height}
|
||
className="absolute inset-0"
|
||
>
|
||
{graphLayout.edges.map((edge) => (
|
||
<line
|
||
key={edge.id}
|
||
x1={edge.x1}
|
||
y1={edge.y1}
|
||
x2={edge.x2}
|
||
y2={edge.y2}
|
||
stroke="hsl(var(--border))"
|
||
strokeWidth={2}
|
||
/>
|
||
))}
|
||
</svg>
|
||
{graphLayout.nodes.map((node) => (
|
||
<button
|
||
key={node.id}
|
||
type="button"
|
||
className={cn(
|
||
"absolute rounded-lg border bg-card px-3 py-2 text-left text-sm shadow-sm hover:bg-accent/50",
|
||
highlightedKpId === node.id && "border-primary bg-primary/5"
|
||
)}
|
||
style={{ left: node.x, top: node.y, width: 160, height: 52 }}
|
||
onClick={() => setHighlightedKpId(node.id)}
|
||
>
|
||
<div className="font-medium truncate">{node.name}</div>
|
||
{node.description && (
|
||
<div className="text-[10px] text-muted-foreground truncate">
|
||
{node.description}
|
||
</div>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
)}
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
|
||
<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">
|
||
{isEditing ? (
|
||
<div className="h-full">
|
||
<RichTextEditor
|
||
value={editContent}
|
||
onChange={setEditContent}
|
||
className="min-h-[500px] border-none shadow-none"
|
||
/>
|
||
</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">
|
||
请选择一个章节开始阅读。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|