feat(textbooks): add force-graph component and update graph components

- Add force-graph.tsx for new graph visualization

- Update graph-toolbar, knowledge-graph, knowledge-point-list, textbook-reader

- Update use-graph-data hook and types

- Update textbooks error boundary pages
This commit is contained in:
SpecialX
2026-07-04 10:22:24 +08:00
parent 25dca843be
commit 6ea8ba763b
11 changed files with 667 additions and 153 deletions

View File

@@ -1,13 +1,12 @@
"use client"
import { RouteError } from "@/shared/components/route-error"
import { RouteErrorBoundary } from "@/shared/components/route-error"
export default function StudentTextbookDetailError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return <RouteError error={error} reset={reset} namespace="textbooks" />
return <RouteErrorBoundary reset={reset} namespace="textbooks" />
}

View File

@@ -1,13 +1,12 @@
"use client"
import { RouteError } from "@/shared/components/route-error"
import { RouteErrorBoundary } from "@/shared/components/route-error"
export default function StudentTextbooksError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return <RouteError error={error} reset={reset} namespace="textbooks" />
return <RouteErrorBoundary reset={reset} namespace="textbooks" />
}

View File

@@ -1,13 +1,12 @@
"use client"
import { RouteError } from "@/shared/components/route-error"
import { RouteErrorBoundary } from "@/shared/components/route-error"
export default function TeacherTextbookDetailError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return <RouteError error={error} reset={reset} namespace="textbooks" />
return <RouteErrorBoundary reset={reset} namespace="textbooks" />
}

View File

@@ -1,13 +1,12 @@
"use client"
import { RouteError } from "@/shared/components/route-error"
import { RouteErrorBoundary } from "@/shared/components/route-error"
export default function TeacherTextbooksError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return <RouteError error={error} reset={reset} namespace="textbooks" />
return <RouteErrorBoundary reset={reset} namespace="textbooks" />
}

View File

@@ -0,0 +1,446 @@
"use client"
/**
* Obsidian 风格的知识点力导向图谱。
*
* 视觉对齐 Obsidian Graph View
* - 小圆点节点(非大圆球),半径随连接数轻微变化
* - 极细半透明边线,父子/依赖用颜色区分Obsidian 无虚线)
* - 标签默认显示在节点右侧,缩放小时按连接数优先显示
* - 选中/hover 节点时高亮邻居,无关节点淡化
* - 节点拖拽联动d3-force 物理模拟)
*
* 通过 next/dynamic ssr:false 加载,避免 SSR 阶段访问 canvas/window。
* 顶部用 `import type` 引入类型,编译期擦除,防止模块顶层副作用在 SSR 执行。
*/
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react"
import dynamic from "next/dynamic"
import type ForceGraph2DType from "react-force-graph-2d"
import type {
ForceGraphMethods,
NodeObject,
LinkObject,
} from "react-force-graph-2d"
import { useTranslations } from "next-intl"
import { Share2 } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type {
GraphViewMode,
KnowledgeGraphData,
KpWithRelations,
MasteryInfo,
MasteryLevel,
} from "../types"
/** 章节颜色调色板柔和、Obsidian 风格) */
const CHAPTER_COLORS = [
"#7c3aed", "#2563eb", "#dc2626", "#059669",
"#d97706", "#db2777", "#0891b2", "#65a30d",
"#9333ea", "#0284c7", "#e11d48", "#16a34a",
]
/** 掌握度色彩(柔和) */
const MASTERY_FILL: Record<MasteryLevel, string> = {
low: "#dc2626",
medium: "#d97706",
high: "#059669",
unassessed: "#94a3b8",
}
/** 边颜色:父子关系(中性灰)/ 前置依赖(紫色) */
const EDGE_PARENT_COLOR = "rgba(148, 163, 184, 0.5)"
const EDGE_PREREQ_COLOR = "rgba(124, 58, 237, 0.6)"
/** 力导向布局参数(参照 Obsidian 默认手感)。
* Obsidian 用较强斥力让节点分散链接距离短collide 防重叠。
*/
const FORCE_CHARGE = -280
const FORCE_LINK_DISTANCE = 45
const FORCE_CENTER_STRENGTH = 0.02
/** 节点基础半径Obsidian 风格:小圆点) */
const NODE_BASE_RADIUS = 4
const NODE_MAX_RADIUS = 9
/** 标签显示的缩放阈值(缩放小于此值时只显示高连接数节点标签) */
const LABEL_MIN_SCALE = 1.2
/** 缩放小于此值时不显示任何标签 */
const LABEL_HIDE_SCALE = 0.6
/**
* 动态加载的 react-force-graph-2dSSR 关闭)。
* 用 `as unknown as ComponentType<...>` 保留完整 props 类型。
*/
const ForceGraph2D = dynamic(
() => import("react-force-graph-2d").then((mod) => mod.default),
{
ssr: false,
loading: () => <ForceGraphLoading />,
},
) as unknown as ComponentType<React.ComponentProps<typeof ForceGraph2DType>>
function ForceGraphLoading() {
const t = useTranslations("textbooks")
return (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
{t("reader.loadingKnowledge")}
</div>
)
}
function getMasteryLevel(mastery: MasteryInfo | null): MasteryLevel {
if (!mastery) return "unassessed"
if (mastery.masteryLevel < 60) return "low"
if (mastery.masteryLevel < 85) return "medium"
return "high"
}
/** 力导向图谱节点 */
interface KpGraphNode extends NodeObject {
id: string
name: string
kp: KpWithRelations
mastery: MasteryInfo | null
/** 连接数(决定节点大小) */
val: number
/** 章节颜色 */
chapterColor: string
}
/** 力导向图谱边 */
interface KpGraphLink {
source: string | KpGraphNode
target: string | KpGraphNode
/** 边类型parent树归属/ prerequisite依赖 */
edgeType: "parent" | "prerequisite"
}
interface ForceGraphProps {
data: KnowledgeGraphData
viewMode: GraphViewMode
searchText: string
selectedKpId: string | null
onSelectKp: (kpId: string | null) => void
}
/** 将业务数据转换为 react-force-graph-2d graphData 格式 */
function buildGraphData(
data: KnowledgeGraphData,
): { nodes: KpGraphNode[]; links: KpGraphLink[] } {
const kpIdSet = new Set(data.knowledgePoints.map((kp) => kp.id))
// 章节颜色映射
const chapterColorMap = new Map<string, string>()
const chapterIds = [...new Set(
data.knowledgePoints
.map((kp) => kp.chapterId)
.filter((id): id is string => id !== null),
)]
chapterIds.forEach((id, index) => {
chapterColorMap.set(id, CHAPTER_COLORS[index % CHAPTER_COLORS.length]!)
})
// 计算每个节点的连接数
const connectionCount = new Map<string, number>()
for (const kp of data.knowledgePoints) {
if (kp.parentId && kpIdSet.has(kp.parentId)) {
connectionCount.set(kp.id, (connectionCount.get(kp.id) ?? 0) + 1)
connectionCount.set(kp.parentId, (connectionCount.get(kp.parentId) ?? 0) + 1)
}
for (const prereqId of kp.prerequisiteIds) {
if (kpIdSet.has(prereqId)) {
connectionCount.set(kp.id, (connectionCount.get(kp.id) ?? 0) + 1)
connectionCount.set(prereqId, (connectionCount.get(prereqId) ?? 0) + 1)
}
}
}
const nodes: KpGraphNode[] = data.knowledgePoints.map((kp) => ({
id: kp.id,
name: kp.name,
kp,
mastery: data.masteryMap[kp.id] ?? null,
val: connectionCount.get(kp.id) ?? 0,
chapterColor: chapterColorMap.get(kp.chapterId ?? "") ?? "#6b7280",
}))
const links: KpGraphLink[] = []
for (const kp of data.knowledgePoints) {
if (kp.parentId && kpIdSet.has(kp.parentId)) {
links.push({ source: kp.parentId, target: kp.id, edgeType: "parent" })
}
for (const prereqId of kp.prerequisiteIds) {
if (kpIdSet.has(prereqId)) {
links.push({ source: prereqId, target: kp.id, edgeType: "prerequisite" })
}
}
}
return { nodes, links }
}
/** 计算节点邻居集合(含自身) */
function computeNeighbors(data: KnowledgeGraphData, kpId: string): Set<string> {
const result = new Set<string>([kpId])
const selectedKp = data.knowledgePoints.find((kp) => kp.id === kpId)
if (selectedKp) {
if (selectedKp.parentId) result.add(selectedKp.parentId)
for (const id of selectedKp.prerequisiteIds) result.add(id)
for (const kp of data.knowledgePoints) {
if (kp.parentId === kpId) result.add(kp.id)
if (kp.prerequisiteIds.includes(kpId)) result.add(kp.id)
}
}
return result
}
function getNodeHighlightState(
nodeId: string,
searchText: string,
matchedIds: Set<string>,
selectedKpId: string | null,
neighborIds: Set<string>,
): "highlighted" | "dimmed" | "normal" {
if (searchText) {
return matchedIds.has(nodeId) ? "highlighted" : "dimmed"
}
if (selectedKpId) {
return neighborIds.has(nodeId) ? "highlighted" : "dimmed"
}
return "normal"
}
/** 节点半径:基础 + 连接数轻微缩放Obsidian 风格,变化幅度小) */
function getNodeRadius(val: number): number {
const scaled = NODE_BASE_RADIUS + Math.log2(val + 1) * 1.8
return Math.min(NODE_MAX_RADIUS, scaled)
}
function ForceGraphInner({
data,
viewMode,
searchText,
selectedKpId,
onSelectKp,
}: ForceGraphProps) {
const t = useTranslations("textbooks")
const containerRef = useRef<HTMLDivElement>(null)
const fgRef = useRef<ForceGraphMethods | undefined>(undefined)
const [hoveredKpId, setHoveredKpId] = useState<string | null>(null)
const [dimensions, setDimensions] = useState({ width: 800, height: 600 })
const graphData = useMemo(() => buildGraphData(data), [data])
// 搜索匹配
const matchedIds = useMemo(() => {
if (!searchText) 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 selectedNeighborIds = useMemo(() => {
if (!selectedKpId) return new Set<string>()
return computeNeighbors(data, selectedKpId)
}, [selectedKpId, data])
// hover 节点的邻居集合Obsidian 风格hover 也高亮邻居)
const hoveredNeighborIds = useMemo(() => {
if (!hoveredKpId) return new Set<string>()
return computeNeighbors(data, hoveredKpId)
}, [hoveredKpId, data])
// 当前生效的高亮邻居集合(选中优先于 hover
const activeNeighborIds = selectedKpId ? selectedNeighborIds : hoveredNeighborIds
const activeFocusId = selectedKpId ?? hoveredKpId
// 容器尺寸自适应
useEffect(() => {
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry) {
setDimensions({
width: Math.max(320, entry.contentRect.width),
height: Math.max(240, entry.contentRect.height),
})
}
})
observer.observe(container)
return () => observer.disconnect()
}, [])
// 配置力Obsidian 风格)
useEffect(() => {
const fg = fgRef.current
if (!fg) return
fg.d3Force("charge")?.strength(FORCE_CHARGE)
fg.d3Force("link")?.distance(FORCE_LINK_DISTANCE)
fg.d3Force("center")?.strength(FORCE_CENTER_STRENGTH)
fg.d3ReheatSimulation()
}, [graphData])
// 节点 Canvas 绘制Obsidian 风格)
const nodeCanvasObject = useCallback(
(node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
const kpNode = node as KpGraphNode
const highlightState = getNodeHighlightState(
kpNode.id,
searchText,
matchedIds,
activeFocusId,
activeNeighborIds,
)
const radius = getNodeRadius(kpNode.val)
const showMastery = viewMode === "student-mastery" || viewMode === "class-mastery"
const fillColor = showMastery
? MASTERY_FILL[getMasteryLevel(kpNode.mastery)]
: kpNode.chapterColor
// 透明度Obsidian 风格:淡化节点保持可见但低对比)
const alpha = highlightState === "dimmed" ? 0.15 : 1
ctx.save()
ctx.globalAlpha = alpha
// 选中/hover 节点外圈高亮环Obsidian 风格)
if (kpNode.id === activeFocusId) {
ctx.beginPath()
ctx.arc(kpNode.x ?? 0, kpNode.y ?? 0, radius + 3, 0, 2 * Math.PI)
ctx.strokeStyle = "rgba(124, 58, 237, 0.8)"
ctx.lineWidth = 1.5
ctx.stroke()
}
// 主圆Obsidian 风格:实心小圆点 + 轻微白色描边)
ctx.beginPath()
ctx.arc(kpNode.x ?? 0, kpNode.y ?? 0, radius, 0, 2 * Math.PI)
ctx.fillStyle = fillColor
ctx.fill()
ctx.strokeStyle = "rgba(255,255,255,0.9)"
ctx.lineWidth = 0.8
ctx.stroke()
// 标签Obsidian 风格:节点右侧,缩放小时只显示高连接数节点)
const shouldShowLabel = (() => {
if (globalScale < LABEL_HIDE_SCALE) return false
if (highlightState === "dimmed") return false
if (highlightState === "highlighted") return true
// 缩放足够大时显示所有标签,否则只显示连接数 ≥3 的
if (globalScale > LABEL_MIN_SCALE) return true
return kpNode.val >= 3
})()
if (shouldShowLabel) {
const fontSize = 11 / globalScale
ctx.font = `${fontSize}px -apple-system, system-ui, sans-serif`
ctx.textAlign = "left"
ctx.textBaseline = "middle"
// 标签背景Obsidian 风格:浅色背景提升可读性)
const labelX = (kpNode.x ?? 0) + radius + 2
const labelY = kpNode.y ?? 0
const labelWidth = ctx.measureText(kpNode.name).width
ctx.fillStyle = "rgba(255,255,255,0.85)"
ctx.fillRect(labelX - 1, labelY - fontSize / 2 - 1, labelWidth + 2, fontSize + 2)
// 标签文字
ctx.fillStyle = "rgba(30, 41, 59, 0.95)"
ctx.fillText(kpNode.name, labelX, labelY)
}
ctx.restore()
},
[searchText, matchedIds, activeFocusId, activeNeighborIds, viewMode],
)
// 边绘制Obsidian 风格:极细半透明线,颜色区分类型)
const linkCanvasObject = useCallback(
(link: LinkObject, ctx: CanvasRenderingContext2D) => {
const kpLink = link as KpGraphLink
const source = kpLink.source as KpGraphNode
const target = kpLink.target as KpGraphNode
if (!source.x || !source.y || !target.x || !target.y) return
// 高亮逻辑:有焦点时仅高亮含邻居节点的边
const isHighlighted =
activeFocusId === null ||
activeNeighborIds.has(source.id) ||
activeNeighborIds.has(target.id)
const alpha = isHighlighted ? 1 : 0.08
ctx.save()
ctx.globalAlpha = alpha
ctx.strokeStyle = kpLink.edgeType === "prerequisite" ? EDGE_PREREQ_COLOR : EDGE_PARENT_COLOR
ctx.lineWidth = 0.8
ctx.beginPath()
ctx.moveTo(source.x, source.y)
ctx.lineTo(target.x, target.y)
ctx.stroke()
ctx.restore()
},
[activeFocusId, activeNeighborIds],
)
const handleNodeClick = useCallback(
(node: NodeObject) => {
const kpNode = node as KpGraphNode
onSelectKp(kpNode.id === selectedKpId ? null : kpNode.id)
},
[onSelectKp, selectedKpId],
)
const handleBackgroundClick = useCallback(() => {
onSelectKp(null)
}, [onSelectKp])
const handleNodeHover = useCallback((node: NodeObject | null) => {
setHoveredKpId(node ? (node as KpGraphNode).id : null)
if (containerRef.current) {
containerRef.current.style.cursor = node ? "pointer" : "default"
}
}, [])
if (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"
/>
)
}
return (
<div ref={containerRef} className="w-full h-full relative bg-muted/20">
<ForceGraph2D
ref={fgRef}
graphData={graphData}
width={dimensions.width}
height={dimensions.height}
nodeId="id"
nodeRelSize={1}
nodeCanvasObject={nodeCanvasObject}
linkCanvasObject={linkCanvasObject}
onNodeClick={handleNodeClick}
onBackgroundClick={handleBackgroundClick}
onNodeHover={handleNodeHover}
cooldownTicks={150}
enableNodeDrag
enableZoomInteraction
enablePanInteraction
minZoom={0.15}
maxZoom={5}
/>
</div>
)
}
export function ForceKnowledgeGraph(props: ForceGraphProps) {
return <ForceGraphInner {...props} />
}

View File

@@ -11,12 +11,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import type { GraphViewMode } from "../types"
import type { GraphLayoutMode, GraphViewMode } from "../types"
interface GraphToolbarProps {
viewMode: GraphViewMode
onViewModeChange: (mode: GraphViewMode) => void
availableViewModes: GraphViewMode[]
layoutMode: GraphLayoutMode
onLayoutModeChange: (mode: GraphLayoutMode) => void
searchText: string
onSearchChange: (text: string) => void
onResetView: () => void
@@ -30,14 +32,22 @@ const ALL_VIEW_MODES: readonly GraphViewMode[] = [
"class-mastery",
]
const ALL_LAYOUT_MODES: readonly GraphLayoutMode[] = ["hierarchical", "force"]
function isGraphViewMode(value: string): value is GraphViewMode {
return ALL_VIEW_MODES.some((mode) => mode === value)
}
function isGraphLayoutMode(value: string): value is GraphLayoutMode {
return ALL_LAYOUT_MODES.some((mode) => mode === value)
}
export function GraphToolbar({
viewMode,
onViewModeChange,
availableViewModes,
layoutMode,
onLayoutModeChange,
searchText,
onSearchChange,
onResetView,
@@ -45,15 +55,32 @@ export function GraphToolbar({
}: GraphToolbarProps) {
const t = useTranslations("textbooks")
const handleValueChange = (v: string): void => {
const handleViewModeChange = (v: string): void => {
if (isGraphViewMode(v)) {
onViewModeChange(v)
}
}
const handleLayoutModeChange = (v: string): void => {
if (isGraphLayoutMode(v)) {
onLayoutModeChange(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}>
{/* 布局切换:分层有向图 / Obsidian 风格力导向图 */}
<Select value={layoutMode} onValueChange={handleLayoutModeChange}>
<SelectTrigger className="w-[120px] h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hierarchical">{t("graph.layout.hierarchical")}</SelectItem>
<SelectItem value="force">{t("graph.layout.force")}</SelectItem>
</SelectContent>
</Select>
<Select value={viewMode} onValueChange={handleViewModeChange}>
<SelectTrigger className="w-[140px] h-8 text-xs">
<SelectValue />
</SelectTrigger>

View File

@@ -35,7 +35,7 @@ import {
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Button } from "@/shared/components/ui/button"
import type { GraphViewMode, GraphNodeData } from "../types"
import type { GraphLayoutMode, GraphViewMode, GraphNodeData } from "../types"
import { computeGraphLayout } from "../graph-layout"
import type { GraphLayoutNodeData } from "../graph-layout"
import { useGraphData } from "../hooks/use-graph-data"
@@ -47,6 +47,7 @@ import { GraphKpNode } from "./graph-kp-node"
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
import { GraphToolbar } from "./graph-toolbar"
import { GraphNodeDetailPanel } from "./graph-node-detail-panel"
import { ForceKnowledgeGraph } from "./force-graph"
const nodeTypes = { kpNode: GraphKpNode }
const edgeTypes = { prerequisiteEdge: GraphPrerequisiteEdge }
@@ -61,15 +62,18 @@ interface KnowledgeGraphProps {
textbookId: string
/** 初始视图模式,默认 structure */
initialViewMode?: GraphViewMode
/** 初始布局模式,默认 hierarchical分层有向图 */
initialLayoutMode?: GraphLayoutMode
}
function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: KnowledgeGraphProps) {
function KnowledgeGraphInner({ textbookId, initialViewMode = "structure", initialLayoutMode = "hierarchical" }: KnowledgeGraphProps) {
const t = useTranslations("textbooks")
const { hasPermission } = usePermission()
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
const reactFlow = useReactFlow()
const [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
const [layoutMode, setLayoutMode] = useState<GraphLayoutMode>(initialLayoutMode)
const [searchText, setSearchText] = useState("")
const [selectedKpId, setSelectedKpId] = useState<string | null>(null)
// 添加前置依赖对话框状态
@@ -189,15 +193,19 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
}, [])
const resetView = useCallback(() => {
if (layoutMode === "hierarchical") {
reactFlow.fitView({ duration: 300 })
}
setSearchText("")
setSelectedKpId(null)
}, [reactFlow])
}, [reactFlow, layoutMode])
const onJumpToKp = useCallback((kpId: string) => {
setSelectedKpId(kpId)
if (layoutMode === "hierarchical") {
reactFlow.fitView({ nodes: [{ id: kpId }], duration: 300 })
}, [reactFlow])
}
}, [reactFlow, layoutMode])
// 添加前置依赖
const handleAddPrerequisite = useCallback(async () => {
@@ -293,12 +301,15 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
viewMode={viewMode}
onViewModeChange={setViewMode}
availableViewModes={availableViewModes}
layoutMode={layoutMode}
onLayoutModeChange={setLayoutMode}
searchText={searchText}
onSearchChange={setSearchText}
onResetView={resetView}
isRefreshing={isRefreshing}
/>
<div className="flex-1 min-h-0 relative">
{layoutMode === "hierarchical" ? (
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
@@ -323,6 +334,15 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
}}
/>
</ReactFlow>
) : (
<ForceKnowledgeGraph
data={data}
viewMode={viewMode}
searchText={searchText}
selectedKpId={selectedKpId}
onSelectKp={setSelectedKpId}
/>
)}
</div>
</div>

View File

@@ -49,14 +49,21 @@ export function KnowledgePointList({
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-2 pb-4">
{knowledgePoints.map((kp) => (
<button
<div
key={kp.id}
type="button"
role="button"
tabIndex={0}
className={cn(
"w-full text-left p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer",
highlightedKpId === kp.id && "border-primary bg-primary/5"
)}
onClick={() => onHighlight(kp.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onHighlight(kp.id)
}
}}
>
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
@@ -115,7 +122,7 @@ export function KnowledgePointList({
{kp.description}
</p>
)}
</button>
</div>
))}
</div>
</ScrollArea>

View File

@@ -42,6 +42,7 @@ import {
SheetHeader,
SheetTitle,
} from "@/shared/components/ui/sheet"
import { ResizablePanel } from "@/shared/components/ui/resizable-panel"
import { useTextSelection } from "../hooks/use-text-selection"
import { useKnowledgePointActions } from "../hooks/use-knowledge-point-actions"
import { buildChapterIndex, highlightKnowledgePoints } from "../utils"
@@ -326,26 +327,12 @@ export function TextbookReader({
</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>
// 图谱 tab 时侧栏(图谱)占更大比例,其他 tab 时侧栏(目录/知识点)占较小比例
const sidebarInitialPct = activeTab === "graph" ? 60 : 35
{/* P2-4 移动端侧栏lg 以下用 Sheet 抽屉式展示 */}
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
{/* 任意值 w-[85vw]移动端抽屉占视口宽度max-w-sm 防止超宽屏过大 */}
<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 移动端侧栏触发按钮 + 为此课文备课按钮 */}
const contentPanel = (
<div className="flex flex-col h-full min-h-0 relative">
{/* 移动端侧栏触发按钮 + 为此课文备课按钮 */}
<div className="flex items-center gap-2 mb-3 px-2 shrink-0">
<Button
variant="outline"
@@ -439,6 +426,33 @@ export function TextbookReader({
/>
</TextbookSectionErrorBoundary>
</div>
)
return (
<div className="flex flex-col h-full lg:h-full">
{/* 移动端:单列布局 + Sheet 抽屉侧栏 */}
<div className="lg:hidden flex-1 min-h-0">{contentPanel}</div>
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
{/* 任意值 w-[85vw]移动端抽屉占视口宽度max-w-sm 防止超宽屏过大 */}
<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>
{/* 桌面端:可拖拽分栏,左侧侧栏 + 右侧正文,切换 tab 时重置比例 */}
<div className="hidden lg:flex h-full min-h-0">
<ResizablePanel
key={activeTab}
initialLeft={sidebarInitialPct}
minLeft={20}
minRight={25}
left={<div className="h-full pr-3 border-r">{sidebarContent}</div>}
right={<div className="h-full pl-3">{contentPanel}</div>}
/>
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { useState, useEffect, useCallback } from "react"
import { getKnowledgeGraphDataAction } from "../actions"
import type { GraphViewMode, KnowledgeGraphData } from "../types"
@@ -20,6 +20,12 @@ interface UseGraphDataResult {
* 按 textbookId + viewMode 加载,切换 viewMode 时重新加载。
* 区分 isLoading首次加载和 isRefreshing切换模式刷新
* 切换模式时保留旧数据避免 UI 闪烁。
*
* 注意:不使用 lastRequestKey 防重复机制。该机制在 React StrictMode
* Next.js 默认启用)下会导致 action 结果被丢弃:
* 第一次 mount 发起 action → cleanup 设置 cancelled=true →
* 第二次 mount 因 requestKey 相同跳过 → action 结果被忽略 → 永久 loading。
* useEffect 依赖数组已足够控制 effect 执行频率,无需额外防重复。
*/
export function useGraphData(
textbookId: string,
@@ -28,7 +34,6 @@ export function useGraphData(
const [data, setData] = useState<KnowledgeGraphData | null>(null)
const [error, setError] = useState<string | null>(null)
const [reloadTrigger, setReloadTrigger] = useState(0)
const lastRequestKey = useRef<string>("")
const reload = useCallback(() => {
setReloadTrigger((n) => n + 1)
@@ -42,10 +47,6 @@ export function useGraphData(
useEffect(() => {
if (!textbookId) return
const requestKey = `${textbookId}:${viewMode}:${reloadTrigger}`
if (lastRequestKey.current === requestKey) return
lastRequestKey.current = requestKey
let cancelled = false
getKnowledgeGraphDataAction(textbookId, viewMode)

View File

@@ -46,9 +46,12 @@ export type KnowledgePoint = {
// ===== 知识图谱相关类型 =====
/** 图谱视图模式 */
/** 图谱视图模式(数据视角:结构 / 学生掌握度 / 班级掌握度) */
export type GraphViewMode = "structure" | "student-mastery" | "class-mastery"
/** 图谱布局模式(渲染方式:分层有向图 / Obsidian 风格力导向图) */
export type GraphLayoutMode = "hierarchical" | "force"
/** 掌握度信息 */
export interface MasteryInfo {
/** 掌握度等级 0-100 */