frontend-oj/src/views/course/CourseDetail.vue

851 lines
23 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 class="course-header">
<div class="container">
<div class="course-info">
<h1 class="course-title">{{ courseData.title }}</h1>
<p class="course-desc">{{ courseData.description }}</p>
<div class="course-meta">
<div class="meta-item">
<el-icon><User /></el-icon>
<span>讲师: {{ courseData.teacher }}</span>
</div>
<div class="meta-item">
<el-icon><View /></el-icon>
<span>{{ courseData.studentCount || 0 }}人学习</span>
</div>
<div class="meta-item">
<el-rate v-model="courseData.rating" disabled text-color="#ff9900" />
<span>({{ courseData.ratingCount || 0 }})</span>
</div>
</div>
</div>
</div>
</div>
<div class="main-content">
<div class="container">
<el-row :gutter="20">
<el-col :span="18">
<div class="resource-player">
<div v-if="currentResource" class="player-container">
<video-player
v-if="currentResource.type === 'video'"
:src="currentResource.url"
:poster="courseData.coverImg"
@ended="handleResourceComplete"
@timeupdate="handleTimeUpdate"
/>
<pdf-viewer
v-else-if="currentResource.type === 'pdf'"
:pdf-url="currentResource.url"
/>
<ppt-viewer
v-else-if="currentResource.type === 'ppt'"
:slides="currentResource.slides"
@slide-change="handleSlideChange"
/>
<div class="resource-info">
<h3>{{ currentResource.title }}</h3>
<div class="resource-meta">
<span class="type-tag" :class="currentResource.type">
{{ getResourceTypeName(currentResource.type) }}
</span>
<span v-if="currentResource.duration" class="duration">
<el-icon><Timer /></el-icon>
{{ currentResource.duration }}
</span>
<el-button
v-if="currentResource.downloadable"
type="primary"
link
@click="downloadResource"
>
<el-icon><Download /></el-icon>
下载资料
</el-button>
</div>
</div>
</div>
</div>
<div class="course-tabs">
<el-tabs v-model="activeTab">
<el-tab-pane label="课程章节" name="chapters">
<div class="chapter-list">
<el-collapse v-model="activeChapters">
<el-collapse-item
v-for="chapter in courseData.chapters"
:key="chapter.id"
:title="chapter.title"
:name="chapter.id"
>
<div class="section-list">
<div
v-for="section in chapter.sections"
:key="section.id"
class="section-item"
:class="{
'active': currentResource?.id === section.id,
'completed': section.completed
}"
@click="playResource(section)"
>
<div class="section-info">
<el-icon>
<VideoCamera v-if="section.type === 'video'" />
<Document v-else-if="section.type === 'pdf'" />
<PictureFilled v-else-if="section.type === 'ppt'" />
<Files v-else />
</el-icon>
<span class="title">{{ section.title }}</span>
<el-tag
v-if="section.isFree"
size="small"
type="success"
>免费</el-tag>
</div>
<div class="section-meta">
<span class="duration">{{ section.duration }}</span>
<el-icon
v-if="section.completed"
class="completed-icon"
><Check /></el-icon>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</el-tab-pane>
<el-tab-pane label="课程介绍" name="intro">
<div class="course-intro" v-html="courseData.description"></div>
</el-tab-pane>
<el-tab-pane label="课程评价" name="reviews">
<div class="course-reviews">
<div class="review-summary">
<div class="rating-overall">
<div class="rating-score">{{ courseData.rating.toFixed(1) }}</div>
<div class="rating-stars">
<el-rate v-model="courseData.rating" disabled text-color="#ff9900" />
<div class="rating-count">{{ courseData.ratingCount }}人评价</div>
</div>
</div>
<div class="rating-distribution">
<div v-for="(count, index) in courseData.ratingDistribution" :key="index" class="rating-bar">
<span class="rating-level">{{ 5 - index }}星</span>
<el-progress
:percentage="Math.round(count / courseData.ratingCount * 100)"
:stroke-width="12"
:show-text="false"
:color="'#ffc107'"
/>
<span class="rating-percent">{{ Math.round(count / courseData.ratingCount * 100) }}%</span>
</div>
</div>
</div>
<div class="review-list">
<div v-for="review in courseData.reviews" :key="review.id" class="review-item">
<div class="review-header">
<div class="reviewer-info">
<el-avatar :size="40" :src="review.avatar"></el-avatar>
<div class="reviewer-meta">
<div class="reviewer-name">{{ review.username }}</div>
<div class="review-date">{{ review.date }}</div>
</div>
</div>
<div class="review-rating">
<el-rate v-model="review.rating" disabled text-color="#ff9900" />
</div>
</div>
<div class="review-content">{{ review.content }}</div>
<div v-if="review.reply" class="review-reply">
<div class="reply-header">讲师回复:</div>
<div class="reply-content">{{ review.reply }}</div>
</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</el-col>
<el-col :span="6">
<div class="course-sidebar">
<div class="price-card">
<div class="price" :class="{ 'free': !courseData.price }">
{{ courseData.price ? `¥${courseData.price}` : '免费' }}
</div>
<el-button
type="primary"
size="large"
block
@click="handleEnroll"
>
{{ courseData.enrolled ? '继续学习' : '立即报名' }}
</el-button>
</div>
<div class="course-info-card">
<h4>课程信息</h4>
<ul>
<li>
<el-icon><VideoPlay /></el-icon>
<span>{{ courseData.videoCount || 0 }}个视频</span>
</li>
<li>
<el-icon><Document /></el-icon>
<span>{{ courseData.docCount || 0 }}份文档</span>
</li>
<li>
<el-icon><Timer /></el-icon>
<span>{{ courseData.totalDuration || '0小时' }}学时</span>
</li>
<li>
<el-icon><Connection /></el-icon>
<span>永久有效</span>
</li>
</ul>
</div>
</div>
</el-col>
</el-row>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
User, View, VideoPlay, Document, Timer,
Check, Connection, VideoCamera, Files,
PictureFilled, Download
} from '@element-plus/icons-vue'
import VideoPlayer from '@/components/player/VideoPlayer.vue'
import PdfViewer from '@/components/player/PdfViewer.vue'
import PptViewer from '@/components/player/PptViewer.vue'
const route = useRoute()
const activeTab = ref('chapters')
const activeChapters = ref([])
// 模拟课程数据
const courseData = ref({
title: 'C++面向对象编程精讲',
description: '本课程深入讲解C++面向对象编程的核心概念和实践应用帮助学习者掌握C++开发技能。',
teacher: '张教授',
price: 0, // 免费
rating: 4.8,
ratingCount: 245,
studentCount: 1234,
videoCount: 12,
docCount: 5,
totalDuration: '24小时',
enrolled: false,
coverImg: 'http://localhost:9000/file/c6d07740-7306-4fc1-b1f8-670684a73ed9/img/31c258e7-3fd4-402c-8f2f-c9455c6cfdaf.png',
chapters: [
{
id: 1,
title: '第一章C++基础入门',
sections: [
{
id: 101,
title: '1.1 C++开发环境搭建',
type: 'video',
duration: '25:00',
url: 'http://localhost:9000/videos/ffffad37-9804-4765-ae18-3f8dcda9bea8.mp4',
isFree: true,
completed: false
},
{
id: 102,
title: '1.2 C++基本语法',
type: 'video',
duration: '30:00',
url: 'https://example.com/videos/2.mp4',
isFree: true,
completed: false
},
{
id: 103,
title: '第一章课件',
type: 'pdf',
url: 'http://localhost:9000/file/%E5%AE%9E%E6%96%BD%E7%94%A8.pdf',
downloadable: true,
isFree: true,
completed: false
}
]
},
{
id: 2,
title: '第二章:面向对象基础',
sections: [
{
id: 201,
title: '2.1 类与对象',
type: 'video',
duration: '45:00',
url: 'https://example.com/videos/3.mp4',
isFree: false,
completed: false
},
{
id: 202,
title: '2.2 封装与访问控制',
type: 'video',
duration: '40:00',
url: 'https://example.com/videos/4.mp4',
isFree: false,
completed: false
},
{
id: 203,
title: '第二章PPT',
type: 'ppt',
slides: [
{ url: 'http://localhost:9000/file/c6d07740-7306-4fc1-b1f8-670684a73ed9/img/31c258e7-3fd4-402c-8f2f-c9455c6cfdaf.png', title: '封面' },
{ url: 'http://localhost:9000/file/c6d07740-7306-4fc1-b1f8-670684a73ed9/img/4灰度.jpg', title: '目录' },
{ url: 'http://localhost:9000/file/c6d07740-7306-4fc1-b1f8-670684a73ed9/img/4.jpg', title: '内容' }
],
isFree: false,
completed: false
}
]
}
],
reviews: [
{
id: 1,
username: '学员小王',
avatar: 'https://example.com/avatar1.jpg',
rating: 5,
content: '课程内容非常详实,讲解清晰,很适合入门学习!',
date: '2024-01-15',
reply: '感谢您的评价,我们会继续努力!'
},
{
id: 2,
username: '学员小李',
avatar: 'https://example.com/avatar2.jpg',
rating: 4,
content: '整体不错,希望能多一些实战案例。',
date: '2024-01-10'
}
],
ratingDistribution: [150, 50, 30, 10, 5]
})
// 当前正在播放/查看的资源
const currentResource = ref(courseData.value.chapters[0].sections[0])
// 资源类型名称映射
const resourceTypes = {
video: '视频',
pdf: 'PDF文档',
ppt: 'PPT课件',
document: '文档资料'
}
const getResourceTypeName = (type) => {
return resourceTypes[type] || '其他'
}
// 播放资源
const playResource = (resource) => {
if (!courseData.value.enrolled && !resource.isFree) {
ElMessage.warning('请先报名课程')
return
}
currentResource.value = resource
}
// 处理资源完成
const handleResourceComplete = () => {
if (currentResource.value) {
currentResource.value.completed = true
ElMessage.success('学习完成!')
// TODO: 调用后端API更新学习进度
}
}
// 处理视频进度更新
const handleTimeUpdate = ({ currentTime, duration }) => {
const progress = (currentTime / duration) * 100
console.log('Progress:', progress)
// TODO: 定期保存播放进度到后端
if (progress > 90) {
handleResourceComplete()
}
}
// 处理PPT幻灯片切换
const handleSlideChange = (index) => {
// 保存PPT浏览进度
console.log('Current slide:', index)
}
// 下载资源
const downloadResource = () => {
if (currentResource.value?.url) {
window.open(currentResource.value.url, '_blank')
}
}
// 报名课程
const handleEnroll = async () => {
try {
// TODO: 调用报名API
courseData.value.enrolled = true
ElMessage.success('报名成功!')
} catch (error) {
ElMessage.error('报名失败:' + error.message)
}
}
onMounted(async () => {
const courseId = route.params.id
try {
// TODO: 获取课程详情
// const response = await getCourseDetail(courseId)
// courseData.value = response.data
// 默认展开第一章
activeChapters.value = [courseData.value.chapters[0].id]
} catch (error) {
ElMessage.error('获取课程信息失败')
}
})
</script>
<style lang="scss" scoped>
.course-detail {
background: #f8fafc;
min-height: 100vh;
.course-header {
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
padding: 60px 0;
color: white;
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.course-info {
max-width: 800px;
.course-title {
font-size: 32px;
font-weight: 600;
margin-bottom: 16px;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.course-desc {
font-size: 16px;
color: #e2e8f0;
margin-bottom: 24px;
line-height: 1.6;
opacity: 0.9;
}
.course-meta {
display: flex;
flex-wrap: wrap;
gap: 24px;
.meta-item {
display: flex;
align-items: center;
color: #cbd5e1;
font-size: 15px;
.el-icon {
margin-right: 8px;
font-size: 18px;
}
.el-rate {
margin-right: 4px;
}
}
}
}
}
.main-content {
padding: 40px 20px;
.container {
max-width: 1200px;
margin: 0 auto;
}
}
.resource-player {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
.player-container {
position: relative;
width: 100%;
background: #000;
video {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: contain;
}
.resource-info {
background: white;
padding: 20px;
border-top: 1px solid #e2e8f0;
h3 {
font-size: 18px;
font-weight: 600;
color: #1e293b;
margin-bottom: 12px;
}
.resource-meta {
display: flex;
align-items: center;
gap: 16px;
.type-tag {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
.el-icon {
margin-right: 4px;
}
&.video {
background: #eff6ff;
color: #3b82f6;
}
&.pdf {
background: #fef2f2;
color: #ef4444;
}
&.ppt {
background: #fff7ed;
color: #f97316;
}
&.document {
background: #f0fdf4;
color: #22c55e;
}
}
.duration {
display: flex;
align-items: center;
gap: 4px;
color: #64748b;
font-size: 14px;
}
}
}
}
}
.course-tabs {
margin-top: 24px;
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
.chapter-list {
.el-collapse {
border: none;
.el-collapse-item {
&:not(:last-child) {
margin-bottom: 12px;
}
.el-collapse-item__header {
font-size: 16px;
font-weight: 600;
color: #1e293b;
background: #f8fafc;
border-radius: 8px;
padding: 16px;
border: none;
}
.el-collapse-item__content {
padding: 16px;
}
}
}
.section-item {
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 8px;
&:hover {
background: #f1f5f9;
}
&.active {
background: #eff6ff;
.section-info {
color: #3b82f6;
}
}
&.completed {
.completed-icon {
color: #22c55e;
}
}
.section-info {
display: flex;
align-items: center;
gap: 12px;
color: #475569;
.el-icon {
font-size: 18px;
}
.title {
flex: 1;
font-size: 15px;
}
.el-tag {
font-size: 12px;
border-radius: 4px;
}
}
.section-meta {
display: flex;
align-items: center;
gap: 12px;
color: #94a3b8;
font-size: 13px;
}
}
}
.course-reviews {
.review-summary {
display: flex;
gap: 40px;
padding: 24px;
background: #f8fafc;
border-radius: 12px;
margin-bottom: 24px;
.rating-overall {
display: flex;
gap: 20px;
align-items: center;
.rating-score {
font-size: 48px;
font-weight: 600;
color: #f59e0b;
}
.rating-stars {
.rating-count {
margin-top: 4px;
color: #64748b;
font-size: 14px;
}
}
}
.rating-distribution {
flex: 1;
max-width: 400px;
.rating-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
.rating-level {
width: 40px;
color: #64748b;
font-size: 14px;
}
.el-progress {
flex: 1;
}
.rating-percent {
width: 40px;
text-align: right;
color: #64748b;
font-size: 14px;
}
}
}
}
.review-list {
.review-item {
padding: 20px;
border-bottom: 1px solid #e2e8f0;
&:last-child {
border-bottom: none;
}
.review-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.reviewer-info {
display: flex;
align-items: center;
gap: 12px;
.reviewer-meta {
.reviewer-name {
font-weight: 500;
color: #1e293b;
}
.review-date {
font-size: 13px;
color: #64748b;
}
}
}
}
.review-content {
color: #475569;
line-height: 1.6;
margin-bottom: 12px;
}
.review-reply {
background: #f8fafc;
padding: 12px 16px;
border-radius: 8px;
margin-top: 12px;
.reply-header {
color: #64748b;
font-size: 13px;
margin-bottom: 4px;
}
.reply-content {
color: #475569;
}
}
}
}
}
}
.course-sidebar {
position: sticky;
top: 24px;
.price-card {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
.price {
font-size: 32px;
font-weight: 600;
color: #ef4444;
margin-bottom: 20px;
&.free {
color: #22c55e;
}
}
.el-button {
height: 44px;
font-size: 16px;
}
}
.course-info-card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
h4 {
font-size: 16px;
font-weight: 600;
color: #1e293b;
margin-bottom: 16px;
}
ul {
list-style: none;
padding: 0;
margin: 0;
li {
display: flex;
align-items: center;
padding: 12px 0;
color: #475569;
font-size: 15px;
&:not(:last-child) {
border-bottom: 1px solid #e2e8f0;
}
.el-icon {
margin-right: 12px;
font-size: 18px;
color: #3b82f6;
}
}
}
}
}
}
</style>