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
+4 -4
View File
@@ -139,12 +139,12 @@ app.include_router(course_content.router)
app.include_router(forum.router) app.include_router(forum.router)
# 静态文件服务 # 静态文件服务
if os.path.exists("uploads"): if os.path.exists(settings.upload_dir):
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads")
# 挂载生成图像目录 # 挂载生成图像目录
if os.path.exists("generated_images"): if os.path.exists(settings.generated_images_dir):
app.mount("/generated_images", StaticFiles(directory="generated_images"), name="generated_images") app.mount("/generated_images", StaticFiles(directory=settings.generated_images_dir), name="generated_images")
# 根路径 # 根路径
@app.get("/") @app.get("/")
+1 -1
View File
@@ -150,7 +150,7 @@ export default function CourseContentPage() {
<> <>
{/* 知识图谱视图 */} {/* 知识图谱视图 */}
{viewMode === 'graph' && ( {viewMode === 'graph' && (
<div className="w-full h-[calc(100vh-250px)] min-h-[600px] lg:min-h-[700px] rounded-lg border border-border bg-background overflow-hidden"> <div className="w-full h-[calc(100vh-250px)] min-h-[600px] lg:min-h-[700px] rounded-lg border border-white/10 overflow-hidden">
<KnowledgeGraph bookStructure={bookStructure} /> <KnowledgeGraph bookStructure={bookStructure} />
</div> </div>
)} )}
@@ -2,7 +2,7 @@
import { useMemo, useRef, useCallback, useState, useEffect } from "react"; import { useMemo, useRef, useCallback, useState, useEffect } from "react";
import ForceGraph2D from "react-force-graph-2d"; 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 { Button } from "@/components/ui/button";
import { RotateCcw, ZoomIn, ZoomOut } from "lucide-react"; import { RotateCcw, ZoomIn, ZoomOut } from "lucide-react";
import NodeDetailDialog from "./node-detail-dialog"; import NodeDetailDialog from "./node-detail-dialog";
@@ -11,16 +11,16 @@ interface GraphNode {
id: string; id: string;
name: string; name: string;
type: 'root' | 'chapter' | 'section'; type: 'root' | 'chapter' | 'section';
nodeId?: number; // 节点在数据库中的ID nodeId?: number;
nodeType?: 'chapter' | 'section'; // 用于详情对话框 nodeType?: 'chapter' | 'section';
sectionData?: { subsections: Array<{ id: number; title: string }> }; // 存储节的小节(知识点)数据 sectionData?: { subsections: Array<{ id: number; title: string }> };
val?: number; val?: number;
color?: string; color?: string;
fx?: number; fx?: number;
fy?: number; fy?: number;
x?: number; // 初始x位置 x?: number;
y?: number; // 初始y位置 y?: number;
_fullLabel?: string; // 完整标签用于tooltip _fullLabel?: string;
} }
interface GraphLink { interface GraphLink {
@@ -39,30 +39,25 @@ interface KnowledgeGraphProps {
onNodeClick?: (node: GraphNode) => void; onNodeClick?: (node: GraphNode) => void;
} }
// 标签尺寸配置(分级字体大小,不限制宽度) const CHAPTER_COLORS = [
const LABEL_CONFIG = { '#a78bfa',
root: { '#22d3ee',
fontSize: 18, '#fbbf24',
padding: 8, '#34d399',
}, '#f472b6',
chapter: { '#60a5fa',
fontSize: 15, ];
padding: 6,
}, const ROOT_COLOR = '#c4b5fd';
section: {
fontSize: 13,
padding: 5,
},
};
export default function KnowledgeGraph({ bookStructure, onNodeClick }: KnowledgeGraphProps) { export default function KnowledgeGraph({ bookStructure, onNodeClick }: KnowledgeGraphProps) {
const graphRef = useRef<any>(null); const graphRef = useRef<any>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const particleCanvasRef = useRef<HTMLCanvasElement>(null);
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null); const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [dimensions, setDimensions] = useState({ width: 1200, height: 800 }); const [dimensions, setDimensions] = useState({ width: 1200, height: 800 });
// 响应式尺寸计算
useEffect(() => { useEffect(() => {
const updateDimensions = () => { const updateDimensions = () => {
if (containerRef.current) { if (containerRef.current) {
@@ -73,7 +68,6 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
}); });
} }
}; };
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
updateDimensions(); updateDimensions();
window.addEventListener('resize', updateDimensions); window.addEventListener('resize', updateDimensions);
@@ -81,136 +75,188 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
} }
}, []); }, []);
// 将书籍结构数据转换为图谱数据
const graphData: GraphData = useMemo(() => { const graphData: GraphData = useMemo(() => {
const nodes: GraphNode[] = []; const nodes: GraphNode[] = [];
const links: GraphLink[] = []; const links: GraphLink[] = [];
// 调试信息
console.log("知识图谱数据:", bookStructure);
console.log("章节数量:", bookStructure?.chapters?.length || 0);
// 创建根节点(书籍,固定在中心)
const rootNode: GraphNode = { const rootNode: GraphNode = {
id: 'root', id: 'root',
name: bookStructure.book.title, name: bookStructure.book.title,
type: 'root', type: 'root',
val: 25, // 增大根节点 val: 25,
color: '#3b82f6', // 蓝色 color: ROOT_COLOR,
fx: 0, // 固定x坐标 fx: 0,
fy: 0, // 固定y坐标 fy: 0,
_fullLabel: bookStructure.book.title, // 存储完整标签用于tooltip _fullLabel: bookStructure.book.title,
}; };
nodes.push(rootNode); nodes.push(rootNode);
// 检查是否有章节数据
if (!bookStructure.chapters || bookStructure.chapters.length === 0) { if (!bookStructure.chapters || bookStructure.chapters.length === 0) {
console.warn("警告: 没有章节数据");
return { nodes, links }; return { nodes, links };
} }
// 章节颜色配置
const chapterColors = [
'#8b5cf6', // 紫色
'#ec4899', // 粉色
'#f59e0b', // 橙色
'#10b981', // 绿色
'#06b6d4', // 青色
'#ef4444', // 红色
];
// 为每个章节创建节点和连接
const chapterCount = bookStructure.chapters.length; const chapterCount = bookStructure.chapters.length;
const chapterAngleStep = (2 * Math.PI) / chapterCount; const chapterAngleStep = (2 * Math.PI) / chapterCount;
bookStructure.chapters.forEach((chapter, chapterIndex) => { bookStructure.chapters.forEach((chapter, chapterIndex) => {
console.log(`处理章节 ${chapterIndex + 1}:`, chapter.title, "节数量:", chapter.sections?.length || 0);
const chapterNodeId = `chapter-${chapter.id}`; const chapterNodeId = `chapter-${chapter.id}`;
const chapterColor = chapterColors[chapterIndex % chapterColors.length]; const chapterColor = CHAPTER_COLORS[chapterIndex % CHAPTER_COLORS.length];
// 计算章节节点的初始位置(圆形分布)
const chapterRadius = 400;
const angle = chapterIndex * chapterAngleStep; const angle = chapterIndex * chapterAngleStep;
const initialX = Math.cos(angle) * chapterRadius; const chapterRadius = 400;
const initialY = Math.sin(angle) * chapterRadius;
// 创建章节节点
const chapterNode: GraphNode = { const chapterNode: GraphNode = {
id: chapterNodeId, id: chapterNodeId,
name: chapter.title, name: chapter.title,
type: 'chapter', type: 'chapter',
nodeId: chapter.id, nodeId: chapter.id,
nodeType: 'chapter', nodeType: 'chapter',
val: 18, // 章节节点大小 val: 18,
color: chapterColor, color: chapterColor,
_fullLabel: chapter.title, _fullLabel: chapter.title,
x: initialX, x: Math.cos(angle) * chapterRadius,
y: initialY, y: Math.sin(angle) * chapterRadius,
}; };
nodes.push(chapterNode); nodes.push(chapterNode);
// 连接根节点到章节节点
links.push({ links.push({
source: 'root', source: 'root',
target: chapterNodeId, target: chapterNodeId,
type: 'root-chapter', type: 'root-chapter',
}); });
// 检查是否有节数据 if (!chapter.sections || chapter.sections.length === 0) return;
if (!chapter.sections || chapter.sections.length === 0) {
console.warn(`章节 ${chapter.title} 没有节数据`);
return;
}
// 为每个节创建节点
const sectionCount = chapter.sections.length; const sectionCount = chapter.sections.length;
const sectionAngleStep = sectionCount > 1 ? (2 * Math.PI) / sectionCount : 0; const sectionAngleStep = sectionCount > 1 ? (2 * Math.PI) / sectionCount : 0;
chapter.sections.forEach((section, sectionIndex) => { chapter.sections.forEach((section, sectionIndex) => {
const sectionNodeId = `section-${section.id}`; const sectionNodeId = `section-${section.id}`;
// 计算节节点的初始位置(围绕章节节点)
const sectionRadius = 250; const sectionRadius = 250;
const sectionAngle = sectionAngleStep * sectionIndex; const sectionAngle = sectionAngleStep * sectionIndex;
const sectionX = initialX + Math.cos(angle + sectionAngle) * sectionRadius; const sectionX = Math.cos(angle) * chapterRadius + Math.cos(angle + sectionAngle) * sectionRadius;
const sectionY = initialY + Math.sin(angle + sectionAngle) * sectionRadius; const sectionY = Math.sin(angle) * chapterRadius + Math.sin(angle + sectionAngle) * sectionRadius;
// 创建节节点(包含小节数据,用于点击后展开)
const sectionNode: GraphNode = { const sectionNode: GraphNode = {
id: sectionNodeId, id: sectionNodeId,
name: section.title, name: section.title,
type: 'section', type: 'section',
nodeId: section.id, nodeId: section.id,
nodeType: 'section', nodeType: 'section',
sectionData: { sectionData: { subsections: section.subsections || [] },
subsections: section.subsections || [] val: 12,
}, color: chapterColor,
val: 12, // 节节点大小
color: chapterColor + 'CC', // 添加透明度
_fullLabel: section.title, _fullLabel: section.title,
x: sectionX, x: sectionX,
y: sectionY, y: sectionY,
}; };
nodes.push(sectionNode); nodes.push(sectionNode);
// 连接章节节点到节节点
links.push({ links.push({
source: chapterNodeId, source: chapterNodeId,
target: sectionNodeId, target: sectionNodeId,
type: 'chapter-section', type: 'chapter-section',
}); });
// 注意:小节(知识点)不在图谱中直接展示,点击节节点后在对话框中展开
}); });
}); });
console.log("图谱节点数量:", nodes.length, "链接数量:", links.length);
return { nodes, links }; return { nodes, links };
}, [bookStructure]); }, [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) => { const handleNodeClick = useCallback((node: GraphNode) => {
// 只有非根节点才能点击查看详情
if (node.type !== 'root' && node.nodeId && node.nodeType) { if (node.type !== 'root' && node.nodeId && node.nodeType) {
setSelectedNode(node); setSelectedNode(node);
setIsDialogOpen(true); setIsDialogOpen(true);
@@ -220,7 +266,6 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
} }
}, [onNodeClick]); }, [onNodeClick]);
// 重置视图
const handleResetView = useCallback(() => { const handleResetView = useCallback(() => {
if (graphRef.current) { if (graphRef.current) {
graphRef.current.zoomToFit(400, 20); graphRef.current.zoomToFit(400, 20);
@@ -228,7 +273,6 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
} }
}, []); }, []);
// 放大
const handleZoomIn = useCallback(() => { const handleZoomIn = useCallback(() => {
if (graphRef.current) { if (graphRef.current) {
const currentZoom = graphRef.current.zoom() || 1; const currentZoom = graphRef.current.zoom() || 1;
@@ -236,7 +280,6 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
} }
}, []); }, []);
// 缩小
const handleZoomOut = useCallback(() => { const handleZoomOut = useCallback(() => {
if (graphRef.current) { if (graphRef.current) {
const currentZoom = graphRef.current.zoom() || 1; const currentZoom = graphRef.current.zoom() || 1;
@@ -246,13 +289,184 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
return ( return (
<div ref={containerRef} className="relative w-full h-full"> <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"> <div className="absolute top-4 right-4 z-10 flex flex-col space-y-2">
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={handleZoomIn} 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="放大" title="放大"
> >
<ZoomIn className="w-4 h-4" /> <ZoomIn className="w-4 h-4" />
@@ -261,7 +475,7 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
variant="outline" variant="outline"
size="icon" size="icon"
onClick={handleZoomOut} 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="缩小" title="缩小"
> >
<ZoomOut className="w-4 h-4" /> <ZoomOut className="w-4 h-4" />
@@ -270,134 +484,24 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
variant="outline" variant="outline"
size="icon" size="icon"
onClick={handleResetView} 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="重置视图" title="重置视图"
> >
<RotateCcw className="w-4 h-4" /> <RotateCcw className="w-4 h-4" />
</Button> </Button>
</div> </div>
{/* 知识图谱 */} {/* Node detail dialog */}
<ForceGraph2D {selectedNode && selectedNode.nodeId && selectedNode.nodeType && (
ref={graphRef} <NodeDetailDialog
graphData={graphData} open={isDialogOpen}
nodeLabel={(node: any) => node._fullLabel || node.name} onOpenChange={setIsDialogOpen}
nodeColor={(node: any) => node.color || '#3b82f6'} nodeType={selectedNode.nodeType}
nodeVal={(node: any) => node.val || 8} nodeId={selectedNode.nodeId}
nodeRelSize={6} nodeTitle={selectedNode.name}
// 碰撞检测和力衰减优化 subsections={selectedNode.sectionData?.subsections}
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}
/>
)}
</div> </div>
); );
} }