Files
course-agent-od/web/src/app/(main)/course-content/page.tsx
T
pengxiao b8561e04c6 fix: source list scroll, citation targeting, and page UI polish
- 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>
2026-05-27 22:05:08 +08:00

271 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}