From 90ab913ef99e15bd6d8f0532cbb0f6e2771e9e00 Mon Sep 17 00:00:00 2001 From: xiaopeng <1509442308@qq.com> Date: Fri, 22 May 2026 14:20:28 +0800 Subject: [PATCH] fix: use config paths for static file mounts, redesign knowledge graph - Fix generated images 404: use settings.generated_images_dir and settings.upload_dir instead of hardcoded relative paths in main.py - Redesign knowledge graph with p5-style particle background, glow nodes, and curved gradient links - Update course-content page border styling Co-Authored-By: Claude Opus 4.7 --- backend/main.py | 8 +- web/src/app/(main)/course-content/page.tsx | 2 +- .../course-content/knowledge-graph.tsx | 532 +++++++++++------- 3 files changed, 323 insertions(+), 219 deletions(-) diff --git a/backend/main.py b/backend/main.py index 8f1a70d..eee242e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -139,12 +139,12 @@ app.include_router(course_content.router) app.include_router(forum.router) # 静态文件服务 -if os.path.exists("uploads"): - app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") +if os.path.exists(settings.upload_dir): + app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads") # 挂载生成图像目录 -if os.path.exists("generated_images"): - app.mount("/generated_images", StaticFiles(directory="generated_images"), name="generated_images") +if os.path.exists(settings.generated_images_dir): + app.mount("/generated_images", StaticFiles(directory=settings.generated_images_dir), name="generated_images") # 根路径 @app.get("/") diff --git a/web/src/app/(main)/course-content/page.tsx b/web/src/app/(main)/course-content/page.tsx index 38b4759..0b92cfc 100644 --- a/web/src/app/(main)/course-content/page.tsx +++ b/web/src/app/(main)/course-content/page.tsx @@ -150,7 +150,7 @@ export default function CourseContentPage() { <> {/* 知识图谱视图 */} {viewMode === 'graph' && ( -
+
)} diff --git a/web/src/components/course-content/knowledge-graph.tsx b/web/src/components/course-content/knowledge-graph.tsx index acbf7b6..2db3428 100644 --- a/web/src/components/course-content/knowledge-graph.tsx +++ b/web/src/components/course-content/knowledge-graph.tsx @@ -2,7 +2,7 @@ import { useMemo, useRef, useCallback, useState, useEffect } from "react"; import ForceGraph2D from "react-force-graph-2d"; -import { BookStructure, Chapter, Section, Subsection } from "@/types"; +import { BookStructure } from "@/types"; import { Button } from "@/components/ui/button"; import { RotateCcw, ZoomIn, ZoomOut } from "lucide-react"; import NodeDetailDialog from "./node-detail-dialog"; @@ -11,16 +11,16 @@ interface GraphNode { id: string; name: string; type: 'root' | 'chapter' | 'section'; - nodeId?: number; // 节点在数据库中的ID - nodeType?: 'chapter' | 'section'; // 用于详情对话框 - sectionData?: { subsections: Array<{ id: number; title: string }> }; // 存储节的小节(知识点)数据 + nodeId?: number; + nodeType?: 'chapter' | 'section'; + sectionData?: { subsections: Array<{ id: number; title: string }> }; val?: number; color?: string; fx?: number; fy?: number; - x?: number; // 初始x位置 - y?: number; // 初始y位置 - _fullLabel?: string; // 完整标签用于tooltip + x?: number; + y?: number; + _fullLabel?: string; } interface GraphLink { @@ -39,30 +39,25 @@ interface KnowledgeGraphProps { onNodeClick?: (node: GraphNode) => void; } -// 标签尺寸配置(分级字体大小,不限制宽度) -const LABEL_CONFIG = { - root: { - fontSize: 18, - padding: 8, - }, - chapter: { - fontSize: 15, - padding: 6, - }, - section: { - fontSize: 13, - padding: 5, - }, -}; +const CHAPTER_COLORS = [ + '#a78bfa', + '#22d3ee', + '#fbbf24', + '#34d399', + '#f472b6', + '#60a5fa', +]; + +const ROOT_COLOR = '#c4b5fd'; export default function KnowledgeGraph({ bookStructure, onNodeClick }: KnowledgeGraphProps) { const graphRef = useRef(null); const containerRef = useRef(null); + const particleCanvasRef = useRef(null); const [selectedNode, setSelectedNode] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); const [dimensions, setDimensions] = useState({ width: 1200, height: 800 }); - // 响应式尺寸计算 useEffect(() => { const updateDimensions = () => { if (containerRef.current) { @@ -73,7 +68,6 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge }); } }; - if (typeof window !== 'undefined') { updateDimensions(); window.addEventListener('resize', updateDimensions); @@ -81,136 +75,188 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge } }, []); - // 将书籍结构数据转换为图谱数据 const graphData: GraphData = useMemo(() => { const nodes: GraphNode[] = []; const links: GraphLink[] = []; - // 调试信息 - console.log("知识图谱数据:", bookStructure); - console.log("章节数量:", bookStructure?.chapters?.length || 0); - - // 创建根节点(书籍,固定在中心) const rootNode: GraphNode = { id: 'root', name: bookStructure.book.title, type: 'root', - val: 25, // 增大根节点 - color: '#3b82f6', // 蓝色 - fx: 0, // 固定x坐标 - fy: 0, // 固定y坐标 - _fullLabel: bookStructure.book.title, // 存储完整标签用于tooltip + val: 25, + color: ROOT_COLOR, + fx: 0, + fy: 0, + _fullLabel: bookStructure.book.title, }; nodes.push(rootNode); - // 检查是否有章节数据 if (!bookStructure.chapters || bookStructure.chapters.length === 0) { - console.warn("警告: 没有章节数据"); return { nodes, links }; } - // 章节颜色配置 - const chapterColors = [ - '#8b5cf6', // 紫色 - '#ec4899', // 粉色 - '#f59e0b', // 橙色 - '#10b981', // 绿色 - '#06b6d4', // 青色 - '#ef4444', // 红色 - ]; - - // 为每个章节创建节点和连接 const chapterCount = bookStructure.chapters.length; const chapterAngleStep = (2 * Math.PI) / chapterCount; - + bookStructure.chapters.forEach((chapter, chapterIndex) => { - console.log(`处理章节 ${chapterIndex + 1}:`, chapter.title, "节数量:", chapter.sections?.length || 0); const chapterNodeId = `chapter-${chapter.id}`; - const chapterColor = chapterColors[chapterIndex % chapterColors.length]; - - // 计算章节节点的初始位置(圆形分布) - const chapterRadius = 400; + const chapterColor = CHAPTER_COLORS[chapterIndex % CHAPTER_COLORS.length]; const angle = chapterIndex * chapterAngleStep; - const initialX = Math.cos(angle) * chapterRadius; - const initialY = Math.sin(angle) * chapterRadius; - - // 创建章节节点 + const chapterRadius = 400; + const chapterNode: GraphNode = { id: chapterNodeId, name: chapter.title, type: 'chapter', nodeId: chapter.id, nodeType: 'chapter', - val: 18, // 章节节点大小 + val: 18, color: chapterColor, _fullLabel: chapter.title, - x: initialX, - y: initialY, + x: Math.cos(angle) * chapterRadius, + y: Math.sin(angle) * chapterRadius, }; nodes.push(chapterNode); - // 连接根节点到章节节点 links.push({ source: 'root', target: chapterNodeId, type: 'root-chapter', }); - // 检查是否有节数据 - if (!chapter.sections || chapter.sections.length === 0) { - console.warn(`章节 ${chapter.title} 没有节数据`); - return; - } + if (!chapter.sections || chapter.sections.length === 0) return; - // 为每个节创建节点 const sectionCount = chapter.sections.length; const sectionAngleStep = sectionCount > 1 ? (2 * Math.PI) / sectionCount : 0; - + chapter.sections.forEach((section, sectionIndex) => { const sectionNodeId = `section-${section.id}`; - - // 计算节节点的初始位置(围绕章节节点) const sectionRadius = 250; const sectionAngle = sectionAngleStep * sectionIndex; - const sectionX = initialX + Math.cos(angle + sectionAngle) * sectionRadius; - const sectionY = initialY + Math.sin(angle + sectionAngle) * sectionRadius; - - // 创建节节点(包含小节数据,用于点击后展开) + const sectionX = Math.cos(angle) * chapterRadius + Math.cos(angle + sectionAngle) * sectionRadius; + const sectionY = Math.sin(angle) * chapterRadius + Math.sin(angle + sectionAngle) * sectionRadius; + const sectionNode: GraphNode = { id: sectionNodeId, name: section.title, type: 'section', nodeId: section.id, nodeType: 'section', - sectionData: { - subsections: section.subsections || [] - }, - val: 12, // 节节点大小 - color: chapterColor + 'CC', // 添加透明度 + sectionData: { subsections: section.subsections || [] }, + val: 12, + color: chapterColor, _fullLabel: section.title, x: sectionX, y: sectionY, }; nodes.push(sectionNode); - // 连接章节节点到节节点 links.push({ source: chapterNodeId, target: sectionNodeId, type: 'chapter-section', }); - - // 注意:小节(知识点)不在图谱中直接展示,点击节节点后在对话框中展开 }); }); - console.log("图谱节点数量:", nodes.length, "链接数量:", links.length); return { nodes, links }; }, [bookStructure]); - // 节点点击处理 + // p5-style ambient particle background + useEffect(() => { + const canvas = particleCanvasRef.current; + if (!canvas) return; + + const dpr = window.devicePixelRatio || 1; + const W = dimensions.width; + const H = dimensions.height; + + canvas.width = W * dpr; + canvas.height = H * dpr; + canvas.style.width = `${W}px`; + canvas.style.height = `${H}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.scale(dpr, dpr); + + const particleCount = 40; + const connectDist = 130; + const particles = Array.from({ length: particleCount }, () => ({ + x: Math.random() * W, + y: Math.random() * H, + vx: (Math.random() - 0.5) * 0.2, + vy: (Math.random() - 0.5) * 0.2, + size: Math.random() * 1.5 + 0.5, + hue: 210 + Math.random() * 60, + alpha: Math.random() * 0.35 + 0.1, + phase: Math.random() * Math.PI * 2, + })); + + let animId: number; + let time = 0; + + const animate = () => { + time += 0.01; + ctx.fillStyle = '#0a0a1a'; + ctx.fillRect(0, 0, W, H); + + // update positions + for (const p of particles) { + p.x += p.vx + Math.sin(time + p.phase) * 0.05; + p.y += p.vy + Math.cos(time + p.phase) * 0.05; + if (p.x < 0 || p.x > W) p.vx *= -1; + if (p.y < 0 || p.y > H) p.vy *= -1; + p.x = Math.max(0, Math.min(W, p.x)); + p.y = Math.max(0, Math.min(H, p.y)); + } + + // draw connections + for (let i = 0; i < particles.length; i++) { + for (let j = i + 1; j < particles.length; j++) { + const dx = particles[i].x - particles[j].x; + const dy = particles[i].y - particles[j].y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < connectDist) { + const a = (1 - dist / connectDist) * 0.06; + ctx.beginPath(); + ctx.moveTo(particles[i].x, particles[i].y); + ctx.lineTo(particles[j].x, particles[j].y); + ctx.strokeStyle = `rgba(120, 140, 220, ${a})`; + ctx.lineWidth = 0.5; + ctx.stroke(); + } + } + } + + // draw particles + for (const p of particles) { + const pulse = Math.sin(time * 2 + p.phase) * 0.3 + 0.7; + + // glow + const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 6); + grd.addColorStop(0, `hsla(${p.hue}, 60%, 60%, ${p.alpha * 0.3 * pulse})`); + grd.addColorStop(1, `hsla(${p.hue}, 60%, 60%, 0)`); + ctx.fillStyle = grd; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size * 6, 0, Math.PI * 2); + ctx.fill(); + + // core + ctx.fillStyle = `hsla(${p.hue}, 60%, 70%, ${p.alpha * pulse})`; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + } + + animId = requestAnimationFrame(animate); + }; + + animate(); + return () => cancelAnimationFrame(animId); + }, [dimensions]); + const handleNodeClick = useCallback((node: GraphNode) => { - // 只有非根节点才能点击查看详情 if (node.type !== 'root' && node.nodeId && node.nodeType) { setSelectedNode(node); setIsDialogOpen(true); @@ -220,7 +266,6 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge } }, [onNodeClick]); - // 重置视图 const handleResetView = useCallback(() => { if (graphRef.current) { graphRef.current.zoomToFit(400, 20); @@ -228,7 +273,6 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge } }, []); - // 放大 const handleZoomIn = useCallback(() => { if (graphRef.current) { const currentZoom = graphRef.current.zoom() || 1; @@ -236,7 +280,6 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge } }, []); - // 缩小 const handleZoomOut = useCallback(() => { if (graphRef.current) { const currentZoom = graphRef.current.zoom() || 1; @@ -246,13 +289,184 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge return (
- {/* 控制按钮 */} + {/* p5-style particle background */} + + + {/* Force graph layer */} +
+ node._fullLabel || node.name} + nodeVal={(node: any) => node.val || 8} + nodeRelSize={6} + d3AlphaDecay={0.02} + d3AlphaMin={0.005} + cooldownTicks={200} + onNodeClick={(node: any) => handleNodeClick(node)} + onNodeDragEnd={(node: any) => { + if (node.id !== 'root') { + node.fx = node.x; + node.fy = node.y; + } + }} + onNodeHover={(node: any) => { + if (typeof document !== 'undefined') { + document.body.style.cursor = node ? 'pointer' : 'default'; + } + }} + onEngineStop={() => { + if (graphRef.current) { + graphRef.current.centerAt(0, 0, 1000); + graphRef.current.zoomToFit(400, 30); + } + }} + nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { + if (!isFinite(node.x) || !isFinite(node.y)) return; + const label = node.name; + const color = node.color || '#a78bfa'; + const nodeRadius = Math.max(3, (node.val || 8) * 0.6); + + // 1. outer glow halo + const glowR = nodeRadius * 8; + const grd = ctx.createRadialGradient( + node.x, node.y, 0, + node.x, node.y, glowR + ); + grd.addColorStop(0, color + '25'); + grd.addColorStop(0.4, color + '0c'); + grd.addColorStop(1, color + '00'); + ctx.fillStyle = grd; + ctx.beginPath(); + ctx.arc(node.x, node.y, glowR, 0, Math.PI * 2); + ctx.fill(); + + // 2. inner glow + const innerR = nodeRadius * 3; + const grd2 = ctx.createRadialGradient( + node.x, node.y, 0, + node.x, node.y, innerR + ); + grd2.addColorStop(0, color + '70'); + grd2.addColorStop(1, color + '00'); + ctx.fillStyle = grd2; + ctx.beginPath(); + ctx.arc(node.x, node.y, innerR, 0, Math.PI * 2); + ctx.fill(); + + // 3. core + ctx.beginPath(); + ctx.arc(node.x, node.y, nodeRadius, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + + // 4. bright center spot + ctx.beginPath(); + ctx.arc(node.x, node.y, nodeRadius * 0.3, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255,255,255,0.75)'; + ctx.fill(); + + // 5. label + const fontSize = Math.max(9, 13 / globalScale); + ctx.font = `600 ${fontSize}px "Noto Serif SC", Georgia, serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + const tw = ctx.measureText(label).width; + const pad = 4 / globalScale; + const labelY = node.y + nodeRadius + fontSize / 2 + pad * 3; + + // label background pill + const bw = tw + pad * 3; + const bh = fontSize + pad * 2; + const r = Math.max(2, 3 / globalScale); + const lx = node.x - bw / 2; + const ly = labelY - bh / 2; + + ctx.fillStyle = 'rgba(10, 10, 26, 0.85)'; + ctx.strokeStyle = color + '40'; + ctx.lineWidth = Math.max(0.5, 0.8 / globalScale); + + ctx.beginPath(); + ctx.moveTo(lx + r, ly); + ctx.lineTo(lx + bw - r, ly); + ctx.quadraticCurveTo(lx + bw, ly, lx + bw, ly + r); + ctx.lineTo(lx + bw, ly + bh - r); + ctx.quadraticCurveTo(lx + bw, ly + bh, lx + bw - r, ly + bh); + ctx.lineTo(lx + r, ly + bh); + ctx.quadraticCurveTo(lx, ly + bh, lx, ly + bh - r); + ctx.lineTo(lx, ly + r); + ctx.quadraticCurveTo(lx, ly, lx + r, ly); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + // label text + ctx.fillStyle = '#e2e8f0'; + ctx.fillText(label, node.x, labelY); + }} + linkCanvasObjectMode={() => 'replace'} + linkCanvasObject={(link: any, ctx: CanvasRenderingContext2D, globalScale: number) => { + const src = link.source; + const tgt = link.target; + if (!isFinite(src.x) || !isFinite(src.y) || !isFinite(tgt.x) || !isFinite(tgt.y)) return; + + const srcColor = src.color || '#a78bfa'; + const tgtColor = tgt.color || '#a78bfa'; + + const dx = tgt.x - src.x; + const dy = tgt.y - src.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + + const curvature = 0.12; + const cpX = (src.x + tgt.x) / 2 + (-dy / len) * len * curvature; + const cpY = (src.y + tgt.y) / 2 + (dx / len) * len * curvature; + + const gradient = ctx.createLinearGradient(src.x, src.y, tgt.x, tgt.y); + gradient.addColorStop(0, srcColor + '45'); + gradient.addColorStop(1, tgtColor + '45'); + + ctx.beginPath(); + ctx.moveTo(src.x, src.y); + ctx.quadraticCurveTo(cpX, cpY, tgt.x, tgt.y); + ctx.strokeStyle = gradient; + ctx.lineWidth = Math.max( + 0.5, + (link.type === 'root-chapter' ? 1.8 : 0.8) / Math.max(0.3, globalScale) + ); + ctx.stroke(); + + // subtle glow line + ctx.beginPath(); + ctx.moveTo(src.x, src.y); + ctx.quadraticCurveTo(cpX, cpY, tgt.x, tgt.y); + const glowGradient = ctx.createLinearGradient(src.x, src.y, tgt.x, tgt.y); + glowGradient.addColorStop(0, srcColor + '12'); + glowGradient.addColorStop(1, tgtColor + '12'); + ctx.strokeStyle = glowGradient; + ctx.lineWidth = Math.max( + 1.5, + (link.type === 'root-chapter' ? 5 : 3) / Math.max(0.3, globalScale) + ); + ctx.stroke(); + }} + width={dimensions.width} + height={dimensions.height} + /> +
+ + {/* Controls */}
- {/* 知识图谱 */} - node._fullLabel || node.name} - nodeColor={(node: any) => node.color || '#3b82f6'} - nodeVal={(node: any) => node.val || 8} - nodeRelSize={6} - // 碰撞检测和力衰减优化 - d3AlphaDecay={0.02} - d3AlphaMin={0.005} - cooldownTicks={200} - linkColor={(link: any) => { - if (link.type === 'root-chapter') { - return '#64748b'; // 灰色 - } - return link.source.color || '#94a3b8'; // 使用章节颜色 - }} - linkWidth={(link: any) => { - if (link.type === 'root-chapter') { - return 3; - } - return 2; - }} - linkDirectionalArrowLength={6} - linkDirectionalArrowRelPos={1} - linkCurvature={0.15} - onNodeClick={(node: any) => handleNodeClick(node)} - onNodeDragEnd={(node: any) => { - // 保持根节点固定 - if (node.id !== 'root') { - node.fx = node.x; - node.fy = node.y; - } - }} - onNodeHover={(node: any) => { - if (typeof document !== 'undefined') { - if (node) { - document.body.style.cursor = 'pointer'; - } else { - document.body.style.cursor = 'default'; - } - } - }} - onEngineStop={() => { - if (graphRef.current) { - // 确保根节点在中心 - graphRef.current.centerAt(0, 0, 1000); - graphRef.current.zoomToFit(400, 30); - } - }} - nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { - const label = node.name; - - // 获取分级标签配置(只使用字体大小和padding,不限制宽度) - const config = LABEL_CONFIG[node.type as 'root' | 'chapter' | 'section'] || LABEL_CONFIG.section; - const fontSize = Math.max(9, config.fontSize / globalScale); - const padding = config.padding / globalScale; - - // 设置字体 - ctx.font = `bold ${fontSize}px "Microsoft YaHei", "SimHei", "Arial", sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - - // 测量完整文本尺寸(不截断) - const textWidth = ctx.measureText(label).width; - const textHeight = fontSize; - - // 计算标签位置(在节点下方,根据节点大小调整间距) - const nodeRadius = node.val || 8; - const labelY = node.y + nodeRadius + textHeight / 2 + padding * 2; - const labelX = node.x; - - // 计算背景尺寸(完全根据实际文本宽度,不限制) - const bgWidth = textWidth + padding * 2; - const bgHeight = textHeight + padding * 2; - const radius = Math.max(2, 4 / globalScale); - - // 背景色(根据节点类型调整透明度) - const bgAlpha = node.type === 'knowledge' ? 0.92 : 0.95; - ctx.fillStyle = `rgba(255, 255, 255, ${bgAlpha})`; - ctx.strokeStyle = node.color || '#3b82f6'; - ctx.lineWidth = Math.max(1, 1.5 / globalScale); - - // 绘制圆角矩形背景 - const x = labelX - bgWidth / 2; - const y = labelY - textHeight / 2 - padding; - ctx.beginPath(); - ctx.moveTo(x + radius, y); - ctx.lineTo(x + bgWidth - radius, y); - ctx.quadraticCurveTo(x + bgWidth, y, x + bgWidth, y + radius); - ctx.lineTo(x + bgWidth, y + bgHeight - radius); - ctx.quadraticCurveTo(x + bgWidth, y + bgHeight, x + bgWidth - radius, y + bgHeight); - ctx.lineTo(x + radius, y + bgHeight); - ctx.quadraticCurveTo(x, y + bgHeight, x, y + bgHeight - radius); - ctx.lineTo(x, y + radius); - ctx.quadraticCurveTo(x, y, x + radius, y); - ctx.closePath(); - ctx.fill(); - ctx.stroke(); - - // 绘制完整文本(使用节点颜色,不截断) - ctx.fillStyle = node.color || '#3b82f6'; - ctx.fillText(label, labelX, labelY); - }} - width={dimensions.width} - height={dimensions.height} - /> - - {/* 节点详情对话框 */} - {selectedNode && selectedNode.nodeId && selectedNode.nodeType && ( - - )} + {/* Node detail dialog */} + {selectedNode && selectedNode.nodeId && selectedNode.nodeType && ( + + )}
); } -