feat:课程详情切换ai伴学,里面接口重新调用,dplayer播放器切换,播放器配置,

This commit is contained in:
小张 2025-08-26 18:51:00 +08:00
parent 068fc262ab
commit 829660dbda
7 changed files with 981 additions and 637 deletions

View File

@ -16,25 +16,54 @@
</div>
</div>
<!-- 视频控制工具栏 -->
<div v-if="playerInitialized" class="video-controls-overlay">
<!-- 功能按钮组 -->
<div class="video-function-buttons">
<!-- 清晰度选择器 -->
<div v-if="videoQualities.length > 1" class="video-quality-selector">
<div class="quality-dropdown">
<button class="quality-btn" @click="showQualityMenu = !showQualityMenu">
{{ currentQuality }}p
<svg width="12" height="12" viewBox="0 0 12 12" class="dropdown-icon">
<div v-if="videoQualities.length > 1" class="dplayer-quality-selector">
<button class="dplayer-control-btn dplayer-quality-btn" @click="showQualityMenu = !showQualityMenu" title="清晰度">
{{ getCurrentQualityLabel() }}
<svg width="12" height="12" viewBox="0 0 12 12" class="quality-dropdown-icon">
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.5" fill="none" />
</svg>
</button>
<div v-if="showQualityMenu" class="quality-menu">
<div v-for="quality in videoQualities" :key="quality.value" class="quality-option"
<div v-if="showQualityMenu" class="dplayer-quality-menu">
<div v-for="quality in videoQualities" :key="quality.value"
class="dplayer-quality-option"
:class="{ active: quality.value === currentQuality }"
@click="changeVideoQuality(quality.value); showQualityMenu = false">
@click="switchQuality(quality); showQualityMenu = false">
{{ quality.label }}
</div>
</div>
</div>
<!-- 截屏按钮 -->
<button class="dplayer-control-btn" @click="takeScreenshot" title="截屏">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M15 5v6c0 .55-.45 1-1 1H2c-.55 0-1-.45-1-1V5c0-.55.45-1 1-1h1.5l.5-1h7l.5 1H14c.55 0 1 .45 1 1zM8 10.5c1.38 0 2.5-1.12 2.5-2.5S9.38 5.5 8 5.5 5.5 6.62 5.5 8 6.62 10.5 8 10.5z"/>
</svg>
</button>
<!-- 画中画按钮 -->
<button class="dplayer-control-btn" @click="togglePictureInPicture" title="画中画">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M0 1a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V1zm1 0v14h14V1H1zm5.5 4a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5V5z"/>
</svg>
</button>
<!-- 弹幕开关 -->
<button class="dplayer-control-btn" @click="toggleDanmaku" :class="{ active: danmakuEnabled }" title="弹幕">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M2.5 3A1.5 1.5 0 0 0 1 4.5v7A1.5 1.5 0 0 0 2.5 13h11a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 13.5 3h-11zM2 4.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-7z"/>
<path d="M3.5 6h9v1h-9V6zm0 2h7v1h-7V8zm0 2h5v1h-5v-1z"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</template>
@ -72,12 +101,19 @@ const emit = defineEmits<{
ended: []
error: [error: any]
qualityChange: [quality: string]
screenshot: [dataUrl: string]
danmakuSend: [text: string]
}>()
const dplayerContainer = ref<HTMLDivElement>()
let player: any = null
const playerInitialized = ref(false)
const isPlaying = ref(false)
//
const danmakuEnabled = ref(true)
const danmakuText = ref('')
const isPictureInPicture = ref(false)
const showQualityMenu = ref(false)
// DPlayer
@ -146,6 +182,19 @@ const initializePlayer = async (videoUrl?: string) => {
if (!dplayerContainer.value) return
//
const containerRect = dplayerContainer.value.getBoundingClientRect()
console.log('🔍 DPlayer容器尺寸:', {
width: containerRect.width,
height: containerRect.height,
top: containerRect.top,
left: containerRect.left
})
if (containerRect.height === 0) {
console.warn('⚠️ DPlayer容器高度为0可能影响播放')
}
const DPlayer = (window as any).DPlayer
const url = videoUrl || props.videoUrl
@ -159,7 +208,8 @@ const initializePlayer = async (videoUrl?: string) => {
player = null
}
player = new DPlayer({
// DPlayer
const dplayerConfig: any = {
container: dplayerContainer.value,
video: {
url: url,
@ -173,12 +223,50 @@ const initializePlayer = async (videoUrl?: string) => {
volume: 0.8,
playbackSpeed: [0.5, 0.75, 1, 1.25, 1.5, 2],
loop: false,
screenshot: true, //
danmaku: {
id: 'course-video-' + Date.now(),
api: '/api/danmaku/', // API
token: 'demo-token',
maximum: 1000,
// addition API
// addition: ['https://api.prprpr.me/dplayer/'],
user: 'student',
bottom: '15%',
unlimited: true
},
contextmenu: [
{
text: '截屏',
click: () => takeScreenshot()
},
{
text: '关于 DPlayer',
link: 'https://github.com/DIYGod/DPlayer'
}
]
}
// DPlayer
console.log('🔍 检查清晰度配置:', {
videoQualities: props.videoQualities,
currentQuality: props.currentQuality,
qualitiesLength: props.videoQualities?.length
})
// 使DPlayerquality
console.log('✅ 使用自定义清晰度选择器:', {
mainVideoUrl: url,
availableQualities: props.videoQualities?.length || 0
})
player = new DPlayer(dplayerConfig)
// DPlayerquality
console.log('🔍 DPlayer实例创建完成:', {
hasQuality: !!player.quality,
qualityOptions: player.quality?.options,
playerConfig: dplayerConfig
})
//
@ -198,10 +286,42 @@ const initializePlayer = async (videoUrl?: string) => {
})
player.on('error', (error: any) => {
console.error('DPlayer error:', error)
console.error('DPlayer 播放错误:', error)
console.error('错误详情:', {
type: error.type,
message: error.message,
url: url
})
emit('error', error)
})
player.on('loadstart', () => {
console.log('🔍 视频开始加载:', url)
})
player.on('canplay', () => {
console.log('✅ 视频可以播放:', url)
})
player.on('loadeddata', () => {
console.log('✅ 视频数据加载完成:', url)
//
if (props.videoQualities && props.videoQualities.length > 1) {
setTimeout(() => {
console.log('🔍 尝试手动设置清晰度选项')
if (player && player.quality) {
console.log('✅ DPlayer quality对象存在:', player.quality)
} else {
console.log('❌ DPlayer quality对象不存在尝试其他方法')
// DOM
const qualityBtn = dplayerContainer.value?.querySelector('.dplayer-quality-button')
console.log('🔍 查找清晰度按钮:', qualityBtn)
}
}, 1000)
}
})
playerInitialized.value = true
console.log('DPlayer 初始化成功:', url)
} catch (err) {
@ -210,30 +330,7 @@ const initializePlayer = async (videoUrl?: string) => {
}
}
//
const changeVideoQuality = (quality: string) => {
const qualityVideo = props.videoQualities.find(q => q.value === quality)
if (qualityVideo && player) {
try {
// 使 DPlayer switchVideo
if (typeof player.switchVideo === 'function') {
player.switchVideo({
url: qualityVideo.url,
type: 'auto'
})
} else {
// switchVideo
initializePlayer(qualityVideo.url)
}
emit('qualityChange', quality)
console.log('切换清晰度到:', quality)
} catch (error) {
console.error('切换清晰度失败:', error)
//
initializePlayer(qualityVideo.url)
}
}
}
//
const play = () => {
@ -254,6 +351,144 @@ const seek = (time: number) => {
}
}
//
const takeScreenshot = () => {
if (player && player.video) {
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const video = player.video
canvas.width = video.videoWidth
canvas.height = video.videoHeight
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
const dataUrl = canvas.toDataURL('image/png')
//
const link = document.createElement('a')
link.download = `screenshot-${Date.now()}.png`
link.href = dataUrl
link.click()
emit('screenshot', dataUrl)
console.log('截屏成功')
}
} catch (error) {
console.error('截屏失败:', error)
}
}
}
//
const togglePictureInPicture = async () => {
if (player && player.video) {
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture()
isPictureInPicture.value = false
} else {
await player.video.requestPictureInPicture()
isPictureInPicture.value = true
}
} catch (error) {
console.error('画中画切换失败:', error)
}
}
}
//
const toggleDanmaku = () => {
danmakuEnabled.value = !danmakuEnabled.value
if (player && player.danmaku) {
if (danmakuEnabled.value) {
player.danmaku.show()
} else {
player.danmaku.hide()
}
}
}
const sendDanmaku = () => {
if (danmakuText.value.trim() && player && player.danmaku) {
const danmaku = {
text: danmakuText.value.trim(),
color: '#ffffff',
type: 'right'
}
player.danmaku.send(danmaku)
emit('danmakuSend', danmakuText.value.trim())
danmakuText.value = ''
console.log('发送弹幕:', danmaku.text)
}
}
//
const getCurrentQualityLabel = () => {
const current = props.videoQualities.find(q => q.value === props.currentQuality)
return current ? current.label : '360p'
}
const switchQuality = (quality: any) => {
if (player && quality.url) {
try {
//
const currentTime = player.video?.currentTime || 0
const wasPlaying = !player.video?.paused
console.log('🔄 开始切换清晰度:', {
from: getCurrentQualityLabel(),
to: quality.label,
currentTime: currentTime,
wasPlaying: wasPlaying
})
//
if (wasPlaying) {
player.pause()
}
//
if (typeof player.switchVideo === 'function') {
player.switchVideo({
url: quality.url,
type: 'auto'
})
//
setTimeout(() => {
if (player && player.video) {
player.seek(currentTime)
if (wasPlaying) {
player.play()
}
}
}, 1000)
} else {
//
initializePlayer(quality.url).then(() => {
//
setTimeout(() => {
if (player && player.video) {
player.seek(currentTime)
if (wasPlaying) {
player.play()
}
}
}, 500)
})
}
emit('qualityChange', quality.value)
console.log('✅ 切换清晰度到:', quality.label)
} catch (error) {
console.error('❌ 切换清晰度失败:', error)
}
}
}
const setVolume = (volume: number) => {
if (player) {
player.volume(volume / 100)
@ -286,7 +521,12 @@ defineExpose({
seek,
setVolume,
destroy,
initializePlayer
initializePlayer,
takeScreenshot,
togglePictureInPicture,
toggleDanmaku,
sendDanmaku,
switchQuality
})
onMounted(() => {
@ -299,6 +539,8 @@ onMounted(() => {
onUnmounted(() => {
destroy()
})
</script>
<style scoped>
@ -310,7 +552,7 @@ onUnmounted(() => {
.video-container {
position: relative;
width: 100%;
height: 578px;
height: 100%; /* 填满父容器 */
background: transparent;
}
@ -371,83 +613,244 @@ onUnmounted(() => {
opacity: 0.9;
}
/* 清晰度选择器 */
.video-quality-selector {
/* 视频控制工具栏 - DPlayer原生风格 */
.video-controls-overlay {
position: absolute;
top: 15px;
right: 15px;
z-index: 10;
top: 16px;
right: 16px;
z-index: 20;
}
.quality-dropdown {
.video-function-buttons {
display: flex;
align-items: center;
gap: 8px;
}
/* DPlayer原生风格的控制按钮 */
.dplayer-control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
}
.dplayer-control-btn:hover {
background: rgba(0, 0, 0, 0.9);
}
.dplayer-control-btn.active {
background: #007bff;
}
.dplayer-control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* DPlayer风格的清晰度选择器 */
.dplayer-quality-selector {
position: relative;
}
.quality-btn {
.dplayer-quality-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 8px 12px;
min-width: 60px;
height: 36px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
border: none;
border-radius: 4px;
font-size: 12px;
border-radius: 3px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
transition: all 0.2s ease;
justify-content: center;
}
.quality-btn:hover {
background: rgba(0, 0, 0, 0.8);
.dplayer-quality-btn:hover {
background: rgba(0, 0, 0, 0.9);
}
.dropdown-icon {
transition: transform 0.2s;
.quality-dropdown-icon {
transition: transform 0.2s ease;
margin-left: 2px;
}
.quality-btn:hover .dropdown-icon {
.dplayer-quality-btn:hover .quality-dropdown-icon {
transform: rotate(180deg);
}
.quality-menu {
.dplayer-quality-menu {
position: absolute;
top: 100%;
bottom: 100%;
right: 0;
margin-top: 4px;
margin-bottom: 8px;
background: rgba(0, 0, 0, 0.9);
border-radius: 4px;
border-radius: 3px;
overflow: hidden;
min-width: 80px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
}
.quality-option {
.dplayer-quality-option {
padding: 8px 12px;
color: white;
font-size: 12px;
color: #fff;
font-size: 13px;
cursor: pointer;
transition: background-color 0.2s;
transition: background-color 0.2s ease;
text-align: center;
white-space: nowrap;
}
.quality-option:hover {
.dplayer-quality-option:hover {
background: rgba(255, 255, 255, 0.1);
}
.quality-option.active {
background: #1890ff;
.dplayer-quality-option.active {
background: #007bff;
color: #fff;
}
/* 弹幕输入框 */
.danmaku-input-container {
position: absolute;
bottom: 60px;
left: 16px;
right: 16px;
z-index: 15;
}
.danmaku-input-wrapper {
display: flex;
gap: 8px;
background: rgba(0, 0, 0, 0.8);
padding: 8px;
border-radius: 8px;
backdrop-filter: blur(4px);
}
.danmaku-input {
flex: 1;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
font-size: 14px;
outline: none;
transition: all 0.2s;
}
.danmaku-input::placeholder {
color: rgba(255, 255, 255, 0.6);
}
.danmaku-input:focus {
background: rgba(255, 255, 255, 0.15);
border-color: #007bff;
}
.danmaku-send-btn {
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
}
.danmaku-send-btn:hover:not(:disabled) {
background: #0056b3;
}
.danmaku-send-btn:disabled {
background: rgba(255, 255, 255, 0.2);
cursor: not-allowed;
}
/* 响应式设计 */
@media (max-width: 768px) {
.video-container {
height: 400px;
.video-controls-overlay {
top: 12px;
right: 12px;
gap: 8px;
}
.function-btn {
width: 32px;
height: 32px;
}
.danmaku-input-container {
bottom: 50px;
left: 12px;
right: 12px;
}
.danmaku-input-wrapper {
padding: 6px;
}
.danmaku-input {
padding: 6px 10px;
font-size: 13px;
}
.danmaku-send-btn {
padding: 6px 12px;
font-size: 13px;
}
}
@media (max-width: 576px) {
.video-container {
height: 350px;
.video-controls-overlay {
top: 8px;
right: 8px;
gap: 6px;
}
.function-btn {
width: 28px;
height: 28px;
}
.function-btn svg {
width: 14px;
height: 14px;
}
.danmaku-input-container {
bottom: 40px;
left: 8px;
right: 8px;
}
.danmaku-input {
padding: 6px 8px;
font-size: 12px;
}
.danmaku-send-btn {
padding: 6px 10px;
font-size: 12px;
}
}
</style>

View File

@ -6,6 +6,7 @@ import Home from '@/views/Home.vue'
import Courses from '@/views/Courses.vue'
import CourseDetail from '@/views/CourseDetail.vue'
import CourseDetailEnrolled from '@/views/CourseDetailEnrolled.vue'
import CourseExchanged from '@/views/CourseExchanged.vue'
import CourseStudy from '@/views/CourseStudy.vue'
import Learning from '@/views/Learning.vue'
import Profile from '@/views/Profile.vue'
@ -26,6 +27,7 @@ import SpecialTraining from '@/views/SpecialTraining.vue'
import SpecialTrainingDetail from '@/views/SpecialTrainingDetail.vue'
import HelpCenter from '@/views/HelpCenter.vue'
import LearningCenter from '@/views/LearningCenter.vue'
import AICompanion from '@/views/AICompanion.vue'
// ========== 管理员后台组件 ==========
import AdminDashboard from '@/views/teacher/AdminDashboard.vue'
@ -311,6 +313,14 @@ const routes: RouteRecordRaw[] = [
meta: { title: '积分中心', requiresAuth: true }
},
// AI伴学
{
path: '/ai-companion',
name: 'AICompanion',
component: AICompanion,
meta: { title: 'AI伴学' }
},
// 首页与课程
{
path: '/service-agreement',
@ -344,6 +354,12 @@ const routes: RouteRecordRaw[] = [
component: CourseDetailEnrolled,
meta: { title: '课程详情 - 已报名' }
},
{
path: '/course/:id/exchanged',
name: 'CourseExchanged',
component: CourseExchanged,
meta: { title: '课程详情 - 已兑换', requiresAuth: true }
},
{
path: '/course/study/:id',
name: 'CourseStudy',

View File

@ -522,12 +522,12 @@
<button @click="loadCourseSections" class="retry-btn">重试</button>
</div>
<div v-else-if="courseSections.length > 0" class="sections-list">
<!-- 按章节分组显示 -->
<!-- 按章节分组显示 - 未报名状态灰色不可点击 -->
<div v-for="(chapter, chapterIndex) in groupedSections" :key="chapterIndex" class="chapter-section">
<div class="chapter-header" @click="toggleChapter(chapterIndex)">
<div class="chapter-info">
<span class="chapter-number">{{ chapterIndex + 1 }}</span>
<span class="chapter-title">{{ chapter.title }}</span>
<span class="chapter-title">{{ getChapterNumber(chapterIndex + 1) }} {{ chapter.title
}}</span>
</div>
<span class="chapter-toggle" :class="{ 'expanded': chapter.expanded }">
<svg width="12" height="12" viewBox="0 0 12 12">
@ -537,46 +537,39 @@
</div>
<div v-if="chapter.expanded" class="chapter-lessons">
<div v-for="section in chapter.sections" :key="section.id" class="lesson-item">
<div class="lesson-content" :class="{ 'unregistered': !isUserEnrolled }"
@click="isUserEnrolled ? handleSectionClick(section) : handleUnregisteredClick(section)">
<div class="lesson-type-badge"
:class="[getLessonTypeBadgeClass(section), { 'disabled': !isUserEnrolled }]">
<!-- 未报名状态灰色不可点击 -->
<div class="lesson-content unregistered" @click="handleUnregisteredClick(section)">
<div class="lesson-type-badge disabled" :class="getLessonTypeBadgeClass(section)">
{{ getLessonTypeText(section) }}
</div>
<div class="lesson-info">
<span class="lesson-title" :class="{ 'disabled': !isUserEnrolled }">{{ section.name
}}</span>
<span class="lesson-title disabled">{{ section.name }}</span>
</div>
<div class="lesson-meta">
<span v-if="isVideoLesson(section)" class="lesson-duration"
:class="{ 'disabled': !isUserEnrolled }">{{ formatLessonDuration(section) }}</span>
<span v-if="isVideoLesson(section)" class="lesson-duration disabled">{{
formatLessonDuration(section) }}</span>
<div class="lesson-actions">
<!-- 视频播放图标 -->
<button v-if="isVideoLesson(section)" class="lesson-action-btn video-btn"
:class="{ 'disabled': !isUserEnrolled }" :disabled="!isUserEnrolled"
@click.stop="isUserEnrolled ? handleSectionClick(section) : handleUnregisteredClick(section)">
<!-- 调试: 视频课时判断结果 -->
<!-- 视频播放图标 - 不可点击 -->
<button v-if="isVideoLesson(section)" class="lesson-action-btn video-btn disabled" disabled
@click.stop="handleUnregisteredClick(section)">
<img src="/public/images/courses/video-enroll.png" alt="视频" width="14" height="14">
</button>
<!-- 下载图标 -->
<button v-else-if="isResourceLesson(section)" class="lesson-action-btn download-btn"
:class="{ 'disabled': !isUserEnrolled }" :disabled="!isUserEnrolled"
@click.stop="isUserEnrolled ? handleDownload(section) : handleUnregisteredClick(section)">
<img src="/images/courses/download-enroll.png" alt="资料" width="14" height="14">
<!-- 下载图标 - 不可点击 -->
<button v-else-if="isResourceLesson(section)" class="lesson-action-btn download-btn disabled" disabled
@click.stop="handleUnregisteredClick(section)">
<img src="/public/images/courses/download-enroll.png" alt="资料" width="14" height="14">
</button>
<!-- 作业图标 -->
<button v-else-if="isHomeworkLesson(section)" class="lesson-action-btn edit-btn"
:class="{ 'disabled': !isUserEnrolled }" :disabled="!isUserEnrolled"
@click.stop="isUserEnrolled ? handleHomework(section) : handleUnregisteredClick(section)">
<img src="/images/courses/homework-enroll.png" alt="作业" width="14" height="14">
<!-- 编辑图标作业 - 不可点击 -->
<button v-else-if="isHomeworkLesson(section)" class="lesson-action-btn edit-btn disabled" disabled
@click.stop="handleUnregisteredClick(section)">
<img src="/public/images/courses/homework-enroll.png" alt="作业" width="14" height="14">
</button>
<!-- 考试图标 -->
<button v-else-if="isExamLesson(section)" class="lesson-action-btn exam-btn"
:class="{ 'disabled': !isUserEnrolled }" :disabled="!isUserEnrolled"
@click.stop="isUserEnrolled ? handleExam(section) : handleUnregisteredClick(section)">
<img src="/images/courses/examination-enroll.png" alt="考试" width="14" height="14">
<!-- 考试图标 - 不可点击 -->
<button v-else-if="isExamLesson(section)" class="lesson-action-btn exam-btn disabled" disabled
@click.stop="handleUnregisteredClick(section)">
<img src="/public/images/courses/examination-enroll.png" alt="考试" width="14"
height="14">
</button>
</div>
</div>
</div>
@ -714,7 +707,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/stores/user'
@ -728,7 +721,7 @@ import RegisterModal from '@/components/auth/RegisterModal.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const courseId = ref(Number(route.params.id))
const courseId = ref(String(route.query.courseId) || '1')
const { loginModalVisible, registerModalVisible, handleAuthSuccess, showLoginModal } = useAuth()
// enrollCourse 使
@ -837,33 +830,52 @@ const generateMockSections = (): CourseSection[] => {
]
}
//
// -
const groupSectionsByChapter = (sections: CourseSection[]) => {
const chapterTitles = [
'课前准备',
'程序设计基础知识',
'实战项目',
'高级应用',
'拓展学习',
'答疑与交流'
]
console.log('🔍 开始分组章节数据:', sections)
const groups: ChapterGroup[] = []
let sectionsPerChapter = [4, 5, 6, 4, 3, 2] //
let sectionIndex = 0
for (let i = 0; i < chapterTitles.length; i++) {
const chapterSections = sections.slice(sectionIndex, sectionIndex + sectionsPerChapter[i])
if (chapterSections.length > 0) {
// level=1
const parentChapters = sections.filter(section => section.level === 1)
console.log('🔍 找到一级章节:', parentChapters)
// sortOrdersortOrder
parentChapters.sort((a, b) => b.sort - a.sort)
//
parentChapters.forEach((parentChapter, index) => {
// level=2parentId
const childSections = sections.filter(section =>
section.level === 2 && section.parentId === parentChapter.id
)
// sortOrdersortOrder
childSections.sort((a, b) => b.sort - a.sort)
console.log(`🔍 章节"${parentChapter.name}"的子章节:`, childSections)
//
groups.push({
title: `${i + 1}${chapterTitles[i]}`,
sections: chapterSections,
expanded: i === 0 //
title: parentChapter.name, // 使
sections: childSections.length > 0 ? childSections : [parentChapter], //
expanded: index === 0 //
})
})
//
if (groups.length === 0 && sections.length > 0) {
console.log('🔍 没有找到层级结构,将所有章节作为一组')
// sortOrdersortOrder
const sortedSections = [...sections].sort((a, b) => b.sort - a.sort)
groups.push({
title: '课程章节',
sections: sortedSections,
expanded: true
})
}
sectionIndex += sectionsPerChapter[i]
}
console.log('✅ 章节分组完成:', groups)
return groups
}
@ -1089,9 +1101,9 @@ const replyToUsername = ref('')
const loadCourseDetail = async () => {
console.log('开始加载课程详情课程ID:', courseId.value)
if (!courseId.value) courseId.value = 1
if (!courseId.value) courseId.value = '1'
if (!courseId.value || isNaN(courseId.value)) {
if (!courseId.value || isNaN(Number(courseId.value))) {
console.log('课程ID无效使用模拟数据')
loadMockCourseData()
return
@ -1102,7 +1114,7 @@ const loadCourseDetail = async () => {
error.value = ''
console.log('调用API获取课程详情...')
const response = await CourseApi.getCourseById(String(courseId.value))
const response = await CourseApi.getCourseById(courseId.value)
console.log('API响应:', response)
if (response.code === 0 || response.code === 200) {
@ -1145,7 +1157,7 @@ const loadCourseDetail = async () => {
//
const loadCourseSections = async () => {
if (!courseId.value || isNaN(courseId.value)) {
if (!courseId.value || isNaN(Number(courseId.value))) {
console.log('课程ID无效使用模拟章节数据')
loadMockData()
return
@ -1156,27 +1168,32 @@ const loadCourseSections = async () => {
sectionsError.value = ''
console.log('调用API获取课程章节...')
const response = await CourseApi.getCourseSections(String(courseId.value))
const response = await CourseApi.getCourseSections(courseId.value)
console.log('章节API响应:', response)
if (response.code === 0 || response.code === 200) {
if (response.data && Array.isArray(response.data)) {
courseSections.value = response.data
groupedSections.value = groupSectionsByChapter(response.data)
console.log('章节数据设置成功:', courseSections.value)
console.log('分组数据:', groupedSections.value)
if (response.data && response.data.list && Array.isArray(response.data.list)) {
console.log('✅ API返回的原始章节数据:', response.data.list)
console.log('✅ 章节数据数量:', response.data.list.length)
courseSections.value = response.data.list
groupedSections.value = groupSectionsByChapter(response.data.list)
console.log('✅ 设置后的courseSections:', courseSections.value)
console.log('✅ 设置后的groupedSections:', groupedSections.value)
console.log('✅ groupedSections长度:', groupedSections.value.length)
} else {
console.log('API返回的章节数据为空使用模拟数据')
loadMockData()
console.log('❌ API返回的章节数据为空或格式错误')
console.log('❌ response.data:', response.data)
sectionsError.value = '暂无课程章节数据'
}
} else {
console.log('API返回错误,使用模拟数据')
loadMockData()
console.log('API返回错误')
sectionsError.value = response.message || '获取课程章节失败'
}
} catch (err) {
console.error('加载课程章节失败:', err)
console.log('API调用失败使用模拟数据')
loadMockData()
sectionsError.value = '网络错误,请稍后重试'
} finally {
sectionsLoading.value = false
}
@ -1252,6 +1269,12 @@ const loadMockData = () => {
examProgress.value = examSections.length > 0 ? Math.round((examSections.filter(s => s.completed).length / examSections.length) * 100) : 0
}
//
const getChapterNumber = (num: number) => {
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
return numbers[num - 1] || num.toString()
}
// /
const toggleChapter = (chapterIndex: number) => {
console.log('点击切换章节,章节索引:', chapterIndex)
@ -1272,87 +1295,66 @@ const toggleChapter = (chapterIndex: number) => {
//
const getLessonTypeText = (section: CourseSection): string => {
if (section.outline && section.outline.includes('ppt')) {
return '资料'
} else if (section.name.includes('作业') || section.name.includes('练习')) {
return '作业'
} else if (section.name.includes('考试') || section.name.includes('测试')) {
return '考试'
}
return '视频' //
//
const getLessonTypeText = (section: CourseSection) => {
if (isVideoLesson(section)) return '视频'
if (isResourceLesson(section)) return '资料'
if (isHomeworkLesson(section)) return '作业'
if (isExamLesson(section)) return '考试'
return '视频'
}
//
const formatLessonDuration = (section: CourseSection): string => {
//
const durations = [
'01:03:56', '00:44:05', '00:52:22', '', //
'00:52:22', '', '01:03:56', '', '' //
]
// section.id
const durationIndex = Number(section.id) - 1
if (durationIndex >= 0 && durationIndex < durations.length) {
return durations[durationIndex] || ''
}
//
if (isVideoLesson(section)) {
const minutes = Math.floor(Math.random() * 60) + 10 // 10-70
const seconds = Math.floor(Math.random() * 60)
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
return '' //
//
const formatLessonDuration = (section: CourseSection) => {
if (!section.duration) return ''
return section.duration
}
//
const isVideoLesson = (section: CourseSection): boolean => {
if (!section.outline && getLessonTypeText(section) != '考试') {
// - CourseExchanged
const isVideoLesson = (section: CourseSection) => {
console.log('检查章节类型:', section.name, 'type:', section.type, 'outline:', section.outline)
// type0=
if (section.type === 0) {
return true
}
console.log(section.outline)
//
return !!(section.outline && (
section.outline.includes('.m3u8') ||
section.outline.includes('.mp4') ||
section.outline.includes('.avi') ||
section.outline.includes('.mov') ||
section.outline.includes('.wmv')
)) || section.name.includes('视频')
// typenulloutline
return section.outline && (section.outline.includes('.m3u8') || section.outline.includes('.mp4'))
}
//
const isResourceLesson = (section: CourseSection): boolean => {
return !!(section.outline && section.outline.includes('ppt')) || section.name.includes('PPT')
}
//
const isHomeworkLesson = (section: CourseSection): boolean => {
return section.name.includes('作业') || section.name.includes('练习') || section.name.includes('题目') || section.name.includes('分析')
}
//
const isExamLesson = (section: CourseSection): boolean => {
return section.name.includes('考试') || section.name.includes('测试') || section.name.includes('函数&循环')
}
//
const getLessonTypeBadgeClass = (section: CourseSection): string => {
if (isVideoLesson(section)) {
return 'badge-video'
} else if (isResourceLesson(section)) {
return 'badge-resource'
} else if (isHomeworkLesson(section)) {
return 'badge-homework'
} else if (isExamLesson(section)) {
return 'badge-exam'
const isResourceLesson = (section: CourseSection) => {
// type1=
if (section.type === 1) {
return true
}
return 'badge-video' //
// typenulloutline
return section.outline && (section.outline.includes('.pdf') || section.outline.includes('.ppt') || section.outline.includes('.zip'))
}
const isHomeworkLesson = (section: CourseSection) => {
// type3=
if (section.type === 3) {
return true
}
// typenull
return section.name.includes('作业') || section.name.includes('练习')
}
const isExamLesson = (section: CourseSection) => {
// type2=
if (section.type === 2) {
return true
}
// typenull
return section.name.includes('考试') || section.name.includes('测试')
}
//
const getLessonTypeBadgeClass = (section: CourseSection) => {
if (isVideoLesson(section)) return 'badge-video'
if (isResourceLesson(section)) return 'badge-resource'
if (isHomeworkLesson(section)) return 'badge-homework'
if (isExamLesson(section)) return 'badge-exam'
return 'badge-default'
}
//
@ -1582,6 +1584,16 @@ const initializeMockState = () => {
isEnrolled.value = false // false=true=
}
//
watch(() => route.query.courseId, (newCourseId) => {
if (newCourseId && typeof newCourseId === 'string') {
courseId.value = newCourseId
console.log('路由参数变化重新加载课程数据课程ID:', courseId.value)
loadCourseDetail()
loadCourseSections()
}
}, { immediate: false })
onMounted(() => {
console.log('课程详情页加载完成课程ID:', courseId.value)
initializeMockState() //

View File

@ -59,9 +59,23 @@
<div class="video-player-section">
<div class="video-player enrolled">
<div class="video-container">
<!-- CKPlayer 容器 -->
<div v-if="currentVideoUrl" id="ckplayer_container" class="ckplayer-container">
</div>
<!-- DPlayer 播放器 -->
<DPlayerVideo
v-if="currentVideoUrl"
ref="videoPlayerRef"
:video-url="currentVideoUrl"
:poster="course?.coverImage || course?.thumbnail"
:title="currentVideoSection?.name || '课程视频'"
:autoplay="false"
:video-qualities="videoQualities"
:current-quality="currentQuality"
@play="onVideoPlay"
@pause="onVideoPause"
@ended="onVideoEnded"
@error="onVideoError"
@screenshot="onScreenshot"
@danmaku-send="onDanmakuSend"
/>
<div v-else class="video-placeholder"
:style="{ backgroundImage: course?.coverImage || course?.thumbnail ? `url(${course.coverImage || course.thumbnail})` : '' }">
<div class="placeholder-content">
@ -543,12 +557,12 @@
<button @click="loadCourseSections" class="retry-btn">重试</button>
</div>
<div v-else-if="courseSections.length > 0" class="sections-list">
<!-- 按章节分组显示 -->
<!-- 按章节分组显示 - 已报名状态彩色可点击 -->
<div v-for="(chapter, chapterIndex) in groupedSections" :key="chapterIndex" class="chapter-section">
<div class="chapter-header" @click="toggleChapter(chapterIndex)">
<div class="chapter-info">
<span class="chapter-number">{{ chapterIndex + 1 }}</span>
<span class="chapter-title">{{ chapter.title }}</span>
<span class="chapter-title">{{ getChapterNumber(chapterIndex + 1) }} {{ chapter.title
}}</span>
</div>
<span class="chapter-toggle" :class="{ 'expanded': chapter.expanded }">
<svg width="12" height="12" viewBox="0 0 12 12">
@ -558,40 +572,39 @@
</div>
<div v-if="chapter.expanded" class="chapter-lessons">
<div v-for="section in chapter.sections" :key="section.id" class="lesson-item">
<div class="lesson-content" @click="handleSectionClick(section)">
<div class="lesson-type-badge"
:class="[getLessonTypeBadgeClass(section), { 'disabled': !isUserEnrolled }]">
<!-- 已报名状态彩色可点击 -->
<div class="lesson-content enrolled" @click="handleSectionClick(section)">
<div class="lesson-type-badge" :class="getLessonTypeBadgeClass(section)">
{{ getLessonTypeText(section) }}
</div>
<div class="lesson-info">
<span class="lesson-title" :class="{ 'disabled': !isUserEnrolled }">{{ section.name
}}</span>
<span class="lesson-title">{{ section.name }}</span>
</div>
<div class="lesson-meta">
<span v-if="isVideoLesson(section)" class="lesson-duration"
:class="{ 'disabled': !isUserEnrolled }">{{ formatLessonDuration(section) }}</span>
<span v-if="isVideoLesson(section)" class="lesson-duration">{{
formatLessonDuration(section) }}</span>
<div class="lesson-actions">
<!-- 视频播放图标 -->
<!-- 视频播放图标 - 可点击 -->
<button v-if="isVideoLesson(section)" class="lesson-action-btn video-btn"
@click.stop="handleVideoPlay(section)">
<img src="/images/courses/video-enroll.png" alt="视频" width="14" height="14">
<img src="/public/images/courses/video-enroll.png" alt="视频" width="14" height="14">
</button>
<!-- 下载图标 -->
<!-- 下载图标 - 可点击 -->
<button v-else-if="isResourceLesson(section)" class="lesson-action-btn download-btn"
@click.stop="handleDownload(section)">
<img src="/images/courses/download-enroll.png" alt="资料" width="14" height="14">
<img src="/public/images/courses/download-enroll.png" alt="资料" width="14" height="14">
</button>
<!-- 作业图标 -->
<!-- 编辑图标作业 - 可点击 -->
<button v-else-if="isHomeworkLesson(section)" class="lesson-action-btn edit-btn"
@click.stop="handleHomework(section)">
<img src="/images/courses/homework-enroll.png" alt="作业" width="14" height="14">
<img src="/public/images/courses/homework-enroll.png" alt="作业" width="14" height="14">
</button>
<!-- 考试图标 -->
<!-- 考试图标 - 可点击 -->
<button v-else-if="isExamLesson(section)" class="lesson-action-btn exam-btn"
@click.stop="handleExam(section)">
<img src="/images/courses/examination-enroll.png" alt="考试" width="14" height="14">
<img src="/public/images/courses/examination-enroll.png" alt="考试" width="14"
height="14">
</button>
</div>
</div>
</div>
@ -938,13 +951,15 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onActivated, nextTick } from 'vue'
import { ref, computed, onMounted, onActivated } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
// import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/stores/user'
import { CourseApi } from '@/api/modules/course'
import type { Course, CourseSection } from '@/api/types'
import QuillEditor from '@/components/common/QuillEditor.vue'
import DPlayerVideo from '@/components/course/DPlayerVideo.vue'
// import LoginModal from '@/components/auth/LoginModal.vue'
// import RegisterModal from '@/components/auth/RegisterModal.vue'
@ -953,7 +968,8 @@ import QuillEditor from '@/components/common/QuillEditor.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const courseId = ref(Number(route.params.id))
const message = useMessage()
const courseId = ref(String(route.params.id))
// const { loginModalVisible, registerModalVisible, handleAuthSuccess, showLoginModal } = useAuth()
// enrollCourse 使
@ -1032,74 +1048,54 @@ interface ChapterGroup {
const groupedSections = ref<ChapterGroup[]>([])
//
const generateMockSections = (): CourseSection[] => {
return [
// - (4)
{ id: '1', lessonId: String(courseId.value), name: '开课彩蛋:新开始新征程', outline: 'https://example.com/video1.m3u8', type: 0, parentId: '0', sort: 1, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: true, duration: '01:03:56' },
{ id: '2', lessonId: String(courseId.value), name: '课程定位与目标', outline: 'https://example.com/video2.m3u8', type: 0, parentId: '0', sort: 2, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: true, duration: '00:44:05' },
{ id: '3', lessonId: String(courseId.value), name: '教学安排及学习建议', outline: 'https://example.com/video3.m3u8', type: 0, parentId: '0', sort: 3, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: true, duration: '00:52:22' },
{ id: '4', lessonId: String(courseId.value), name: '课前准备PPT', outline: 'https://example.com/ppt1.ppt', type: 1, parentId: '0', sort: 4, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: undefined },
// - (5)
{ id: '5', lessonId: String(courseId.value), name: '第一课 程序设计入门', outline: 'https://example.com/video4.m3u8', type: 0, parentId: '0', sort: 5, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: true, duration: '00:52:22' },
{ id: '6', lessonId: String(courseId.value), name: '操作PPT', outline: 'https://example.com/ppt2.ppt', type: 1, parentId: '0', sort: 6, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: undefined },
{ id: '7', lessonId: String(courseId.value), name: '第二课 循环结构', outline: 'https://example.com/video5.m3u8', type: 0, parentId: '0', sort: 7, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: true, duration: '01:03:56' },
{ id: '8', lessonId: String(courseId.value), name: '函数&循环', outline: 'https://example.com/video5.m3u8', type: 0, parentId: '0', sort: 8, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: undefined },
{ id: '9', lessonId: String(courseId.value), name: '第三课 条件结构', outline: 'https://example.com/video6.m3u8', type: 0, parentId: '0', sort: 9, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: '00:45:30' },
// - (6)
{ id: '10', lessonId: String(courseId.value), name: '项目一:计算器开发', outline: 'https://example.com/video7.m3u8', type: 0, parentId: '0', sort: 10, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: '01:20:15' },
{ id: '11', lessonId: String(courseId.value), name: '项目源码下载', outline: 'https://example.com/source1.zip', type: 1, parentId: '0', sort: 11, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: undefined },
{ id: '12', lessonId: String(courseId.value), name: '项目二:数据管理系统', outline: 'https://example.com/video8.m3u8', type: 0, parentId: '0', sort: 12, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: '01:45:20' },
{ id: '13', lessonId: String(courseId.value), name: '作业:完成个人项目', outline: '', type: 3, parentId: '0', sort: 13, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: undefined },
{ id: '14', lessonId: String(courseId.value), name: '项目三Web应用开发', outline: 'https://example.com/video9.m3u8', type: 0, parentId: '0', sort: 14, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: '02:10:45' },
{ id: '15', lessonId: String(courseId.value), name: '期末考试', outline: '', type: 2, parentId: '0', sort: 15, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: undefined },
// - (4)
{ id: '16', lessonId: String(courseId.value), name: '高级特性介绍', outline: 'https://example.com/video10.m3u8', type: 0, parentId: '0', sort: 16, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: '00:55:30' },
{ id: '17', lessonId: String(courseId.value), name: '性能优化技巧', outline: 'https://example.com/video11.m3u8', type: 0, parentId: '0', sort: 17, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: '01:15:20' },
{ id: '18', lessonId: String(courseId.value), name: '部署与发布', outline: 'https://example.com/video12.m3u8', type: 0, parentId: '0', sort: 18, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: '00:40:15' },
{ id: '19', lessonId: String(courseId.value), name: '课程总结', outline: 'https://example.com/video13.m3u8', type: 0, parentId: '0', sort: 19, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: '00:30:10' },
// - (3)
{ id: '20', lessonId: String(courseId.value), name: '行业发展趋势', outline: 'https://example.com/video14.m3u8', type: 0, parentId: '0', sort: 20, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: '00:35:45' },
{ id: '21', lessonId: String(courseId.value), name: '学习资源推荐', outline: 'https://example.com/resources.pdf', type: 1, parentId: '0', sort: 21, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: undefined },
{ id: '22', lessonId: String(courseId.value), name: '结业证书申请', outline: '', type: 1, parentId: '0', sort: 22, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: undefined },
// - (2)
{ id: '23', lessonId: String(courseId.value), name: '常见问题解答', outline: 'https://example.com/video15.m3u8', type: 0, parentId: '0', sort: 23, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: '00:25:30' },
{ id: '24', lessonId: String(courseId.value), name: '在线答疑直播', outline: 'https://example.com/live1.m3u8', type: 0, parentId: '0', sort: 24, level: 1, revision: 1, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, completed: false, duration: '01:30:00' }
]
}
//
// -
const groupSectionsByChapter = (sections: CourseSection[]) => {
const chapterTitles = [
'课前准备',
'程序设计基础知识',
'实战项目',
'高级应用',
'拓展学习',
'答疑与交流'
]
console.log('🔍 开始分组章节数据:', sections)
const groups: ChapterGroup[] = []
let sectionsPerChapter = [4, 5, 6, 4, 3, 2] //
let sectionIndex = 0
for (let i = 0; i < chapterTitles.length; i++) {
const chapterSections = sections.slice(sectionIndex, sectionIndex + sectionsPerChapter[i])
if (chapterSections.length > 0) {
// level=1
const parentChapters = sections.filter(section => section.level === 1)
console.log('🔍 找到一级章节:', parentChapters)
// sortOrdersortOrder
parentChapters.sort((a, b) => b.sort - a.sort)
//
parentChapters.forEach((parentChapter, index) => {
// level=2parentId
const childSections = sections.filter(section =>
section.level === 2 && section.parentId === parentChapter.id
)
// sortOrdersortOrder
childSections.sort((a, b) => b.sort - a.sort)
console.log(`🔍 章节"${parentChapter.name}"的子章节:`, childSections)
//
groups.push({
title: `${i + 1}${chapterTitles[i]}`,
sections: chapterSections,
expanded: i === 0 //
title: parentChapter.name, // 使
sections: childSections.length > 0 ? childSections : [parentChapter], //
expanded: index === 0 //
})
})
//
if (groups.length === 0 && sections.length > 0) {
console.log('🔍 没有找到层级结构,将所有章节作为一组')
// sortOrdersortOrder
const sortedSections = [...sections].sort((a, b) => b.sort - a.sort)
groups.push({
title: '课程章节',
sections: sortedSections,
expanded: true
})
}
sectionIndex += sectionsPerChapter[i]
}
console.log('✅ 章节分组完成:', groups)
return groups
}
@ -1159,7 +1155,11 @@ const getVideoUrl = (section?: CourseSection) => {
//
const currentVideoUrl = ref<string>('')
const currentVideoSection = ref<CourseSection | null>(null)
const ckplayer = ref<any>(null)
const currentVideo = ref<any>(null)
const videoQualities = ref<any[]>([])
const currentQuality = ref<string>('360')
const videoLoading = ref<boolean>(false)
const videoPlayerRef = ref<any>(null)
//
const aiActiveTab = ref('assistant')
@ -1351,18 +1351,8 @@ const replyToUsername = ref('')
const handleVideoPlay = async (section: CourseSection) => {
console.log('播放视频:', section.name)
// URL
const videoUrl = getVideoUrl(section)
currentVideoUrl.value = videoUrl
currentVideoSection.value = section
console.log('使用视频源:', videoUrl)
// DOM
await nextTick()
// CKPlayer
initCKPlayer(videoUrl)
//
await loadSectionVideo(section)
//
if (!section.completed) {
@ -1381,134 +1371,99 @@ const handleVideoPlay = async (section: CourseSection) => {
}
}
// CKPlayer
const initCKPlayer = (url: string) => {
//
if (ckplayer.value) {
//
const loadSectionVideo = async (section: CourseSection) => {
try {
ckplayer.value.remove()
} catch (e) {
console.log('清理播放器实例时出错:', e)
}
ckplayer.value = null
}
videoLoading.value = true
console.log('🔍 加载章节视频章节ID:', section.id)
// CKPlayer
if (typeof window.ckplayer === 'undefined') {
console.error('CKPlayer not loaded')
return
const response = await CourseApi.getSectionVideos(courseId.value, section.id)
console.log('🔍 视频API响应:', response)
if (response.code === 0 || response.code === 200) {
if (response.data && response.data.length > 0) {
const video = response.data[0] //
currentVideo.value = video
videoQualities.value = video.qualities
currentQuality.value = video.defaultQuality
//
videoQualities.value = video.qualities || []
currentQuality.value = video.defaultQuality || '360'
// URL
const defaultQualityVideo = video.qualities.find((q: any) => q.value === video.defaultQuality)
if (defaultQualityVideo) {
currentVideoUrl.value = defaultQualityVideo.url
currentVideoSection.value = section
console.log('✅ 设置视频URL:', currentVideoUrl.value)
console.log('✅ 可用清晰度:', video.qualities)
console.log('✅ 传递给DPlayer的清晰度:', videoQualities.value)
}
// ""
const containerEl = document.querySelector('#ckplayer_container') as HTMLElement | null
if (!containerEl) {
console.warn('Player container not found, retrying init...')
setTimeout(() => initCKPlayer(url), 50)
return
} else {
console.warn('⚠️ 没有找到视频数据')
// 使
currentVideoUrl.value = 'https://example.com/default-video.mp4'
currentVideoSection.value = section
}
//
const isMP4 = url.endsWith('.mp4')
const isHLS = url.endsWith('.m3u8')
// CKPlayer
const videoObject = {
container: '#ckplayer_container', // ID
autoplay: false, //
video: url, //
volume: 0.8, //
poster: course.value?.coverImage || course.value?.thumbnail || '', //
live: false, //
//
plug: isHLS ? 'hls.js' : '', // HLS使hls.jsMP4
playbackrateOpen: true, //
playbackrateList: [0.5, 0.75, 1, 1.25, 1.5, 2], //
seek: 0, //
loaded: 'loadedHandler', //
ended: 'endedHandler', //
error: 'errorHandler', //
title: currentVideoSection.value?.name || '课程视频', //
controls: true, //
webFull: true, //
screenshot: true, //
timeScheduleAdjust: 1, //
// MP4
...(isMP4 && {
type: 'mp4', //
crossOrigin: 'anonymous' //
})
} else {
console.error('❌ 获取视频失败:', response.message)
// 使
currentVideoUrl.value = 'https://example.com/default-video.mp4'
currentVideoSection.value = section
}
try {
//
ckplayer.value = new window.ckplayer(videoObject)
console.log('CKPlayer initialized successfully for:', isMP4 ? 'MP4' : 'HLS')
} catch (error) {
console.error('Failed to initialize CKPlayer:', error)
console.error('❌ 加载章节视频失败:', error)
// 使
currentVideoUrl.value = 'https://example.com/default-video.mp4'
currentVideoSection.value = section
} finally {
videoLoading.value = false
}
}
//
const loadMockCourseData = () => {
console.log('加载模拟课程数据')
course.value = {
id: String(courseId.value || 1),
title: 'DeepSeek办公自动化职业岗位标准课程',
description: '本课程将帮助您掌握DeepSeek的基本使用方法了解办公自动化职业岗位标准提高教学质量和效率获得实际工作技能。',
instructor: {
id: 1,
name: 'DeepSeek技术学院',
title: '讲师',
bio: '专注于AI技术应用与教学',
avatar: '/images/aiCompanion/AI小助手@2x.png',
rating: 4.8,
studentsCount: 1000,
coursesCount: 10,
experience: '5年教学经验',
education: ['计算机科学硕士'],
certifications: ['高级讲师认证']
},
duration: '12小时43分钟',
totalLessons: 54,
rating: 4.8,
studentsCount: 1000,
price: 0,
originalPrice: 299,
currency: 'CNY',
level: 'beginner',
language: 'zh-CN',
category: {
id: 1,
name: 'AI技术',
slug: 'ai-technology',
description: 'AI技术相关课程'
},
tags: ['AI', '办公自动化', 'DeepSeek'],
skills: ['DeepSeek', '办公自动化'],
requirements: ['基础计算机操作能力', '对AI技术感兴趣'],
objectives: ['掌握DeepSeek的基本使用方法', '了解办公自动化职业岗位标准', '提高教学质量和效率'],
thumbnail: '/images/aiCompanion/bg.png',
coverImage: '/images/aiCompanion/bg.png',
content: '课程内容详细介绍...',
status: 'published',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
ratingCount: 0,
isEnrolled: false,
progress: 0,
isFavorite: false
}
// DPlayer
const onVideoPlay = () => {
console.log('视频开始播放')
}
const onVideoPause = () => {
console.log('视频暂停')
}
const onVideoEnded = () => {
console.log('视频播放结束')
}
const onVideoError = (error: Event) => {
console.error('视频播放出错:', error)
}
//
const onScreenshot = (dataUrl: string) => {
console.log('截屏成功:', dataUrl)
//
message.success('截屏成功!')
}
const onDanmakuSend = (text: string) => {
console.log('发送弹幕:', text)
//
}
//
const loadCourseDetail = async () => {
console.log('开始加载课程详情课程ID:', courseId.value)
if (!courseId.value) courseId.value = 1
if (!courseId.value) {
courseId.value = '1'
}
if (!courseId.value || isNaN(courseId.value)) {
console.log('课程ID无效使用模拟数据')
loadMockCourseData()
if (!courseId.value || isNaN(Number(courseId.value))) {
console.log('课程ID无效')
error.value = '课程ID无效'
return
}
@ -1516,8 +1471,8 @@ const loadCourseDetail = async () => {
loading.value = true
error.value = ''
console.log('调用API获取课程详情...')
const response = await CourseApi.getCourseById(String(courseId.value))
console.log('调用API获取课程详情课程ID:', courseId.value)
const response = await CourseApi.getCourseById(courseId.value)
console.log('API响应:', response)
if (response.code === 0 || response.code === 200) {
@ -1546,13 +1501,12 @@ const loadCourseDetail = async () => {
}
}
} else {
console.log('API返回错误,使用模拟数据')
loadMockCourseData()
console.log('API返回错误')
error.value = response.message || '获取课程详情失败'
}
} catch (err) {
console.error('加载课程详情失败:', err)
console.log('API调用失败使用模拟数据')
loadMockCourseData()
error.value = '网络错误,请稍后重试'
} finally {
loading.value = false
}
@ -1560,7 +1514,7 @@ const loadCourseDetail = async () => {
//
const loadCourseSections = async () => {
if (!courseId.value || isNaN(courseId.value)) {
if (!courseId.value || isNaN(Number(courseId.value))) {
sectionsError.value = '课程ID无效'
console.error('课程ID无效:', courseId.value)
return
@ -1571,52 +1525,38 @@ const loadCourseSections = async () => {
sectionsError.value = ''
console.log('调用API获取课程章节...')
const response = await CourseApi.getCourseSections(String(courseId.value))
const response = await CourseApi.getCourseSections(courseId.value)
console.log('章节API响应:', response)
if (response.code === 0 || response.code === 200) {
if (response.data && Array.isArray(response.data)) {
courseSections.value = response.data
groupedSections.value = groupSectionsByChapter(response.data)
console.log('章节数据设置成功:', courseSections.value)
console.log('分组数据:', groupedSections.value)
if (response.data && response.data.list && Array.isArray(response.data.list)) {
console.log('✅ API返回的原始章节数据:', response.data.list)
console.log('✅ 章节数据数量:', response.data.list.length)
courseSections.value = response.data.list
groupedSections.value = groupSectionsByChapter(response.data.list)
console.log('✅ 设置后的courseSections:', courseSections.value)
console.log('✅ 设置后的groupedSections:', groupedSections.value)
console.log('✅ groupedSections长度:', groupedSections.value.length)
} else {
console.log('API返回的章节数据为空使用模拟数据')
loadMockData()
console.log('❌ API返回的章节数据为空或格式错误')
console.log('❌ response.data:', response.data)
sectionsError.value = '暂无课程章节数据'
}
} else {
console.log('API返回错误,使用模拟数据')
loadMockData()
console.log('API返回错误')
sectionsError.value = response.message || '获取课程章节失败'
}
} catch (err) {
console.error('加载课程章节失败:', err)
console.log('API调用失败使用模拟数据')
loadMockData()
sectionsError.value = '网络错误,请稍后重试'
} finally {
sectionsLoading.value = false
}
}
//
const loadMockData = () => {
console.log('加载模拟章节数据')
const mockSections = generateMockSections()
courseSections.value = mockSections
groupedSections.value = groupSectionsByChapter(mockSections)
//
const completed = mockSections.filter(section => section.completed).length
completedLessons.value = completed
//
const videoSections = mockSections.filter(section => isVideoLesson(section))
const exerciseSections = mockSections.filter(section => isHomeworkLesson(section))
const examSections = mockSections.filter(section => isExamLesson(section))
videoProgress.value = videoSections.length > 0 ? Math.round((videoSections.filter(s => s.completed).length / videoSections.length) * 100) : 0
exerciseProgress.value = exerciseSections.length > 0 ? Math.round((exerciseSections.filter(s => s.completed).length / exerciseSections.length) * 100) : 0
examProgress.value = examSections.length > 0 ? Math.round((examSections.filter(s => s.completed).length / examSections.length) * 100) : 0
}
// /
const toggleChapter = (chapterIndex: number) => {
@ -1638,87 +1578,72 @@ const toggleChapter = (chapterIndex: number) => {
//
const getLessonTypeText = (section: CourseSection): string => {
if (section.outline && section.outline.includes('ppt')) {
return '资料'
} else if (section.name.includes('作业') || section.name.includes('练习')) {
return '作业'
} else if (section.name.includes('考试') || section.name.includes('测试')) {
return '考试'
}
return '视频' //
//
const getLessonTypeText = (section: CourseSection) => {
if (isVideoLesson(section)) return '视频'
if (isResourceLesson(section)) return '资料'
if (isHomeworkLesson(section)) return '作业'
if (isExamLesson(section)) return '考试'
return '视频'
}
//
const formatLessonDuration = (section: CourseSection): string => {
//
const durations = [
'01:03:56', '00:44:05', '00:52:22', '', //
'00:52:22', '', '01:03:56', '', '' //
]
// section.id
const durationIndex = Number(section.id) - 1
if (durationIndex >= 0 && durationIndex < durations.length) {
return durations[durationIndex] || ''
}
//
if (isVideoLesson(section)) {
const minutes = Math.floor(Math.random() * 60) + 10 // 10-70
const seconds = Math.floor(Math.random() * 60)
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
return '' //
//
const getChapterNumber = (num: number) => {
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
return numbers[num - 1] || num.toString()
}
//
const isVideoLesson = (section: CourseSection): boolean => {
//
const formatLessonDuration = (section: CourseSection) => {
if (!section.duration) return ''
return section.duration
}
if (!section.outline && getLessonTypeText(section) != '考试') {
// - CourseDetailEnrolled
const isVideoLesson = (section: CourseSection) => {
console.log('检查章节类型:', section.name, 'type:', section.type, 'outline:', section.outline)
// type0=
if (section.type === 0) {
return true
}
console.log(section.outline)
//
return !!(section.outline && (
section.outline.includes('.m3u8') ||
section.outline.includes('.mp4') ||
section.outline.includes('.avi') ||
section.outline.includes('.mov') ||
section.outline.includes('.wmv')
)) || section.name.includes('视频')
// typenulloutline
return section.outline && (section.outline.includes('.m3u8') || section.outline.includes('.mp4'))
}
//
const isResourceLesson = (section: CourseSection): boolean => {
return !!(section.outline && section.outline.includes('ppt')) || section.name.includes('PPT')
}
//
const isHomeworkLesson = (section: CourseSection): boolean => {
return section.name.includes('作业') || section.name.includes('练习') || section.name.includes('题目') || section.name.includes('分析')
}
//
const isExamLesson = (section: CourseSection): boolean => {
return section.name.includes('考试') || section.name.includes('测试') || section.name.includes('函数&循环')
}
//
const getLessonTypeBadgeClass = (section: CourseSection): string => {
if (isVideoLesson(section)) {
return 'badge-video'
} else if (isResourceLesson(section)) {
return 'badge-resource'
} else if (isHomeworkLesson(section)) {
return 'badge-homework'
} else if (isExamLesson(section)) {
return 'badge-exam'
const isResourceLesson = (section: CourseSection) => {
// type1=
if (section.type === 1) {
return true
}
return 'badge-video' //
// typenulloutline
return section.outline && (section.outline.includes('.pdf') || section.outline.includes('.ppt') || section.outline.includes('.zip'))
}
const isHomeworkLesson = (section: CourseSection) => {
// type3=
if (section.type === 3) {
return true
}
// typenull
return section.name.includes('作业') || section.name.includes('练习')
}
const isExamLesson = (section: CourseSection) => {
// type2=
if (section.type === 2) {
return true
}
// typenull
return section.name.includes('考试') || section.name.includes('测试')
}
//
const getLessonTypeBadgeClass = (section: CourseSection) => {
if (isVideoLesson(section)) return 'badge-video'
if (isResourceLesson(section)) return 'badge-resource'
if (isHomeworkLesson(section)) return 'badge-homework'
if (isExamLesson(section)) return 'badge-exam'
return 'badge-default'
}
//
@ -1766,25 +1691,41 @@ const handleExam = (section: CourseSection) => {
//
// -
const handleSectionClick = (section: CourseSection) => {
console.log('点击课程章节:', section)
//
console.log('🔍 点击课程章节:', section.name, section)
currentSection.value = section
//
if (isVideoLesson(section)) {
handleVideoPlay(section)
} else if (isResourceLesson(section)) {
//
const isVideo = isVideoLesson(section)
const isResource = isResourceLesson(section)
const isHomework = isHomeworkLesson(section)
const isExam = isExamLesson(section)
console.log('🔍 章节类型判断结果:', {
isVideo,
isResource,
isHomework,
isExam,
type: section.type
})
//
if (isVideo) {
console.log('✅ 识别为视频课程,开始加载视频数据')
loadSectionVideo(section)
} else if (isResource) {
console.log('✅ 识别为资料课程')
handleDownload(section)
} else if (isHomeworkLesson(section)) {
} else if (isHomework) {
console.log('✅ 识别为作业课程')
handleHomework(section)
} else if (isExamLesson(section)) {
} else if (isExam) {
console.log('✅ 识别为考试课程')
handleExam(section)
} else {
//
previewSection(section)
console.log('⚠️ 未识别的课程类型,默认当作视频处理')
loadSectionVideo(section)
}
}
@ -2023,30 +1964,10 @@ const deleteNote = (index: number) => {
// }
// }
//
const initializeMockState = () => {
//
if (!userStore.isLoggedIn) {
userStore.user = {
id: 1,
username: 'testuser',
email: 'test@example.com',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&auto=format&fit=crop&w=50&q=80',
role: 'student',
status: 'active',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
userStore.token = 'mock-token'
}
//
isEnrolled.value = false // false=true=
}
onMounted(() => {
console.log('课程详情页加载完成课程ID:', courseId.value)
initializeMockState() //
loadCourseDetail()
loadCourseSections()
@ -2434,8 +2355,10 @@ onActivated(() => {
.video-player-section {
position: relative;
background: #fff;
overflow: hidden;
overflow: visible; /* 改为visible确保底部交互区域不被裁剪 */
margin-bottom: 20px;
display: flex;
flex-direction: column;
}
.video-player.unregistered {
@ -2445,21 +2368,26 @@ onActivated(() => {
.video-player.enrolled {
background: #000;
min-height: 400px;
/* 移除固定高度,让内容自适应 */
}
.video-container {
position: relative;
width: 100%;
height: 400px;
height: 450px; /* 使用固定高度,确保播放器能正常工作 */
background: #000;
}
.ckplayer-container {
width: 100%;
height: 100%;
/* DPlayer 容器样式 */
.video-container :deep(.dplayer) {
width: 100% !important;
height: 100% !important;
}
.video-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
@ -2654,6 +2582,9 @@ onActivated(() => {
background: #ffffff;
border-top: 1px solid #e5e7eb;
min-height: 60px;
flex-shrink: 0; /* 防止被压缩 */
position: relative;
z-index: 10; /* 确保在最上层 */
}
.interaction-left {
@ -5036,7 +4967,7 @@ onActivated(() => {
}
.video-player-section {
height: 400px;
/* 移除固定高度,让内容自适应 */
}
}

View File

@ -425,11 +425,8 @@ const goToCourseDetail = async (course: Course) => {
try {
//
if (!userStore.isLoggedIn) {
console.log('用户未登录,跳转到课程详情页')
router.push({
name: 'CourseDetail',
params: { id: course.id }
})
console.log('用户未登录跳转到AI伴学页面')
router.push(`/ai-companion?courseId=${course.id}`)
return
}
@ -442,29 +439,23 @@ const goToCourseDetail = async (course: Course) => {
const isEnrolled = response.data.result
if (isEnrolled) {
//
console.log('用户已报名,跳转到已报名页面')
router.push(`/course/${course.id}/enrolled`)
//
console.log('用户已报名,跳转到已兑换页面')
router.push(`/course/${course.id}/exchanged`)
} else {
//
console.log('用户未报名,跳转到课程详情页')
router.push(`/course/${course.id}`)
// AI
console.log('用户未报名,跳转到AI伴学页面')
router.push(`/ai-companion?courseId=${course.id}`)
}
} else {
//
console.warn('查询报名状态失败,跳转到课程详情页')
router.push({
name: 'CourseDetail',
params: { id: course.id }
})
// AI
console.warn('查询报名状态失败跳转到AI伴学页面')
router.push(`/ai-companion?courseId=${course.id}`)
}
} catch (error) {
console.error('检查报名状态时发生错误:', error)
//
router.push({
name: 'CourseDetail',
params: { id: course.id }
})
// AI
router.push(`/ai-companion?courseId=${course.id}`)
}
}

View File

@ -563,8 +563,8 @@ const goToCourseDetail = async (courseId: string) => {
try {
//
if (!userStore.isLoggedIn) {
console.log('用户未登录,跳转到课程详情页')
router.push(`/course/${courseId}`)
console.log('用户未登录,跳转到AI伴学页面')
router.push(`/ai-companion?courseId=${courseId}`)
return
}
@ -577,23 +577,23 @@ const goToCourseDetail = async (courseId: string) => {
const isEnrolled = response.data.result
if (isEnrolled) {
//
console.log('用户已报名,跳转到已报名页面')
router.push(`/course/${courseId}/enrolled`)
//
console.log('用户已报名,跳转到已兑换页面')
router.push(`/course/${courseId}/exchanged`)
} else {
//
console.log('用户未报名,跳转到课程详情页')
router.push(`/course/${courseId}`)
// AI
console.log('用户未报名,跳转到AI伴学页面')
router.push(`/ai-companion?courseId=${courseId}`)
}
} else {
//
console.warn('查询报名状态失败,跳转到课程详情页')
router.push(`/course/${courseId}`)
// AI
console.warn('查询报名状态失败,跳转到AI伴学页面')
router.push(`/ai-companion?courseId=${courseId}`)
}
} catch (error) {
console.error('检查报名状态时发生错误:', error)
//
router.push(`/course/${courseId}`)
// AI
router.push(`/ai-companion?courseId=${courseId}`)
}
}

View File

@ -563,11 +563,8 @@ const goToCourseDetail = async (course: Course) => {
try {
//
if (!userStore.isLoggedIn) {
console.log('用户未登录,跳转到课程详情页')
router.push({
name: 'CourseDetail',
params: { id: course.id }
})
console.log('用户未登录跳转到AI伴学页面')
router.push(`/ai-companion?courseId=${course.id}`)
return
}
@ -580,29 +577,23 @@ const goToCourseDetail = async (course: Course) => {
const isEnrolled = response.data.result
if (isEnrolled) {
//
console.log('用户已报名,跳转到已报名页面')
router.push(`/course/${course.id}/enrolled`)
//
console.log('用户已报名,跳转到已兑换页面')
router.push(`/course/${course.id}/exchanged`)
} else {
//
console.log('用户未报名,跳转到课程详情页')
router.push(`/course/${course.id}`)
// AI
console.log('用户未报名,跳转到AI伴学页面')
router.push(`/ai-companion?courseId=${course.id}`)
}
} else {
//
console.warn('查询报名状态失败,跳转到课程详情页')
router.push({
name: 'CourseDetail',
params: { id: course.id }
})
// AI
console.warn('查询报名状态失败跳转到AI伴学页面')
router.push(`/ai-companion?courseId=${course.id}`)
}
} catch (error) {
console.error('检查报名状态时发生错误:', error)
//
router.push({
name: 'CourseDetail',
params: { id: course.id }
})
// AI
router.push(`/ai-companion?courseId=${course.id}`)
}
}