Files
chunyu_prject_react/src/pages/CourseLearn/CourseLearn.tsx
T
chunyu e90b787730 feat(C-03):代码在线运行+课程练一节
新增CodeRunner组件(JS worker沙箱+Python pyodide)与/utility/code-runner独立页;CourseLearn新增练习Tab(首代码块自动提取装载,无示例可直接练习)。
2026-09-15 15:26:44 +08:00

530 lines
19 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.
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<string, string> = {
'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<string, string> = {
'beginner': t('learn.courseLevelBeginner'),
'advanced': t('learn.courseLevelAdvanced'),
'intermediate': t('learn.courseLevelAdvanced'),
};
return map[key] || key;
};
const [courseData, setCourseData] = useState<CourseDetailData | null>(null);
const [chapters, setChapters] = useState<ChapterData[]>([]);
const [currentChapterIndex, setCurrentChapterIndex] = useState(0);
const [chapterContent, setChapterContent] = useState("");
const [chapterSnippet, setChapterSnippet] = useState<{ lang: "javascript" | "python"; code: string } | null>(null);
const [completedChapters, setCompletedChapters] = useState<number[]>([]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [materials, setMaterials] = useState<any[]>([]);
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 <CheckCircleOutlined style={{ color: "#10b981" }} />;
case "current":
return <PlayCircleOutlined style={{ color: "#667eea" }} />;
default:
return <PlayCircleOutlined style={{ color: "#94a3b8" }} />;
}
};
if (loading) {
return (
<div className="course-learn-container" style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
<Spin size="large" />
</div>
);
}
if (!courseData) {
return (
<div className="course-learn-container" style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
<span>{t('learn.courseNotExist')}</span>
</div>
);
}
const currentChapter = chapters[currentChapterIndex];
return (
<div className="course-learn-container">
<div className="course-learn-topbar">
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate(-1)}
className="back-button"
/>
<div className="topbar-title">
<BookOutlined />
<span>{courseData.title}</span>
</div>
<div className="topbar-progress">
<Progress percent={progress} size="small" style={{ width: 120 }} />
<span className="progress-text">{progress}%</span>
</div>
<Button
type="text"
icon={<MenuOutlined />}
onClick={() => setSidebarOpen(!sidebarOpen)}
className="menu-button"
/>
</div>
<div className="course-learn-content">
<div className={`sidebar ${sidebarOpen ? "open" : ""}`}>
<div className="sidebar-header">
<h3>{t('learn.chapterList')}</h3>
<span className="chapter-count">
{completedChapters.length}/{chapters.length} {t('learn.chapterCompleted')}
</span>
</div>
<div className="chapter-list">
{chapters.map((chapter, index) => (
<div
key={chapter.id}
className={`chapter-item ${getChapterStatus(chapter, index)} ${
currentChapterIndex === index ? "active" : ""
}`}
onClick={() => handleChapterClick(index)}
>
<div className="chapter-icon">{getStatusIcon(chapter, index)}</div>
<div className="chapter-info">
<div className="chapter-title">{chapter.title}</div>
<div className="chapter-duration">
<ClockCircleOutlined />
<span>{chapter.duration}</span>
</div>
</div>
</div>
))}
</div>
</div>
<div className="main-content">
<div className="content-card">
<div className="content-header">
<h1>{currentChapter?.title}</h1>
<div className="content-meta">
<div className="meta-tags">
{courseData.category && (
<Tag color="blue">{getCategoryName(courseData.category)}</Tag>
)}
{courseData.level && (
<Tag color="green">{getLevelName(courseData.level)}</Tag>
)}
{(currentChapter?.video_url || currentChapter?.video_local_url) && (
<Tag color="purple" icon={<VideoCameraOutlined />}>
{currentChapter?.video_source === 'local' ? '本地视频' : 'B站视频'}
</Tag>
)}
</div>
<div className="meta-duration">
<ClockCircleOutlined />
<span>{currentChapter?.duration}</span>
</div>
</div>
</div>
<div className="content-body with-video">
<Tabs
key={`tabs-${currentChapter?.id}`}
defaultActiveKey="docs"
items={[
{
key: "docs",
label: (
<span>
<FileTextOutlined /> {t('learn.documentation')}
</span>
),
children: (
<div className="markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeHighlight, rehypeKatex]}
>
{chapterContent || t('learn.noContent')}
</ReactMarkdown>
</div>
),
},
...(currentChapter?.video_url || currentChapter?.video_local_url
? [{
key: "video",
label: (
<span>
<VideoCameraOutlined /> {t('learn.videoTutorial')}
</span>
),
children: (
<BDCloudVideoView
videoSource={currentChapter.video_url?.includes('bilibili.com') ? 'bilibili' : 'local'}
bilibiliUrl={currentChapter.video_url?.includes('bilibili.com') ? currentChapter.video_url : undefined}
localUrl={!currentChapter.video_url?.includes('bilibili.com') ? currentChapter.video_url : undefined}
posterUrl={currentChapter.video_poster_url}
title={currentChapter.title}
chapterId={currentChapter.id}
sources={{
bilibili: currentChapter.video_url?.includes('bilibili.com') ? currentChapter.video_url : undefined,
local: !currentChapter.video_url?.includes('bilibili.com') ? currentChapter.video_url : undefined,
}}
activeSource={videoSource}
onSourceChange={setVideoSource}
/>
),
}]
: [{
key: "video",
label: (
<span>
<VideoCameraOutlined /> {t('learn.videoTutorial')}
</span>
),
children: (
<div className="empty-video">
<VideoCameraOutlined className="empty-video-icon" />
<p>{t('learn.noVideo')}</p>
</div>
),
}]),
{
key: "materials",
label: (
<span>
<DownloadOutlined /> {t('learn.materials')}
</span>
),
children: (
<div className="materials-content">
{materials.length > 0 ? (
<div className="materials-list">
{materials.map((item: any) => (
<div key={item.id} className="material-card">
<div className="material-info">
<span className="material-icon">
{item.file_type === 'pdf' ? <FilePdfOutlined /> : item.file_type === 'zip' ? <FileZipOutlined /> : <FileOutlined />}
</span>
<div className="material-details">
<div className="material-title">{item.title}</div>
<div className="material-meta">{item.file_size_display} | {t('learn.downloads')}: {item.download_count}</div>
</div>
</div>
<Button type="primary" size="small" icon={<DownloadOutlined />} onClick={() => {
api_request.learn.download_material(item.id).then((res: any) => {
const blob = res.data;
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = item.title;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}).catch(() => {
message.error(t('learn.downloadError'));
});
}}>
{t('learn.download')}
</Button>
</div>
))}
</div>
) : (
<div className="empty-materials">{t('learn.noMaterials')}</div>
)}
</div>
),
},
{
key: "practice",
label: (
<span>
<PlayCircleOutlined /> {t('codeRunner.practice')}
</span>
),
children: (
<div>
{chapterSnippet ? (
<div className="practice-snippet-bar">
<span>本章检测到可运行代码示例({chapterSnippet.lang})</span>
<Button
size="small"
type="primary"
onClick={() => setChapterSnippet({ ...chapterSnippet })}
>
装载本章代码
</Button>
</div>
) : (
<div className="practice-snippet-bar practice-snippet-bar--empty">
本章暂无代码示例,可直接在下方练习
</div>
)}
<CodeRunner
key={`${currentChapter?.id}-${chapterSnippet?.code?.length ?? 0}`}
height={420}
initialCode={chapterSnippet?.code}
/>
</div>
),
},
]}
/>
</div>
<div className="content-actions">
<Button
onClick={handlePrevChapter}
disabled={currentChapterIndex === 0}
icon={<ArrowLeftOutlined />}
>
{t('learn.prevChapter')}
</Button>
<Button
type="primary"
onClick={handleMarkCompleted}
disabled={completedChapters.includes(currentChapter?.id)}
icon={<CheckCircleOutlined />}
>
{completedChapters.includes(currentChapter?.id) ? t('learn.alreadyCompleted') : t('learn.markCompleted')}
</Button>
<Button
onClick={handleNextChapter}
disabled={currentChapterIndex === chapters.length - 1}
>
{t('learn.nextChapter')}
<ArrowLeftOutlined style={{ transform: "rotate(180deg)" }} />
</Button>
</div>
</div>
</div>
</div>
</div>
);
};
export default CourseLearn;