b8561e04c6
- Replace ScrollArea with native overflow-y-auto for reliable mouse wheel scrolling - Scope source DOM IDs by message ID to fix cross-round citation jumps - Improve profile, settings, forum, knowledge page layouts - Update mobile navigation and chat interface Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
"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();
|
||
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="flex justify-end mb-4">
|
||
<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>
|
||
|
||
{/* 错误提示 */}
|
||
{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 overflow-hidden bg-muted">
|
||
<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>
|
||
);
|
||
}
|
||
|