import "./CourseLearn.css"; import React, { useState, useEffect } from "react"; import { Tag, Button, Progress, Spin, Tabs } from "antd"; import { message } from "@/utils/message"; import { useTranslation } from "react-i18next"; import { useLearnTimer } from "@/hooks/useLearnTimer"; import { PlayCircleOutlined, CheckCircleOutlined, ClockCircleOutlined, BookOutlined, ArrowLeftOutlined, MenuOutlined, VideoCameraOutlined, FileTextOutlined, DownloadOutlined, FilePdfOutlined, FileZipOutlined, FileOutlined, } from "@ant-design/icons"; import { useNavigate, useSearchParams } from "react-router-dom"; import { api_request } from "@/utils/request"; import { useRecordHistory } from "@/hooks/useRecordHistory"; import { BDCloudVideoView } from "@/components/BDCloudVideoView"; import CodeRunner, { extractFirstCodeBlock } from "@/components/CodeRunner/CodeRunner"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import rehypeHighlight from "rehype-highlight"; import rehypeKatex from "rehype-katex"; import "katex/dist/katex.min.css"; interface CourseDetailData { id: number; title: string; description: string; category: string; level: string; color: string; chapters: ChapterData[]; chapters_count: number; students_count: number; } interface ChapterData { id: number; title: string; sort_order: number; duration: string; is_free: boolean; video_url: string; video_source: string; video_local_url: string; video_poster_url: string; } const CourseLearn: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const courseId = searchParams.get("id"); const getCategoryName = (key: string): string => { const map: Record = { 'frontend': t('learn.categoryFrontendShort'), 'backend': t('learn.categoryBackendShort'), 'tools': t('learn.categoryToolsShort'), 'devops': t('learn.categoryDevopsShort'), 'security': t('learn.categorySecurityShort'), 'basic': t('learn.categoryBasicShort'), }; return map[key] || key; }; const getLevelName = (key: string): string => { const map: Record = { 'beginner': t('learn.courseLevelBeginner'), 'advanced': t('learn.courseLevelAdvanced'), 'intermediate': t('learn.courseLevelAdvanced'), }; return map[key] || key; }; const [courseData, setCourseData] = useState(null); const [chapters, setChapters] = useState([]); const [currentChapterIndex, setCurrentChapterIndex] = useState(0); const [chapterContent, setChapterContent] = useState(""); const [chapterSnippet, setChapterSnippet] = useState<{ lang: "javascript" | "python"; code: string } | null>(null); const [completedChapters, setCompletedChapters] = useState([]); const [sidebarOpen, setSidebarOpen] = useState(false); const [loading, setLoading] = useState(true); const [materials, setMaterials] = useState([]); const [videoSource, setVideoSource] = useState<'bilibili' | 'local'>('local'); useLearnTimer(courseData?.id); useRecordHistory( courseData ? { type: "learn", title: courseData.title, description: courseData.description, image: (courseData as any).cover_image_url || "", category: courseData.category, link: `/course-learn?id=${courseData.id}`, } : null ); useEffect(() => { if (!courseId) { message.error(t('learn.missingCourseId')); return; } const fetchCourseDetail = async () => { setLoading(true); try { const res = await api_request.learn.get_course_detail(Number(courseId)); const data = (res as any)?.data as CourseDetailData; if (data) { setCourseData(data); setChapters(data.chapters || []); if (data.chapters?.length > 0) { loadChapterContent(data.id, data.chapters[0].id); } loadCourseProgress(data.id); } } catch { message.error(t('learn.fetchDetailError')); } finally { setLoading(false); } }; const loadCourseProgress = async (cid: number) => { try { const res = await api_request.learn.getCourseProgress(cid); const data = (res as any)?.data; if (data && Array.isArray(data.completed_chapter_ids)) { setCompletedChapters(data.completed_chapter_ids); } } catch { // 进度加载失败不影响页面展示 } }; fetchCourseDetail(); }, [courseId]); useEffect(() => { if (chapters.length > 0 && chapters[currentChapterIndex]) { const chapter = chapters[currentChapterIndex]; setChapterContent(""); loadChapterContent(courseData!.id, chapter.id); } }, [currentChapterIndex]); const loadChapterContent = async (cid: number, chapterId: number) => { try { const cdnUrl = `/media/learn/courses/${cid}/chapters/${chapterId}.md`; const response = await fetch(cdnUrl); if (response.ok) { const md = await response.text(); setChapterContent(md); setChapterSnippet(extractFirstCodeBlock(md)); return; } } catch {} try { const res = await api_request.learn.get_chapter_content(chapterId); const data = (res as any)?.data; const md = data?.content_md || ""; setChapterContent(md); setChapterSnippet(extractFirstCodeBlock(md)); } catch { setChapterContent(""); setChapterSnippet(null); } }; const loadMaterials = async (chapterId: number) => { try { const res = await api_request.learn.get_materials(chapterId); const data = (res as any)?.data; setMaterials(Array.isArray(data) ? data : []); } catch { setMaterials([]); } }; useEffect(() => { if (chapters.length > 0 && currentChapterIndex >= 0) { const currentChapter = chapters[currentChapterIndex]; if (currentChapter?.id) { loadMaterials(currentChapter.id); } } }, [currentChapterIndex, chapters]); const progress = chapters.length ? Math.round((completedChapters.length / chapters.length) * 100) : 0; const handleChapterClick = (index: number) => { setCurrentChapterIndex(index); setSidebarOpen(false); }; const handlePrevChapter = () => { if (currentChapterIndex > 0) { setCurrentChapterIndex(currentChapterIndex - 1); } }; const handleNextChapter = () => { if (currentChapterIndex < chapters.length - 1) { setCurrentChapterIndex(currentChapterIndex + 1); } }; const handleMarkCompleted = async () => { const currentChapter = chapters[currentChapterIndex]; if (currentChapter && !completedChapters.includes(currentChapter.id)) { try { await api_request.learn.markChapterCompleted(currentChapter.id, true); setCompletedChapters([...completedChapters, currentChapter.id]); message.success(t('learn.chapterMarkedCompleted')); } catch { message.error(t('learn.markCompletedError')); } } }; const getChapterStatus = (chapter: ChapterData, index: number) => { if (completedChapters.includes(chapter.id)) return "completed"; if (currentChapterIndex === index) return "current"; return "available"; }; const getStatusIcon = (chapter: ChapterData, index: number) => { const status = getChapterStatus(chapter, index); switch (status) { case "completed": return ; case "current": return ; default: return ; } }; if (loading) { return (
); } if (!courseData) { return (
{t('learn.courseNotExist')}
); } const currentChapter = chapters[currentChapterIndex]; return (

{t('learn.chapterList')}

{completedChapters.length}/{chapters.length} {t('learn.chapterCompleted')}
{chapters.map((chapter, index) => (
handleChapterClick(index)} >
{getStatusIcon(chapter, index)}
{chapter.title}
{chapter.duration}
))}

{currentChapter?.title}

{courseData.category && ( {getCategoryName(courseData.category)} )} {courseData.level && ( {getLevelName(courseData.level)} )} {(currentChapter?.video_url || currentChapter?.video_local_url) && ( }> {currentChapter?.video_source === 'local' ? '本地视频' : 'B站视频'} )}
{currentChapter?.duration}
{t('learn.documentation')} ), children: (
{chapterContent || t('learn.noContent')}
), }, ...(currentChapter?.video_url || currentChapter?.video_local_url ? [{ key: "video", label: ( {t('learn.videoTutorial')} ), children: ( ), }] : [{ key: "video", label: ( {t('learn.videoTutorial')} ), children: (

{t('learn.noVideo')}

), }]), { key: "materials", label: ( {t('learn.materials')} ), children: (
{materials.length > 0 ? (
{materials.map((item: any) => (
{item.file_type === 'pdf' ? : item.file_type === 'zip' ? : }
{item.title}
{item.file_size_display} | {t('learn.downloads')}: {item.download_count}
))}
) : (
{t('learn.noMaterials')}
)}
), }, { key: "practice", label: ( {t('codeRunner.practice')} ), children: (
{chapterSnippet ? (
本章检测到可运行代码示例({chapterSnippet.lang})
) : (
本章暂无代码示例,可直接在下方练习
)}
), }, ]} />
); }; export default CourseLearn;