feat(textbooks): 知识图谱功能全面重构 — 前置依赖 + dagre 布局 + React Flow 可视化 + 师生双视角
将教材模块图谱从基本无用状态升级为完整知识图谱可视化系统。 数据层:新增 knowledgePointPrerequisites 表(复合主键+双外键 cascade);新增 data-access-graph.ts(server-only)知识点关联聚合、学生/班级掌握度查询;utils.ts 新增 hasCycleAfterAddingEdge(DFS 循环依赖检测)。 业务层:3 个新 Server Action(getKnowledgeGraphDataAction 三视图模式、createPrerequisiteAction 含循环检测、deletePrerequisiteAction);graph-layout.ts 重写为 dagre 分层有向图布局。 视图层:knowledge-graph.tsx 重写为 React Flow 主组件(全书视图+搜索高亮+关联节点高亮+章节着色);4 个新组件(graph-kp-node/graph-prerequisite-edge/graph-toolbar/graph-node-detail-panel);use-graph-data.ts 派生值模式避免 effect 中 setState。 架构:严格三层架构,客户端通过 Server Action 间接访问 server-only 数据层;权限校验+ i18n 全覆盖;架构文档 004/005 同步。 测试:utils.test.ts 新增 5 个循环检测测试,graph-layout.test.ts 重写 5 个 dagre 布局测试,全部 30 个教材模块单元测试通过。 附带提交 drizzle/0005 error-book 迁移文件以保持 journal 一致性。
This commit is contained in:
91
src/modules/textbooks/components/graph-kp-node.tsx
Normal file
91
src/modules/textbooks/components/graph-kp-node.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import { memo } from "react"
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { GraphNodeData, MasteryLevel } from "../types"
|
||||
import type { GraphLayoutNodeData } from "../graph-layout"
|
||||
|
||||
/** 根据掌握度计算色彩等级 */
|
||||
function getMasteryLevel(mastery: number | null): MasteryLevel {
|
||||
if (mastery === null) return "unassessed"
|
||||
if (mastery < 60) return "low"
|
||||
if (mastery < 85) return "medium"
|
||||
return "high"
|
||||
}
|
||||
|
||||
const MASTERY_COLORS: Record<MasteryLevel, string> = {
|
||||
low: "border-red-500 bg-red-50 dark:bg-red-950/30",
|
||||
medium: "border-yellow-500 bg-yellow-50 dark:bg-yellow-950/30",
|
||||
high: "border-green-500 bg-green-50 dark:bg-green-950/30",
|
||||
unassessed: "border-border bg-card",
|
||||
}
|
||||
|
||||
const MASTERY_BAR_COLORS: Record<MasteryLevel, string> = {
|
||||
low: "bg-red-500",
|
||||
medium: "bg-yellow-500",
|
||||
high: "bg-green-500",
|
||||
unassessed: "bg-muted",
|
||||
}
|
||||
|
||||
function GraphKpNodeComponent({ data, selected }: NodeProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
const nodeData = data as unknown as GraphLayoutNodeData
|
||||
const { kp } = nodeData
|
||||
const graphData = (data as unknown as { graphData?: GraphNodeData }).graphData
|
||||
const mastery = graphData?.mastery ?? null
|
||||
const masteryLevel = getMasteryLevel(mastery?.masteryLevel ?? null)
|
||||
const showMastery = graphData?.viewMode === "student-mastery" || graphData?.viewMode === "class-mastery"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-2 px-3 py-2 shadow-sm transition-all",
|
||||
MASTERY_COLORS[masteryLevel],
|
||||
selected && "ring-2 ring-primary ring-offset-1",
|
||||
graphData?.isHighlighted && "ring-2 ring-primary",
|
||||
!graphData?.isHighlighted && graphData !== undefined && "opacity-40",
|
||||
)}
|
||||
style={{ width: 180 }}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} className="opacity-0" />
|
||||
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<span className="text-xs font-medium line-clamp-2 flex-1">{kp.name}</span>
|
||||
{kp.questionCount > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary shrink-0">
|
||||
{kp.questionCount} {t("graph.node.questions")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showMastery && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground mb-1">
|
||||
<span>{t("graph.node.mastery")}</span>
|
||||
<span>
|
||||
{mastery ? `${Math.round(mastery.masteryLevel)}%` : t("graph.detail.masteryNotAssessed")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full transition-all", MASTERY_BAR_COLORS[masteryLevel])}
|
||||
style={{ width: `${mastery?.masteryLevel ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kp.chapterTitle && (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground truncate">
|
||||
{kp.chapterTitle}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle type="source" position={Position.Bottom} className="opacity-0" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const GraphKpNode = memo(GraphKpNodeComponent)
|
||||
182
src/modules/textbooks/components/graph-node-detail-panel.tsx
Normal file
182
src/modules/textbooks/components/graph-node-detail-panel.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import Link from "next/link"
|
||||
import { X, ExternalLink, Plus, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import type { KpWithRelations, MasteryInfo } from "../types"
|
||||
|
||||
interface GraphNodeDetailPanelProps {
|
||||
kp: KpWithRelations
|
||||
mastery: MasteryInfo | null
|
||||
prerequisites: { id: string; name: string; description: string | null }[]
|
||||
successors: { id: string; name: string; description: string | null }[]
|
||||
canEdit: boolean
|
||||
textbookId: string
|
||||
onClose: () => void
|
||||
onJumpToKp: (kpId: string) => void
|
||||
onAddPrerequisite: () => void
|
||||
onRemovePrerequisite: (prereqId: string) => void
|
||||
}
|
||||
|
||||
export function GraphNodeDetailPanel({
|
||||
kp,
|
||||
mastery,
|
||||
prerequisites,
|
||||
successors,
|
||||
canEdit,
|
||||
onClose,
|
||||
onJumpToKp,
|
||||
onAddPrerequisite,
|
||||
onRemovePrerequisite,
|
||||
}: GraphNodeDetailPanelProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
|
||||
const correctRate = mastery && mastery.totalQuestions > 0
|
||||
? Math.round((mastery.correctQuestions / mastery.totalQuestions) * 100)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full border-l bg-background">
|
||||
<div className="flex items-center justify-between p-3 border-b shrink-0">
|
||||
<h3 className="text-sm font-semibold truncate">{t("graph.detail.title")}</h3>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="p-3 space-y-4">
|
||||
{/* 知识点名称 */}
|
||||
<div>
|
||||
<h4 className="text-base font-medium">{kp.name}</h4>
|
||||
{kp.chapterTitle && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{kp.chapterTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t("graph.detail.title")}
|
||||
</h5>
|
||||
<p className="text-sm">
|
||||
{kp.description || t("graph.detail.noDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 掌握度 */}
|
||||
{mastery && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-muted-foreground mb-2">
|
||||
{t("graph.node.mastery")}
|
||||
</h5>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>{t("graph.detail.correctRate")}</span>
|
||||
<span>{correctRate !== null ? `${correctRate}%` : t("graph.detail.masteryNotAssessed")}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("graph.detail.totalQuestions")}</span>
|
||||
<span>{mastery.totalQuestions}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 关联题目 */}
|
||||
<Separator />
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h5 className="text-xs font-medium text-muted-foreground">
|
||||
{t("graph.node.questions")} ({kp.questionCount})
|
||||
</h5>
|
||||
{kp.questionCount > 0 && (
|
||||
<Button asChild variant="ghost" size="sm" className="h-7 text-xs">
|
||||
<Link href={`/teacher/questions?kp=${kp.id}`}>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
{t("graph.detail.viewAllQuestions")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 前置知识点 */}
|
||||
<Separator />
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h5 className="text-xs font-medium text-muted-foreground">
|
||||
{t("graph.node.prerequisite")} ({prerequisites.length})
|
||||
</h5>
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={onAddPrerequisite}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{t("graph.detail.addPrerequisite")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{prerequisites.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("graph.detail.noPrerequisites")}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{prerequisites.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs justify-start flex-1"
|
||||
onClick={() => onJumpToKp(p.id)}
|
||||
>
|
||||
{p.name}
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onRemovePrerequisite(p.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 后置知识点 */}
|
||||
<Separator />
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-muted-foreground mb-2">
|
||||
{t("graph.node.successor")} ({successors.length})
|
||||
</h5>
|
||||
{successors.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("graph.detail.noSuccessors")}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{successors.map((s) => (
|
||||
<Badge
|
||||
key={s.id}
|
||||
variant="outline"
|
||||
className="cursor-pointer hover:bg-accent text-xs"
|
||||
onClick={() => onJumpToKp(s.id)}
|
||||
>
|
||||
{s.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/modules/textbooks/components/graph-prerequisite-edge.tsx
Normal file
45
src/modules/textbooks/components/graph-prerequisite-edge.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { memo } from "react"
|
||||
import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
function GraphPrerequisiteEdgeComponent({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
const isHighlighted = (data as { isHighlighted?: boolean } | undefined)?.isHighlighted ?? false
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
isHighlighted ? "opacity-100" : "opacity-30",
|
||||
)}
|
||||
style={{
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: "6 4",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const GraphPrerequisiteEdge = memo(GraphPrerequisiteEdgeComponent)
|
||||
86
src/modules/textbooks/components/graph-toolbar.tsx
Normal file
86
src/modules/textbooks/components/graph-toolbar.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Search, RotateCcw } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import type { GraphViewMode } from "../types"
|
||||
|
||||
interface GraphToolbarProps {
|
||||
viewMode: GraphViewMode
|
||||
onViewModeChange: (mode: GraphViewMode) => void
|
||||
availableViewModes: GraphViewMode[]
|
||||
searchText: string
|
||||
onSearchChange: (text: string) => void
|
||||
onResetView: () => void
|
||||
}
|
||||
|
||||
const ALL_VIEW_MODES: readonly GraphViewMode[] = [
|
||||
"structure",
|
||||
"student-mastery",
|
||||
"class-mastery",
|
||||
]
|
||||
|
||||
function isGraphViewMode(value: string): value is GraphViewMode {
|
||||
return ALL_VIEW_MODES.some((mode) => mode === value)
|
||||
}
|
||||
|
||||
export function GraphToolbar({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
availableViewModes,
|
||||
searchText,
|
||||
onSearchChange,
|
||||
onResetView,
|
||||
}: GraphToolbarProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
|
||||
const handleValueChange = (v: string): void => {
|
||||
if (isGraphViewMode(v)) {
|
||||
onViewModeChange(v)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 p-2 border-b bg-background/95 shrink-0">
|
||||
<Select value={viewMode} onValueChange={handleValueChange}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableViewModes.includes("structure") && (
|
||||
<SelectItem value="structure">{t("graph.viewMode.structure")}</SelectItem>
|
||||
)}
|
||||
{availableViewModes.includes("student-mastery") && (
|
||||
<SelectItem value="student-mastery">{t("graph.viewMode.studentMastery")}</SelectItem>
|
||||
)}
|
||||
{availableViewModes.includes("class-mastery") && (
|
||||
<SelectItem value="class-mastery">{t("graph.viewMode.classMastery")}</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="relative flex-1 min-w-[120px]">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t("graph.toolbar.search")}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" className="h-8 px-2" onClick={onResetView}>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
<span className="text-xs">{t("graph.toolbar.resetView")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,81 +1,277 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useState, useMemo, useCallback } from "react"
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
MiniMap,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
type Node,
|
||||
type Edge,
|
||||
} from "@xyflow/react"
|
||||
import "@xyflow/react/dist/style.css"
|
||||
import { useTranslations } from "next-intl"
|
||||
import type { KnowledgePoint } from "../types"
|
||||
import { computeGraphLayout, NODE_WIDTH, NODE_HEIGHT } from "../graph-layout"
|
||||
import { Share2 } from "lucide-react"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import type { GraphViewMode, GraphNodeData } from "../types"
|
||||
import { computeGraphLayout } from "../graph-layout"
|
||||
import { useGraphData } from "../hooks/use-graph-data"
|
||||
import { GraphKpNode } from "./graph-kp-node"
|
||||
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
|
||||
import { GraphToolbar } from "./graph-toolbar"
|
||||
import { GraphNodeDetailPanel } from "./graph-node-detail-panel"
|
||||
|
||||
const nodeTypes = { kpNode: GraphKpNode }
|
||||
const edgeTypes = { prerequisiteEdge: GraphPrerequisiteEdge }
|
||||
|
||||
/** 章节颜色调色板 */
|
||||
const CHAPTER_COLORS = [
|
||||
"#3b82f6", "#ef4444", "#10b981", "#f59e0b",
|
||||
"#8b5cf6", "#ec4899", "#06b6d4", "#84cc16",
|
||||
]
|
||||
|
||||
interface KnowledgeGraphProps {
|
||||
knowledgePoints: KnowledgePoint[]
|
||||
selectedId: string | null
|
||||
onHighlight: (id: string) => void
|
||||
textbookId: string
|
||||
/** 初始视图模式,默认 structure */
|
||||
initialViewMode?: GraphViewMode
|
||||
}
|
||||
|
||||
export function KnowledgeGraph({
|
||||
knowledgePoints,
|
||||
selectedId,
|
||||
onHighlight,
|
||||
}: KnowledgeGraphProps) {
|
||||
function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: KnowledgeGraphProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
const { hasPermission } = usePermission()
|
||||
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
|
||||
const isTeacher = hasPermission(Permissions.TEXTBOOK_UPDATE)
|
||||
const reactFlow = useReactFlow()
|
||||
|
||||
const layout = useMemo(() => computeGraphLayout(knowledgePoints), [knowledgePoints])
|
||||
const [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [selectedKpId, setSelectedKpId] = useState<string | null>(null)
|
||||
|
||||
if (knowledgePoints.length === 0) {
|
||||
const { data, isLoading, error } = useGraphData(textbookId, viewMode)
|
||||
|
||||
const availableViewModes: GraphViewMode[] = isTeacher
|
||||
? ["structure", "class-mastery"]
|
||||
: ["structure", "student-mastery"]
|
||||
|
||||
// 章节颜色映射
|
||||
const chapterColorMap = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
if (!data) return map
|
||||
const chapterIds = [...new Set(
|
||||
data.knowledgePoints
|
||||
.map((kp) => kp.chapterId)
|
||||
.filter((id): id is string => id !== null),
|
||||
)]
|
||||
chapterIds.forEach((id, index) => {
|
||||
map.set(id, CHAPTER_COLORS[index % CHAPTER_COLORS.length]!)
|
||||
})
|
||||
return map
|
||||
}, [data])
|
||||
|
||||
const layout = useMemo(() => {
|
||||
if (!data) return { nodes: [], edges: [], width: 0, height: 0 }
|
||||
return computeGraphLayout(data.knowledgePoints)
|
||||
}, [data])
|
||||
|
||||
// 搜索高亮
|
||||
const matchedIds = useMemo(() => {
|
||||
if (!searchText || !data) return new Set<string>()
|
||||
const searchLower = searchText.toLowerCase()
|
||||
return new Set(
|
||||
data.knowledgePoints
|
||||
.filter((kp) => kp.name.toLowerCase().includes(searchLower))
|
||||
.map((kp) => kp.id),
|
||||
)
|
||||
}, [searchText, data])
|
||||
|
||||
// 关联节点高亮(选中节点的前置+后置)
|
||||
const relatedIds = useMemo(() => {
|
||||
if (!selectedKpId || !data) return new Set<string>()
|
||||
const related = new Set<string>([selectedKpId])
|
||||
const selectedKp = data.knowledgePoints.find((kp) => kp.id === selectedKpId)
|
||||
if (selectedKp) {
|
||||
for (const id of selectedKp.prerequisiteIds) related.add(id)
|
||||
for (const kp of data.knowledgePoints) {
|
||||
if (kp.prerequisiteIds.includes(selectedKpId)) related.add(kp.id)
|
||||
}
|
||||
}
|
||||
return related
|
||||
}, [selectedKpId, data])
|
||||
|
||||
// 从已加载数据计算前置/后置列表(避免 server-only 导入)
|
||||
const prerequisites = useMemo<{ id: string; name: string; description: string | null }[]>(() => {
|
||||
if (!selectedKpId || !data) return []
|
||||
const selectedKp = data.knowledgePoints.find((kp) => kp.id === selectedKpId)
|
||||
if (!selectedKp) return []
|
||||
return data.knowledgePoints
|
||||
.filter((kp) => selectedKp.prerequisiteIds.includes(kp.id))
|
||||
.map((kp) => ({ id: kp.id, name: kp.name, description: kp.description }))
|
||||
}, [selectedKpId, data])
|
||||
|
||||
const successors = useMemo<{ id: string; name: string; description: string | null }[]>(() => {
|
||||
if (!selectedKpId || !data) return []
|
||||
return data.knowledgePoints
|
||||
.filter((kp) => kp.prerequisiteIds.includes(selectedKpId))
|
||||
.map((kp) => ({ id: kp.id, name: kp.name, description: kp.description }))
|
||||
}, [selectedKpId, data])
|
||||
|
||||
// 组装 React Flow nodes
|
||||
const rfNodes: Node[] = useMemo(() => {
|
||||
return layout.nodes.map((node) => {
|
||||
const kp = node.data.kp
|
||||
const mastery = data?.masteryMap[kp.id] ?? null
|
||||
const isSelected = selectedKpId === node.id
|
||||
const isHighlighted = !searchText
|
||||
? (selectedKpId === null || relatedIds.has(node.id))
|
||||
: matchedIds.has(node.id)
|
||||
|
||||
const graphData: GraphNodeData = {
|
||||
kp,
|
||||
mastery,
|
||||
viewMode,
|
||||
isSelected,
|
||||
isHighlighted,
|
||||
chapterColor: chapterColorMap.get(kp.chapterId ?? "") ?? "#6b7280",
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
data: { ...node.data, graphData },
|
||||
selected: isSelected,
|
||||
}
|
||||
})
|
||||
}, [layout, data, selectedKpId, relatedIds, matchedIds, searchText, viewMode, chapterColorMap])
|
||||
|
||||
// 组装 React Flow edges
|
||||
const rfEdges: Edge[] = useMemo(() => {
|
||||
return layout.edges.map((edge) => ({
|
||||
...edge,
|
||||
data: {
|
||||
...edge.data,
|
||||
isHighlighted: selectedKpId === null || relatedIds.has(edge.source) || relatedIds.has(edge.target),
|
||||
},
|
||||
}))
|
||||
}, [layout, selectedKpId, relatedIds])
|
||||
|
||||
const onNodeClick = useCallback((_event: unknown, node: Node) => {
|
||||
setSelectedKpId(node.id)
|
||||
}, [])
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
reactFlow.fitView({ duration: 300 })
|
||||
setSearchText("")
|
||||
setSelectedKpId(null)
|
||||
}, [reactFlow])
|
||||
|
||||
const onJumpToKp = useCallback((kpId: string) => {
|
||||
setSelectedKpId(kpId)
|
||||
reactFlow.fitView({ nodes: [{ id: kpId }], duration: 300 })
|
||||
}, [reactFlow])
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
{t("reader.emptyKnowledge")}
|
||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
{t("reader.loadingKnowledge")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto p-4">
|
||||
<svg
|
||||
width={layout.width}
|
||||
height={layout.height}
|
||||
role="img"
|
||||
aria-label={t("reader.tabs.graph")}
|
||||
className="mx-auto"
|
||||
>
|
||||
<title>{t("reader.tabs.graph")}</title>
|
||||
{/* 边 */}
|
||||
{layout.edges.map((edge) => (
|
||||
<line
|
||||
key={edge.id}
|
||||
x1={edge.x1}
|
||||
y1={edge.y1}
|
||||
x2={edge.x2}
|
||||
y2={edge.y2}
|
||||
stroke="currentColor"
|
||||
strokeOpacity={0.3}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Share2}
|
||||
title={t("graph.error.loadFailed")}
|
||||
description={error}
|
||||
className="h-full border-none shadow-none bg-transparent"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{/* 节点 */}
|
||||
{layout.nodes.map((node) => {
|
||||
const isSelected = selectedId === node.id
|
||||
return (
|
||||
<g key={node.id} transform={`translate(${node.x}, ${node.y})`}>
|
||||
<foreignObject width={NODE_WIDTH} height={NODE_HEIGHT}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onHighlight(node.id)}
|
||||
className={`flex h-full w-full items-center justify-center rounded-lg border-2 px-3 text-center text-xs font-medium transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-card-foreground hover:border-primary/50 hover:bg-accent"
|
||||
}`}
|
||||
aria-label={`${t("reader.clickToViewKp")}: ${node.name}`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<span className="line-clamp-2">{node.name}</span>
|
||||
</button>
|
||||
</foreignObject>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
if (!data || data.knowledgePoints.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Share2}
|
||||
title={t("reader.emptyKnowledge")}
|
||||
description={t("reader.emptyKnowledgeDesc")}
|
||||
className="h-full border-none shadow-none bg-transparent"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedKp = selectedKpId ? data.knowledgePoints.find((kp) => kp.id === selectedKpId) : null
|
||||
const selectedMastery = selectedKpId ? data.masteryMap[selectedKpId] ?? null : null
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<GraphToolbar
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
availableViewModes={availableViewModes}
|
||||
searchText={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
onResetView={resetView}
|
||||
/>
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.2}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
<Controls className="!bg-background !border !rounded-lg" />
|
||||
<MiniMap
|
||||
className="!bg-background !border !rounded-lg"
|
||||
nodeColor={(node) => {
|
||||
// node.data 是 Record<string, unknown>;从 unknown 安全转换读取 graphData
|
||||
const graphData = (node.data as unknown as { graphData?: { chapterColor: string } })?.graphData
|
||||
return graphData?.chapterColor ?? "#6b7280"
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedKp && (
|
||||
<div className="w-[300px] shrink-0">
|
||||
<GraphNodeDetailPanel
|
||||
kp={selectedKp}
|
||||
mastery={selectedMastery}
|
||||
prerequisites={prerequisites}
|
||||
successors={successors}
|
||||
canEdit={canEdit}
|
||||
textbookId={textbookId}
|
||||
onClose={() => setSelectedKpId(null)}
|
||||
onJumpToKp={onJumpToKp}
|
||||
onAddPrerequisite={() => {
|
||||
// 后续迭代:打开添加前置对话框
|
||||
}}
|
||||
onRemovePrerequisite={(_prereqId: string) => {
|
||||
// 后续迭代:调用 deletePrerequisiteAction
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function KnowledgeGraph(props: KnowledgeGraphProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<KnowledgeGraphInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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<Record<string, KnowledgePoint[]>>({})
|
||||
const requestedChaptersRef = useRef<Set<string>>(new Set())
|
||||
// 用 useMemo 包裹以稳定引用,避免下游 useMemo 因 [] 引用变化而重复计算
|
||||
const currentChapterKPs = useMemo<KnowledgePoint[]>(
|
||||
() => (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 (
|
||||
<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" />
|
||||
{t("reader.tabs.chapters")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
|
||||
<Tag className="h-4 w-4" />
|
||||
{t("reader.tabs.knowledge")}
|
||||
{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" />
|
||||
{t("reader.tabs.graph")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div className="space-y-1 pb-4">
|
||||
<ChapterSidebarList
|
||||
chapters={chapters}
|
||||
selectedChapterId={selectedId || undefined}
|
||||
onSelectChapter={handleSelect}
|
||||
textbookId={textbookId || ""}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TextbookSectionErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<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 ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
{t("reader.selectChapterKnowledge")}
|
||||
</div>
|
||||
) : (
|
||||
<KnowledgePointList
|
||||
knowledgePoints={currentChapterKPs}
|
||||
canEdit={canEdit}
|
||||
canCreateQuestion={canCreateQuestion}
|
||||
highlightedKpId={highlightedKpId}
|
||||
onHighlight={setHighlightedKpId}
|
||||
onEdit={(kp) => {
|
||||
setEditingKp(kp)
|
||||
setEditKpDialogOpen(true)
|
||||
}}
|
||||
onDelete={requestDeleteKnowledgePoint}
|
||||
onCreateQuestion={(kp) => {
|
||||
setTargetKpForQuestion(kp)
|
||||
setQuestionDialogOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TextbookSectionErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<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 ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
{t("reader.selectChapterGraph")}
|
||||
</div>
|
||||
) : (
|
||||
<KnowledgeGraph
|
||||
knowledgePoints={currentChapterKPs}
|
||||
selectedId={highlightedKpId}
|
||||
onHighlight={setHighlightedKpId}
|
||||
/>
|
||||
)}
|
||||
</TextbookSectionErrorBoundary>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
// P2-4 侧边栏内容(章节/知识点/图谱 Tabs),桌面端内联、移动端抽屉复用同一份
|
||||
const sidebarContent = (
|
||||
<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" />
|
||||
{t("reader.tabs.chapters")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
|
||||
<Tag className="h-4 w-4" />
|
||||
{t("reader.tabs.knowledge")}
|
||||
{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">
|
||||
<Share2 className="h-4 w-4" />
|
||||
{t("reader.tabs.graph")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div className="space-y-1 pb-4">
|
||||
<ChapterSidebarList
|
||||
chapters={chapters}
|
||||
selectedChapterId={selectedId || undefined}
|
||||
onSelectChapter={handleSelect}
|
||||
textbookId={textbookId || ""}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TextbookSectionErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<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 ? (
|
||||
<EmptyState
|
||||
icon={Tag}
|
||||
title={t("reader.selectChapterKnowledge")}
|
||||
description={t("reader.selectChapterKnowledgeDesc")}
|
||||
className="h-full border-none shadow-none bg-transparent"
|
||||
/>
|
||||
) : isLoadingKPs ? (
|
||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
{t("reader.loadingKnowledge")}
|
||||
</div>
|
||||
) : (
|
||||
<KnowledgePointList
|
||||
knowledgePoints={currentChapterKPs}
|
||||
canEdit={canEdit}
|
||||
canCreateQuestion={canCreateQuestion}
|
||||
highlightedKpId={highlightedKpId}
|
||||
onHighlight={setHighlightedKpId}
|
||||
onEdit={(kp) => {
|
||||
setEditingKp(kp)
|
||||
setEditKpDialogOpen(true)
|
||||
}}
|
||||
onDelete={requestDeleteKnowledgePoint}
|
||||
onCreateQuestion={(kp) => {
|
||||
setTargetKpForQuestion(kp)
|
||||
setQuestionDialogOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TextbookSectionErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
|
||||
<TextbookSectionErrorBoundary
|
||||
fallbackTitle={t("error.loadFailed")}
|
||||
fallbackDescription={t("error.loadFailedDesc")}
|
||||
retryLabel={t("error.retry")}
|
||||
>
|
||||
<KnowledgeGraph textbookId={textbookId} />
|
||||
</TextbookSectionErrorBoundary>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 h-full">
|
||||
{/* P2-4 桌面端侧栏:lg 及以上内联显示 */}
|
||||
<div className="hidden lg:flex lg:col-span-4 lg:border-r lg:pr-6 flex-col min-h-0">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
|
||||
{/* P2-4 移动端侧栏:lg 以下用 Sheet 抽屉式展示 */}
|
||||
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
|
||||
<SheetContent side="left" className="w-[85vw] max-w-sm p-0 flex flex-col">
|
||||
<SheetHeader className="px-4 py-3 border-b shrink-0">
|
||||
<SheetTitle className="text-left">{t("reader.sidebar")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 min-h-0 p-2">{sidebarContent}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="lg:col-span-8 flex flex-col min-h-0 relative">
|
||||
{/* P2-4 移动端侧栏触发按钮 */}
|
||||
<div className="lg:hidden flex items-center gap-2 mb-3 px-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
aria-expanded={mobileSidebarOpen}
|
||||
aria-controls="mobile-sidebar-sheet"
|
||||
>
|
||||
<Menu className="mr-2 h-4 w-4" />
|
||||
{t("reader.openSidebar")}
|
||||
</Button>
|
||||
{selected && (
|
||||
<span className="text-sm text-muted-foreground truncate">
|
||||
{selected.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user