Initial commit: 国土空间规划课程智能体 v1.0

单容器 Docker 架构的国土空间规划课程智能问答系统,集成 FastAPI 后端与 Next.js 前端。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 09:40:18 +08:00
commit ddbb79b9f6
167 changed files with 44147 additions and 0 deletions
@@ -0,0 +1,403 @@
"use client";
import { useMemo, useRef, useCallback, useState, useEffect } from "react";
import ForceGraph2D from "react-force-graph-2d";
import { BookStructure, Chapter, Section, Subsection } from "@/types";
import { Button } from "@/components/ui/button";
import { RotateCcw, ZoomIn, ZoomOut } from "lucide-react";
import NodeDetailDialog from "./node-detail-dialog";
interface GraphNode {
id: string;
name: string;
type: 'root' | 'chapter' | 'section';
nodeId?: number; // 节点在数据库中的ID
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
}
interface GraphLink {
source: string;
target: string;
type: 'root-chapter' | 'chapter-section';
}
interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
interface KnowledgeGraphProps {
bookStructure: BookStructure;
onNodeClick?: (node: GraphNode) => void;
}
// 标签尺寸配置(分级字体大小,不限制宽度)
const LABEL_CONFIG = {
root: {
fontSize: 18,
padding: 8,
},
chapter: {
fontSize: 15,
padding: 6,
},
section: {
fontSize: 13,
padding: 5,
},
};
export default function KnowledgeGraph({ bookStructure, onNodeClick }: KnowledgeGraphProps) {
const graphRef = useRef<any>(null);
const containerRef = useRef<HTMLDivElement>(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) {
const rect = containerRef.current.getBoundingClientRect();
setDimensions({
width: rect.width,
height: Math.max(600, rect.height),
});
}
};
if (typeof window !== 'undefined') {
updateDimensions();
window.addEventListener('resize', updateDimensions);
return () => window.removeEventListener('resize', updateDimensions);
}
}, []);
// 将书籍结构数据转换为图谱数据
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
};
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 angle = chapterIndex * chapterAngleStep;
const initialX = Math.cos(angle) * chapterRadius;
const initialY = Math.sin(angle) * chapterRadius;
// 创建章节节点
const chapterNode: GraphNode = {
id: chapterNodeId,
name: chapter.title,
type: 'chapter',
nodeId: chapter.id,
nodeType: 'chapter',
val: 18, // 章节节点大小
color: chapterColor,
_fullLabel: chapter.title,
x: initialX,
y: initialY,
};
nodes.push(chapterNode);
// 连接根节点到章节节点
links.push({
source: 'root',
target: chapterNodeId,
type: 'root-chapter',
});
// 检查是否有节数据
if (!chapter.sections || chapter.sections.length === 0) {
console.warn(`章节 ${chapter.title} 没有节数据`);
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 sectionNode: GraphNode = {
id: sectionNodeId,
name: section.title,
type: 'section',
nodeId: section.id,
nodeType: 'section',
sectionData: {
subsections: section.subsections || []
},
val: 12, // 节节点大小
color: chapterColor + 'CC', // 添加透明度
_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]);
// 节点点击处理
const handleNodeClick = useCallback((node: GraphNode) => {
// 只有非根节点才能点击查看详情
if (node.type !== 'root' && node.nodeId && node.nodeType) {
setSelectedNode(node);
setIsDialogOpen(true);
}
if (onNodeClick) {
onNodeClick(node);
}
}, [onNodeClick]);
// 重置视图
const handleResetView = useCallback(() => {
if (graphRef.current) {
graphRef.current.zoomToFit(400, 20);
graphRef.current.centerAt(0, 0, 1000);
}
}, []);
// 放大
const handleZoomIn = useCallback(() => {
if (graphRef.current) {
const currentZoom = graphRef.current.zoom() || 1;
graphRef.current.zoom(currentZoom * 1.2, 200);
}
}, []);
// 缩小
const handleZoomOut = useCallback(() => {
if (graphRef.current) {
const currentZoom = graphRef.current.zoom() || 1;
graphRef.current.zoom(currentZoom * 0.8, 200);
}
}, []);
return (
<div ref={containerRef} className="relative w-full h-full">
{/* 控制按钮 */}
<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"
title="放大"
>
<ZoomIn className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={handleZoomOut}
className="bg-background/80 backdrop-blur-sm"
title="缩小"
>
<ZoomOut className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={handleResetView}
className="bg-background/80 backdrop-blur-sm"
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}
/>
)}
</div>
);
}