Files
schoolNews/schoolNewsWeb/src/views/course/components/CourseDetail.vue
2025-10-24 18:28:35 +08:00

689 lines
16 KiB
Vue
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.

<template>
<div class="course-detail">
<!-- 返回按钮可选 -->
<div v-if="showBackButton" class="back-header">
<el-button @click="handleBack" :icon="ArrowLeft">{{ backButtonText }}</el-button>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading">
<el-skeleton :rows="10" animated />
</div>
<!-- 课程详情 -->
<div v-else-if="courseVO" class="course-content">
<!-- 课程封面和基本信息 -->
<div class="course-header">
<div class="cover-section">
<img
:src="courseVO.course.coverImage? FILE_DOWNLOAD_URL + courseVO.course.coverImage : defaultCover"
:alt="courseVO.course.name"
class="cover-image"
/>
</div>
<div class="info-section">
<h1 class="course-title">{{ courseVO.course.name }}</h1>
<div class="course-meta">
<div class="meta-item">
<el-icon><User /></el-icon>
<span>授课老师{{ courseVO.course.teacher }}</span>
</div>
<div class="meta-item">
<el-icon><Clock /></el-icon>
<span>课程时长{{ formatDuration(courseVO.course.duration) }}</span>
</div>
<div class="meta-item">
<el-icon><View /></el-icon>
<span>{{ courseVO.course.viewCount || 0 }} 人浏览</span>
</div>
<div class="meta-item">
<el-icon><Reading /></el-icon>
<span>{{ courseVO.course.learnCount || 0 }} 人学习</span>
</div>
</div>
<div class="course-description">
<p>{{ courseVO.course.description }}</p>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button
type="primary"
size="large"
@click="handleStartLearning"
:loading="enrolling"
>
<el-icon><VideoPlay /></el-icon>
{{ isEnrolled ? '继续学习' : '开始学习' }}
</el-button>
<el-button
size="large"
:icon="isCollected ? StarFilled : Star"
@click="handleCollect"
:type="isCollected ? 'success' : 'default'"
>
{{ isCollected ? '已收藏' : '收藏课程' }}
</el-button>
</div>
<!-- 学习进度 -->
<div v-if="learningProgress" class="learning-progress">
<div class="progress-header">
<span>学习进度</span>
<span class="progress-text">{{ learningProgress.progress }}%</span>
</div>
<el-progress
:percentage="learningProgress.progress"
:stroke-width="10"
:color="progressColor"
/>
</div>
</div>
</div>
<!-- 课程章节 -->
<el-card class="chapter-card" shadow="never">
<template #header>
<div class="card-header">
<span>课程章节</span>
<span class="chapter-count"> {{ courseVO.courseChapters.length }} </span>
</div>
</template>
<div v-if="courseVO.courseChapters.length === 0" class="empty-tip">
暂无章节内容
</div>
<el-collapse v-else v-model="activeChapters">
<el-collapse-item
v-for="(chapterVO, chapterIndex) in courseVO.courseChapters"
:key="chapterIndex"
:name="chapterIndex"
>
<template #title>
<div class="chapter-title-bar">
<span class="chapter-name">
<el-icon><DocumentCopy /></el-icon>
章节 {{ chapterIndex + 1 }}: {{ chapterVO.chapter.name }}
</span>
<span class="chapter-meta">
{{ chapterVO.nodes.length }} 个节点
</span>
</div>
</template>
<!-- 节点列表 -->
<div v-if="chapterVO.nodes.length === 0" class="empty-tip">
暂无学习节点
</div>
<div v-else class="node-list">
<div
v-for="(node, nodeIndex) in chapterVO.nodes"
:key="nodeIndex"
class="node-item"
@click="handleNodeClick(chapterIndex, nodeIndex)"
>
<div class="node-info">
<el-icon class="node-icon">
<Document v-if="node.nodeType === 0" />
<Edit v-else-if="node.nodeType === 1" />
<Upload v-else />
</el-icon>
<span class="node-name">{{ node.name }}</span>
<el-tag
v-if="node.isRequired === 1"
type="danger"
size="small"
effect="plain"
>
必修
</el-tag>
</div>
<div class="node-meta">
<span v-if="node.duration" class="node-duration">
<el-icon><Clock /></el-icon>
{{ node.duration }} 分钟
</span>
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</el-card>
<!-- 收藏按钮底部浮动 -->
<div class="floating-collect">
<el-button
circle
size="large"
:icon="isCollected ? StarFilled : Star"
@click="handleCollect"
:type="isCollected ? 'warning' : 'default'"
/>
</div>
</div>
<!-- 加载失败 -->
<div v-else class="error-tip">
<el-empty description="加载课程失败" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import {
ArrowLeft,
User,
Clock,
View,
Reading,
VideoPlay,
Star,
StarFilled,
DocumentCopy,
Document,
Edit,
Upload,
ArrowRight
} from '@element-plus/icons-vue';
import { courseApi } from '@/apis/study';
import { learningRecordApi } from '@/apis/study';
import { userCollectionApi } from '@/apis/usercenter';
import { useStore } from 'vuex';
import type { CourseVO, LearningRecord } from '@/types';
import { CollectionType } from '@/types';
import { FILE_DOWNLOAD_URL } from '@/config';
defineOptions({
name: 'CourseDetail'
});
interface Props {
courseId: string;
showBackButton?: boolean;
backButtonText?: string;
}
const props = withDefaults(defineProps<Props>(), {
showBackButton: false,
backButtonText: '返回'
});
const emit = defineEmits<{
'back': [];
'start-learning': [courseId: string, chapterIndex: number, nodeIndex: number];
}>();
const router = useRouter();
const store = useStore();
// 从 store 获取用户信息
const userInfo = computed(() => store.getters['auth/user']);
const loading = ref(false);
const enrolling = ref(false);
const courseVO = ref<CourseVO | null>(null);
const isCollected = ref(false);
const isEnrolled = ref(false);
const learningProgress = ref<LearningRecord | null>(null);
const activeChapters = ref<number[]>([0]); // 默认展开第一章
const defaultCover = new URL('@/assets/imgs/article-default.png', import.meta.url).href;
// 进度条颜色
const progressColor = computed(() => {
const progress = learningProgress.value?.progress || 0;
if (progress >= 80) return '#67c23a';
if (progress >= 50) return '#409eff';
return '#e6a23c';
});
watch(() => props.courseId, (newId) => {
if (newId) {
loadCourseDetail();
checkCollectionStatus();
loadLearningProgress();
}
}, { immediate: true });
// 加载课程详情
async function loadCourseDetail() {
loading.value = true;
try {
// 增加浏览次数
courseApi.incrementViewCount(props.courseId);
const res = await courseApi.getCourseById(props.courseId);
if (res.success && res.data) {
courseVO.value = res.data;
// 确保数据结构完整
if (!courseVO.value.courseChapters) {
courseVO.value.courseChapters = [];
}
if (!courseVO.value.courseTags) {
courseVO.value.courseTags = [];
}
courseVO.value.courseChapters.forEach((chapter) => {
if (!chapter.nodes) {
chapter.nodes = [];
}
});
} else {
ElMessage.error('加载课程失败');
}
} catch (error) {
console.error('加载课程失败:', error);
ElMessage.error('加载课程失败');
} finally {
loading.value = false;
}
}
// 检查收藏状态
async function checkCollectionStatus() {
if (!userInfo.value?.id) return;
try {
const res = await userCollectionApi.isCollected(
CollectionType.COURSE,
props.courseId
);
if (res.success) {
isCollected.value = res.data || false;
}
} catch (error) {
console.error('检查收藏状态失败:', error);
}
}
// 加载学习进度
async function loadLearningProgress() {
if (!userInfo.value?.id) return;
try {
const res = await learningRecordApi.getCourseLearningRecord({
userID: userInfo.value.id,
resourceType: 2, // 课程
courseID: props.courseId
});
if (res.success && res.dataList && res.dataList.length > 0) {
learningProgress.value = res.dataList[0];
isEnrolled.value = true;
}
} catch (error) {
console.error('加载学习进度失败:', error);
}
}
// 处理收藏
async function handleCollect() {
if (!userInfo.value?.id) {
ElMessage.warning('请先登录');
router.push('/login');
return;
}
try {
if (isCollected.value) {
// 取消收藏
const res = await userCollectionApi.removeCollection(
userInfo.value.id,
CollectionType.COURSE,
props.courseId
);
if (res.success) {
isCollected.value = false;
ElMessage.success('已取消收藏');
}
} else {
// 添加收藏
const res = await userCollectionApi.addCollection({
userID: userInfo.value.id,
collectionType: CollectionType.COURSE,
collectionID: props.courseId
});
if (res.success) {
isCollected.value = true;
ElMessage.success('收藏成功');
}
}
} catch (error) {
console.error('收藏操作失败:', error);
ElMessage.error('操作失败');
}
}
// 开始学习
async function handleStartLearning() {
if (!userInfo.value?.id) {
ElMessage.warning('请先登录');
router.push('/login');
return;
}
if (!courseVO.value || courseVO.value.courseChapters.length === 0) {
ElMessage.warning('课程暂无内容');
return;
}
enrolling.value = true;
try {
// 如果未报名,创建学习记录
if (!isEnrolled.value) {
await courseApi.incrementLearnCount(props.courseId);
await learningRecordApi.createRecord({
userID: userInfo.value.id,
resourceType: 2, // 课程
resourceID: props.courseId,
progress: 0,
duration: 0,
isComplete: false
});
isEnrolled.value = true;
}
// 跳转到学习页面,默认从第一章第一节开始
emit('start-learning', props.courseId, 0, 0);
} catch (error) {
console.error('开始学习失败:', error);
ElMessage.error('开始学习失败');
} finally {
enrolling.value = false;
}
}
// 点击节点
function handleNodeClick(chapterIndex: number, nodeIndex: number) {
if (!userInfo.value?.id) {
ElMessage.warning('请先登录');
router.push('/login');
return;
}
emit('start-learning', props.courseId, chapterIndex, nodeIndex);
}
// 返回
function handleBack() {
emit('back');
}
// 格式化时长
function formatDuration(minutes?: number): string {
if (!minutes) return '0分钟';
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) {
return `${hours}小时${mins}分钟`;
}
return `${mins}分钟`;
}
</script>
<style lang="scss" scoped>
.course-detail {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 60px;
}
.back-header {
position: sticky;
top: 0;
z-index: 100;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
margin-bottom: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.loading {
padding: 24px;
background: #fff;
margin: 16px;
border-radius: 8px;
}
.course-content {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.course-header {
background: #fff;
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
display: flex;
gap: 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
@media (max-width: 768px) {
flex-direction: column;
}
}
.cover-section {
flex-shrink: 0;
.cover-image {
width: 320px;
height: 240px;
object-fit: cover;
border-radius: 8px;
@media (max-width: 768px) {
width: 100%;
height: auto;
}
}
}
.info-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
.course-title {
font-size: 28px;
font-weight: 600;
color: #303133;
margin: 0;
line-height: 1.4;
}
.course-meta {
display: flex;
flex-wrap: wrap;
gap: 24px;
.meta-item {
display: flex;
align-items: center;
gap: 8px;
color: #606266;
font-size: 14px;
.el-icon {
color: #909399;
}
}
}
.course-description {
color: #606266;
font-size: 14px;
line-height: 1.8;
p {
margin: 0;
}
}
.action-buttons {
display: flex;
gap: 16px;
margin-top: 8px;
}
.learning-progress {
padding: 16px;
background: #f5f7fa;
border-radius: 8px;
.progress-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
font-size: 14px;
.progress-text {
color: #409eff;
font-weight: 600;
}
}
}
.chapter-card {
:deep(.el-card__header) {
background: #fafafa;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 18px;
font-weight: 600;
.chapter-count {
font-size: 14px;
color: #909399;
font-weight: normal;
}
}
}
.chapter-title-bar {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding-right: 20px;
.chapter-name {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 500;
}
.chapter-meta {
font-size: 14px;
color: #909399;
}
}
.node-list {
padding: 8px 0;
}
.node-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
margin: 8px 0;
background: #fafafa;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: #f0f2f5;
transform: translateX(4px);
}
.node-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
.node-icon {
color: #409eff;
font-size: 18px;
}
.node-name {
font-size: 14px;
color: #303133;
}
}
.node-meta {
display: flex;
align-items: center;
gap: 12px;
.node-duration {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #909399;
}
.arrow-icon {
color: #c0c4cc;
transition: transform 0.3s;
}
}
&:hover .arrow-icon {
transform: translateX(4px);
}
}
.empty-tip {
text-align: center;
padding: 40px;
color: #909399;
font-size: 14px;
}
.error-tip {
padding: 40px;
}
.floating-collect {
position: fixed;
bottom: 80px;
right: 40px;
z-index: 100;
@media (max-width: 768px) {
bottom: 60px;
right: 20px;
}
}
</style>