Files
schoolNews/schoolNewsWeb/src/views/public/course/components/CourseDetail.vue
2025-11-17 16:35:18 +08:00

898 lines
21 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="courseItemVO" class="course-content">
<!-- 课程信息看板 -->
<div class="course-info-panel">
<div class="panel-container">
<!-- 左侧课程封面 -->
<div class="course-cover">
<img
:src="courseItemVO.coverImage ? FILE_DOWNLOAD_URL + courseItemVO.coverImage : defaultCover"
:alt="courseItemVO.name"
@error="handleImageError"
/>
</div>
<!-- 右侧课程信息 -->
<div class="course-info">
<div class="info-content">
<!-- 课程标题 -->
<h1 class="course-title">{{ courseItemVO.name }}</h1>
<!-- 课程简介 -->
<p class="course-desc">{{ courseItemVO.description || '暂无简介' }}</p>
<!-- 课程元信息 -->
<div class="course-meta">
<div class="meta-item">
<el-avatar :size="24" :src="getTeacherAvatar()" />
<span>{{ courseItemVO.teacher || '课程讲师' }}</span>
</div>
<div class="meta-divider"></div>
<div class="meta-item">
<img src="@/assets/imgs/clock.svg" alt="time" />
<span>{{ formatDuration(courseItemVO.duration) }}</span>
</div>
<div class="meta-divider"></div>
<div class="meta-item">
<img src="@/assets/imgs/book-read.svg" alt="learning" />
<span>{{ courseItemVO.learnCount || 0 }}人学习</span>
</div>
</div>
</div>
<!-- 进度条区域 -->
<div v-if="learningProgress" class="progress-section">
<span class="progress-label">课程进度</span>
<div class="progress-bar-wrapper">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: learningProgress.progress + '%' }"></div>
</div>
<span class="progress-percent">{{ learningProgress.progress }}%</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button
type="primary"
size="large"
@click="handleStartLearning"
:loading="enrolling"
>
{{ isEnrolled ? '继续学习' : '开始学习' }}
</el-button>
<el-button
size="large"
:plain="!isCollected"
@click="handleCollect"
>
<el-icon><Star /></el-icon>
收藏课程
</el-button>
</div>
</div>
</div>
</div>
<!-- 课程章节 -->
<div class="chapter-section">
<!-- 标题 -->
<div class="section-title">
<div class="title-bar"></div>
<span class="title-text">课程目录</span>
</div>
<!-- 章节列表 -->
<div v-if="!courseItemVO.chapters || courseItemVO.chapters.length === 0" class="empty-tip">
暂无章节内容
</div>
<div v-else class="chapter-list">
<div
v-for="(chapterItem, chapterIndex) in courseItemVO.chapters"
:key="chapterIndex"
class="chapter-item"
>
<!-- 章节标题 -->
<div class="chapter-header" @click="toggleChapter(chapterIndex)">
<div class="chapter-title">
<img src="@/assets/imgs/arrow-down.svg" alt="arrow" class="chevron-icon" :class="{ 'expanded': activeChapters.includes(chapterIndex) }"/>
<span class="chapter-name">
{{ chapterIndex + 1 }} {{ chapterItem.name }}{{ getChapterNodes(chapterIndex).length }}小节
</span>
</div>
</div>
<!-- 节点列表 -->
<div v-show="activeChapters.includes(chapterIndex)" class="node-list">
<div v-if="getChapterNodes(chapterIndex).length === 0" class="empty-tip">
暂无学习节点
</div>
<div
v-else
v-for="(node, nodeIndex) in getChapterNodes(chapterIndex)"
:key="nodeIndex"
class="node-item"
:class="{ 'completed': isNodeCompleted(chapterIndex, nodeIndex) }"
@click="handleNodeClick(chapterIndex, nodeIndex)"
>
<div class="node-left">
<span class="node-number">{{ nodeIndex + 1 }}</span>
<div class="node-info">
<img v-if="node.nodeType === 2" src="@/assets/imgs/video.svg" alt="video" class="node-icon" />
<img v-else src="@/assets/imgs/article.svg" alt="article" class="node-icon" />
<span class="node-name">{{ node.name }}</span>
</div>
</div>
<div class="node-right">
<span class="node-status">
{{ getNodeStatusText(node, chapterIndex, nodeIndex) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 收藏按钮底部浮动 -->
<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,
Star,
StarFilled
} 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 { CourseItemVO, 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 courseItemVO = ref<CourseItemVO | 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;
// 辅助函数:获取章节的节点列表
function getChapterNodes(chapterIndex: number): CourseItemVO[] {
if (!courseItemVO.value) return [];
const chapter = courseItemVO.value.chapters?.[chapterIndex];
if (!chapter?.chapterID) return [];
return courseItemVO.value.chapterNodes?.[chapter.chapterID] || [];
}
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) {
courseItemVO.value = res.data;
// 确保数据结构完整
if (!courseItemVO.value.chapters) {
courseItemVO.value.chapters = [];
}
if (!courseItemVO.value.chapterNodes) {
courseItemVO.value.chapterNodes = {};
}
} 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({
userID: userInfo.value.id,
collectionType: CollectionType.COURSE,
collectionID: 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 (!courseItemVO.value || !courseItemVO.value.chapters || courseItemVO.value.chapters.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, // 课程
courseID: props.courseId, // 使用courseID而不是resourceID
resourceID: props.courseId, // 保留resourceID以兼容
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 toggleChapter(chapterIndex: number) {
const index = activeChapters.value.indexOf(chapterIndex);
if (index > -1) {
activeChapters.value.splice(index, 1);
} else {
activeChapters.value.push(chapterIndex);
}
}
// 判断节点是否完成
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function isNodeCompleted(chapterIndex: number, nodeIndex: number): boolean {
// TODO: 实际应该从学习记录中判断
return false;
}
// 获取节点状态文本
function getNodeStatusText(node: any, chapterIndex: number, nodeIndex: number): string {
const isCompleted = isNodeCompleted(chapterIndex, nodeIndex);
if (isCompleted) {
if (node.nodeType === 0) {
// 文章类型,显示字数
return '已学完';
} else if (node.nodeType === 2 && node.duration) {
// 视频类型,显示时长
return `${node.duration}分钟|已学完`;
}
return '已学完';
} else {
if (node.nodeType === 0) {
// 文章类型
return '200字';
} else if (node.nodeType === 2 && node.duration) {
// 视频类型,显示时长和学习进度
return `${node.duration}分钟`;
}
return '未学习';
}
}
// 获取讲师头像
function getTeacherAvatar(): string {
return 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png';
}
// 图片加载失败处理
function handleImageError(event: Event) {
const target = event.target as HTMLImageElement;
target.src = defaultCover;
}
function formatDuration(minutes?: number): string {
if (!minutes) return '0小时0分';
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}小时${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-info-panel {
margin-bottom: 24px;
.panel-container {
display: flex;
gap: 40px;
padding: 40px 60px;
background: #FFFFFF;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}
}
.course-cover {
width: 478px;
height: 237px;
flex-shrink: 0;
border-radius: 10px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.course-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
.info-content {
display: flex;
flex-direction: column;
gap: 10px;
}
.course-title {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 20px;
line-height: 1.4;
color: #141F38;
margin: 0;
}
.course-desc {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.57;
color: rgba(0, 0, 0, 0.3);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.course-meta {
display: flex;
align-items: center;
gap: 7px;
.meta-item {
display: flex;
align-items: center;
gap: 7px;
img {
width: 18px;
height: 18px;
}
span {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.57;
color: #4E5969;
}
}
.meta-divider {
width: 1px;
height: 14px;
background: #F1F5F9;
}
}
}
.progress-section {
display: flex;
align-items: center;
gap: 14px;
.progress-label {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 14px;
line-height: 1.71;
color: #86909C;
}
.progress-bar-wrapper {
flex: 1;
display: flex;
align-items: center;
gap: 9px;
}
.progress-bar {
flex: 1;
height: 6px;
background: #EAEAEA;
border-radius: 27px;
overflow: hidden;
.progress-fill {
height: 100%;
background: #10A5A1;
border-radius: 27px;
transition: width 0.3s ease;
}
}
.progress-percent {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 14px;
line-height: 1.71;
color: #86909C;
text-align: right;
}
}
.action-buttons {
display: flex;
align-items: center;
gap: 27px;
:deep(.el-button) {
height: 42px;
border-radius: 8px;
font-family: 'PingFang SC';
font-weight: 500;
font-size: 16px;
padding: 8px 24px;
&.el-button--primary {
width: 180px;
background: #C62828;
border-color: #C62828;
&:hover {
background: #d32f2f;
border-color: #d32f2f;
}
}
&.el-button--default {
width: 125px;
border-color: #86909C;
color: #86909C;
img {
width: 20px;
height: 20px;
margin-right: 4px;
}
.el-icon {
margin-right: 4px;
}
&.is-plain {
background: #FFFFFF;
&:hover {
background: #f5f7fa;
}
}
&:not(.is-plain) {
background: #C62828;
border-color: #C62828;
color: #FFFFFF;
&:hover {
background: #d32f2f;
border-color: #d32f2f;
}
}
}
}
}
.learning-progress {
.progress-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
font-size: 14px;
.progress-text {
color: #409eff;
font-weight: 600;
}
}
}
// 课程章节区域
.chapter-section {
background: #FFFFFF;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}
.section-title {
display: flex;
align-items: center;
gap: 4px;
height: 25px;
margin-bottom: 24px;
.title-bar {
width: 3px;
height: 14px;
background: #C62828;
border-radius: 2px;
}
.title-text {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 16px;
line-height: 1.5;
color: #1E293B;
}
}
.chapter-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.chapter-item {
.chapter-header {
background: #F6F7F8;
border-radius: 6px;
padding: 18px 16px;
cursor: pointer;
transition: background 0.3s;
&:hover {
background: #eef0f2;
}
}
.chapter-title {
display: flex;
align-items: center;
gap: 4px;
.chevron-icon {
width: 12px;
height: 12px;
flex-shrink: 0;
transition: transform 0.3s;
transform: rotate(-90deg);
&.expanded {
transform: rotate(0deg);
}
}
.chapter-name {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 16px;
line-height: 1.5;
color: #000000;
}
}
}
.node-list {
display: flex;
flex-direction: column;
}
.node-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 30px;
border-bottom: 1px solid #F1F5F9;
cursor: pointer;
transition: background 0.3s;
&:hover {
background: #fafbfc;
}
.node-left {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
.node-number {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.57;
color: #C62828;
flex-shrink: 0;
}
.node-info {
display: flex;
align-items: center;
gap: 6px;
.node-icon {
flex-shrink: 0;
stroke: currentColor;
}
.node-name {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.57;
color: #C62828;
}
}
}
.node-right {
.node-status {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.57;
color: #C62828;
text-align: right;
}
}
// 已完成的节点样式
&.completed {
.node-left {
.node-number {
color: #4E5969;
}
.node-info {
.node-icon {
stroke: currentColor;
}
.node-name {
color: #4E5969;
}
}
}
.node-right {
.node-status {
color: #4E5969;
}
}
}
}
.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>