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 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 14:20:28 +08:00
parent 09621ef767
commit 90ab913ef9
3 changed files with 323 additions and 219 deletions
@@ -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<any>(null);
const containerRef = useRef<HTMLDivElement>(null);
const particleCanvasRef = useRef<HTMLCanvasElement>(null);
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(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 (
<div ref={containerRef} className="relative w-full h-full">
{/* 控制按钮 */}
{/* p5-style particle background */}
<canvas
ref={particleCanvasRef}
className="absolute inset-0"
style={{ zIndex: 0 }}
/>
{/* Force graph layer */}
<div className="absolute inset-0" style={{ zIndex: 1 }}>
<ForceGraph2D
ref={graphRef}
graphData={graphData}
backgroundColor="rgba(10, 10, 26, 0.88)"
nodeLabel={(node: any) => 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}
/>
</div>
{/* Controls */}
<div className="absolute top-4 right-4 z-10 flex flex-col space-y-2">
<Button
variant="outline"
size="icon"
onClick={handleZoomIn}
className="bg-background/80 backdrop-blur-sm"
className="bg-black/40 backdrop-blur-sm border-white/10 text-white hover:bg-white/10 hover:text-white"
title="放大"
>
<ZoomIn className="w-4 h-4" />
@@ -261,7 +475,7 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
variant="outline"
size="icon"
onClick={handleZoomOut}
className="bg-background/80 backdrop-blur-sm"
className="bg-black/40 backdrop-blur-sm border-white/10 text-white hover:bg-white/10 hover:text-white"
title="缩小"
>
<ZoomOut className="w-4 h-4" />
@@ -270,134 +484,24 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
variant="outline"
size="icon"
onClick={handleResetView}
className="bg-background/80 backdrop-blur-sm"
className="bg-black/40 backdrop-blur-sm border-white/10 text-white hover:bg-white/10 hover:text-white"
title="重置视图"
>
<RotateCcw className="w-4 h-4" />
</Button>
</div>
{/* 知识图谱 */}
<ForceGraph2D
ref={graphRef}
graphData={graphData}
nodeLabel={(node: any) => 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 && (
<NodeDetailDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
nodeType={selectedNode.nodeType}
nodeId={selectedNode.nodeId}
nodeTitle={selectedNode.name}
subsections={selectedNode.sectionData?.subsections}
/>
)}
{/* Node detail dialog */}
{selectedNode && selectedNode.nodeId && selectedNode.nodeType && (
<NodeDetailDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
nodeType={selectedNode.nodeType}
nodeId={selectedNode.nodeId}
nodeTitle={selectedNode.name}
subsections={selectedNode.sectionData?.subsections}
/>
)}
</div>
);
}