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
+289
View File
@@ -0,0 +1,289 @@
"use client";
import { useEffect, useState } from "react";
// 禁用静态生成
export const dynamic = 'force-dynamic';
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/store/auth";
import MobileNav from "@/components/layout/mobile-nav";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
Loader2,
BookOpen,
GraduationCap,
Lightbulb,
Target,
Network,
List
} from "lucide-react";
import { courseContentAPI } from "@/lib/api";
import { BookStructure } from "@/types";
import dynamicImport from "next/dynamic";
import NodeDetailDialog from "@/components/course-content/node-detail-dialog";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { FileText, ChevronRight } from "lucide-react";
// 动态导入 KnowledgeGraph 组件,禁用 SSR
const KnowledgeGraph = dynamicImport(
() => import("@/components/course-content/knowledge-graph"),
{ ssr: false }
);
export default function CourseContentPage() {
const router = useRouter();
const { isAuthenticated, isLoading: authLoading } = useAuthStore();
const [bookStructure, setBookStructure] = useState<BookStructure | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'graph' | 'list'>('graph');
const [selectedNode, setSelectedNode] = useState<{
type: 'chapter' | 'section' | 'subsection';
id: number;
title: string;
subsections?: Array<{ id: number; title: string }>;
} | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.push("/login");
return;
}
if (isAuthenticated) {
loadCourseContent();
}
}, [isAuthenticated, authLoading, router]);
const loadCourseContent = async () => {
try {
setIsLoading(true);
setError(null);
const structure = await courseContentAPI.getCourseContent();
console.log("加载的书籍结构:", structure);
console.log("章节数量:", structure?.chapters?.length || 0);
if (structure?.chapters) {
structure.chapters.forEach((chapter, index) => {
console.log(`章节 ${index + 1}:`, chapter.title, "节数:", chapter.sections?.length || 0);
});
}
setBookStructure(structure);
} catch (err) {
console.error("加载课程内容失败:", err);
setError("加载课程内容失败: " + (err instanceof Error ? err.message : String(err)));
} finally {
setIsLoading(false);
}
};
if (authLoading || isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="min-h-screen bg-app pb-16 lg:pb-0">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* 页面头部 */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="w-12 h-12 bg-gradient-to-r from-blue-600 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
<GraduationCap className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
</div>
{/* 视图切换 */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'graph' | 'list')}>
<TabsList>
<TabsTrigger value="graph" className="flex items-center space-x-2">
<Network className="w-4 h-4" />
<span className="hidden sm:inline"></span>
</TabsTrigger>
<TabsTrigger value="list" className="flex items-center space-x-2">
<List className="w-4 h-4" />
<span className="hidden sm:inline"></span>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
{/* 错误提示 */}
{error && (
<Alert variant="destructive" className="mb-6">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* 课程内容展示 */}
{!bookStructure ? (
<Card className="backdrop-blur-sm bg-card/80 border-border/50 shadow-xl">
<CardContent className="text-center py-12">
<BookOpen className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2"></h3>
<p className="text-muted-foreground">
</p>
</CardContent>
</Card>
) : (
<>
{/* 知识图谱视图 */}
{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">
<KnowledgeGraph bookStructure={bookStructure} />
</div>
)}
{/* 列表视图 */}
{viewMode === 'list' && (
<div className="space-y-6">
{bookStructure.chapters.map((chapter) => (
<Card
key={chapter.id}
className="hover:shadow-lg transition-shadow backdrop-blur-sm bg-card/80 border-border/50"
>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-xl mb-3 flex items-center space-x-2">
<BookOpen className="w-5 h-5 text-blue-600" />
<span>{chapter.chapter_number} {chapter.title}</span>
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedNode({
type: 'chapter',
id: chapter.id,
title: chapter.title,
});
setIsDialogOpen(true);
}}
className="flex items-center space-x-2"
>
<FileText className="w-4 h-4" />
<span></span>
</Button>
</div>
</CardHeader>
<CardContent>
<Accordion type="single" collapsible className="w-full">
{chapter.sections.map((section) => (
<AccordionItem key={section.id} value={`section-${section.id}`}>
<div className="flex items-center justify-between w-full">
<AccordionTrigger className="text-base font-medium flex-1">
<span>{section.title}</span>
</AccordionTrigger>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setSelectedNode({
type: 'section',
id: section.id,
title: section.title,
subsections: section.subsections,
});
setIsDialogOpen(true);
}}
className="mr-2 flex items-center space-x-1"
>
<FileText className="w-4 h-4" />
<span className="text-xs"></span>
</Button>
</div>
<AccordionContent>
<div className="pt-2 pl-4 space-y-2">
{section.subsections.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<>
<p className="text-sm font-medium text-foreground mb-2">
({section.subsections.length} )
</p>
<ul className="space-y-2">
{section.subsections.map((subsection) => (
<li
key={subsection.id}
className="flex items-center justify-between group"
>
<div className="flex items-start space-x-2 flex-1">
<span className="w-1.5 h-1.5 rounded-full bg-blue-600 mt-2 flex-shrink-0" />
<span className="flex-1 text-sm text-muted-foreground">{subsection.title}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedNode({
type: 'subsection',
id: subsection.id,
title: subsection.title,
});
setIsDialogOpen(true);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity ml-2 flex items-center space-x-1"
>
<FileText className="w-3 h-3" />
<span className="text-xs"></span>
</Button>
</li>
))}
</ul>
</>
)}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
))}
</div>
)}
</>
)}
</div>
{/* 节点详情对话框 */}
{selectedNode && (
<NodeDetailDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
nodeType={selectedNode.type === 'subsection' ? 'section' : selectedNode.type}
nodeId={selectedNode.type === 'subsection' ? 0 : selectedNode.id} // nodeId is not used for direct subsection display
nodeTitle={selectedNode.title}
subsections={selectedNode.type === 'section' ? selectedNode.subsections : undefined}
subsectionId={selectedNode.type === 'subsection' ? selectedNode.id : undefined}
/>
)}
{/* 移动端导航 */}
<MobileNav />
</div>
);
}