Merge branch 'dev' of http://110.42.96.65:19890/GoCo/OL-LearnPlatform-Frontend into dev
This commit is contained in:
commit
788df9a92c
@ -16,24 +16,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 清晰度选择器 -->
|
||||
<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">
|
||||
<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"
|
||||
:class="{ active: quality.value === currentQuality }"
|
||||
@click="changeVideoQuality(quality.value); showQualityMenu = false">
|
||||
{{ quality.label }}
|
||||
<!-- 视频控制工具栏 -->
|
||||
<div v-if="playerInitialized" class="video-controls-overlay">
|
||||
<!-- 功能按钮组 -->
|
||||
<div class="video-function-buttons">
|
||||
<!-- 清晰度选择器 -->
|
||||
<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="dplayer-quality-menu">
|
||||
<div v-for="quality in videoQualities" :key="quality.value"
|
||||
class="dplayer-quality-option"
|
||||
:class="{ active: quality.value === currentQuality }"
|
||||
@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
|
||||
})
|
||||
|
||||
// 使用自定义清晰度选择器,不依赖DPlayer原生quality功能
|
||||
console.log('✅ 使用自定义清晰度选择器:', {
|
||||
mainVideoUrl: url,
|
||||
availableQualities: props.videoQualities?.length || 0
|
||||
})
|
||||
|
||||
player = new DPlayer(dplayerConfig)
|
||||
|
||||
// 检查DPlayer是否正确加载了quality配置
|
||||
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>
|
||||
|
@ -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'
|
||||
@ -324,6 +326,14 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { title: '积分中心', requiresAuth: true }
|
||||
},
|
||||
|
||||
// AI伴学
|
||||
{
|
||||
path: '/ai-companion',
|
||||
name: 'AICompanion',
|
||||
component: AICompanion,
|
||||
meta: { title: 'AI伴学' }
|
||||
},
|
||||
|
||||
// 首页与课程
|
||||
{
|
||||
path: '/service-agreement',
|
||||
@ -357,6 +367,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',
|
||||
|
@ -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,12 +721,12 @@ 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 暂时未使用,后续需要时再启用
|
||||
|
||||
// 当前选中的章节
|
||||
const currentSection = ref<CourseSection | null>(null)
|
||||
// const currentSection = ref<CourseSection | null>(null)
|
||||
|
||||
// 课程数据相关状态
|
||||
const course = ref<Course | null>(null)
|
||||
@ -774,14 +767,14 @@ const examProgress = ref(0)
|
||||
// }
|
||||
|
||||
// 计算用户是否已报名
|
||||
const isUserEnrolled = computed(() => {
|
||||
// 必须同时满足:用户已登录 AND 已报名该课程
|
||||
return userStore.isLoggedIn && isEnrolled.value
|
||||
// const isUserEnrolled = computed(() => {
|
||||
// // 必须同时满足:用户已登录 AND 已报名该课程
|
||||
// return userStore.isLoggedIn && isEnrolled.value
|
||||
|
||||
// 临时测试不同状态:
|
||||
// return false // 强制显示未报名状态(灰色不可点击)
|
||||
// return true // 强制显示已报名状态(彩色可点击)
|
||||
})
|
||||
// // 临时测试不同状态:
|
||||
// // return false // 强制显示未报名状态(灰色不可点击)
|
||||
// // return true // 强制显示已报名状态(彩色可点击)
|
||||
// })
|
||||
|
||||
// 报名确认弹窗
|
||||
const enrollConfirmVisible = ref(false)
|
||||
@ -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) {
|
||||
groups.push({
|
||||
title: `第${i + 1}章 ${chapterTitles[i]}`,
|
||||
sections: chapterSections,
|
||||
expanded: i === 0 // 默认展开第一章
|
||||
})
|
||||
}
|
||||
sectionIndex += sectionsPerChapter[i]
|
||||
// 找出所有一级章节(level=1,这些是父章节)
|
||||
const parentChapters = sections.filter(section => section.level === 1)
|
||||
console.log('🔍 找到一级章节:', parentChapters)
|
||||
|
||||
// 按sortOrder降序排序一级章节(sortOrder越大越靠前)
|
||||
parentChapters.sort((a, b) => b.sort - a.sort)
|
||||
|
||||
// 为每个一级章节创建分组
|
||||
parentChapters.forEach((parentChapter, index) => {
|
||||
// 找出该章节下的所有子章节(level=2,parentId匹配)
|
||||
const childSections = sections.filter(section =>
|
||||
section.level === 2 && section.parentId === parentChapter.id
|
||||
)
|
||||
|
||||
// 按sortOrder降序排序子章节(sortOrder越大越靠前)
|
||||
childSections.sort((a, b) => b.sort - a.sort)
|
||||
|
||||
console.log(`🔍 章节"${parentChapter.name}"的子章节:`, childSections)
|
||||
|
||||
// 创建章节分组
|
||||
groups.push({
|
||||
title: parentChapter.name, // 使用后端返回的章节名称
|
||||
sections: childSections.length > 0 ? childSections : [parentChapter], // 如果有子章节就用子章节,否则用父章节本身
|
||||
expanded: index === 0 // 默认展开第一章
|
||||
})
|
||||
})
|
||||
|
||||
// 如果没有找到一级章节,可能所有章节都是同级的,直接作为一个组
|
||||
if (groups.length === 0 && sections.length > 0) {
|
||||
console.log('🔍 没有找到层级结构,将所有章节作为一组')
|
||||
// 按sortOrder降序排序(sortOrder越大越靠前)
|
||||
const sortedSections = [...sections].sort((a, b) => b.sort - a.sort)
|
||||
groups.push({
|
||||
title: '课程章节',
|
||||
sections: sortedSections,
|
||||
expanded: true
|
||||
})
|
||||
}
|
||||
|
||||
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,170 +1295,149 @@ 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)
|
||||
// 优先根据type字段判断:0=视频
|
||||
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('视频')
|
||||
// 如果type为null,则根据outline判断
|
||||
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) => {
|
||||
// 优先根据type字段判断:1=资料
|
||||
if (section.type === 1) {
|
||||
return true
|
||||
}
|
||||
return 'badge-video' // 默认为视频
|
||||
// 如果type为null,则根据outline或名称判断
|
||||
return section.outline && (section.outline.includes('.pdf') || section.outline.includes('.ppt') || section.outline.includes('.zip'))
|
||||
}
|
||||
|
||||
const isHomeworkLesson = (section: CourseSection) => {
|
||||
// 优先根据type字段判断:3=作业
|
||||
if (section.type === 3) {
|
||||
return true
|
||||
}
|
||||
// 如果type为null,则根据名称判断
|
||||
return section.name.includes('作业') || section.name.includes('练习')
|
||||
}
|
||||
|
||||
const isExamLesson = (section: CourseSection) => {
|
||||
// 优先根据type字段判断:2=考试
|
||||
if (section.type === 2) {
|
||||
return true
|
||||
}
|
||||
// 如果type为null,则根据名称判断
|
||||
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'
|
||||
}
|
||||
|
||||
// 处理下载操作
|
||||
const handleDownload = (section: CourseSection) => {
|
||||
console.log('下载资料:', section)
|
||||
// 这里可以实现下载逻辑
|
||||
alert(`下载资料: ${section.name}`)
|
||||
}
|
||||
// const handleDownload = (section: CourseSection) => {
|
||||
// console.log('下载资料:', section)
|
||||
// // 这里可以实现下载逻辑
|
||||
// alert(`下载资料: ${section.name}`)
|
||||
// }
|
||||
|
||||
// 处理作业操作
|
||||
const handleHomework = (section: CourseSection) => {
|
||||
console.log('打开作业:', section)
|
||||
// const handleHomework = (section: CourseSection) => {
|
||||
// console.log('打开作业:', section)
|
||||
|
||||
// 跳转到练习页面
|
||||
router.push({
|
||||
name: 'Practice',
|
||||
params: {
|
||||
courseId: courseId.value,
|
||||
sectionId: section.id
|
||||
},
|
||||
query: {
|
||||
courseName: course.value?.title || '课程名称',
|
||||
practiceName: section.name
|
||||
}
|
||||
})
|
||||
}
|
||||
// // 跳转到练习页面
|
||||
// router.push({
|
||||
// name: 'Practice',
|
||||
// params: {
|
||||
// courseId: courseId.value,
|
||||
// sectionId: section.id
|
||||
// },
|
||||
// query: {
|
||||
// courseName: course.value?.title || '课程名称',
|
||||
// practiceName: section.name
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
// 处理考试操作
|
||||
const handleExam = (section: CourseSection) => {
|
||||
console.log('开始考试:', section)
|
||||
// const handleExam = (section: CourseSection) => {
|
||||
// console.log('开始考试:', section)
|
||||
|
||||
// 跳转到考前须知页面
|
||||
router.push({
|
||||
name: 'ExamNotice',
|
||||
params: {
|
||||
courseId: courseId.value,
|
||||
sectionId: section.id
|
||||
},
|
||||
query: {
|
||||
courseName: course.value?.title || '课程名称',
|
||||
examName: section.name
|
||||
}
|
||||
})
|
||||
}
|
||||
// // 跳转到考前须知页面
|
||||
// router.push({
|
||||
// name: 'ExamNotice',
|
||||
// params: {
|
||||
// courseId: courseId.value,
|
||||
// sectionId: section.id
|
||||
// },
|
||||
// query: {
|
||||
// courseName: course.value?.title || '课程名称',
|
||||
// examName: section.name
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
|
||||
|
||||
// 点击课程章节标题
|
||||
const handleSectionClick = (section: CourseSection) => {
|
||||
console.log('点击课程章节:', section)
|
||||
// const handleSectionClick = (section: CourseSection) => {
|
||||
// console.log('点击课程章节:', section)
|
||||
|
||||
// 设置当前选中的章节
|
||||
currentSection.value = section
|
||||
// // 设置当前选中的章节
|
||||
// currentSection.value = section
|
||||
|
||||
// 检查是否有视频链接
|
||||
if (section.outline && section.outline.includes('.m3u8')) {
|
||||
console.log('获取到视频链接:', section.outline)
|
||||
// // 检查是否有视频链接
|
||||
// if (section.outline && section.outline.includes('.m3u8')) {
|
||||
// console.log('获取到视频链接:', section.outline)
|
||||
|
||||
// 跳转到已报名区域并播放视频
|
||||
navigateToEnrolledArea(section.outline, section.name)
|
||||
} else {
|
||||
// 如果不是视频,显示预览
|
||||
previewSection(section)
|
||||
}
|
||||
}
|
||||
// // 跳转到已报名区域并播放视频
|
||||
// navigateToEnrolledArea(section.outline, section.name)
|
||||
// } else {
|
||||
// // 如果不是视频,显示预览
|
||||
// previewSection(section)
|
||||
// }
|
||||
// }
|
||||
|
||||
// 跳转到已报名区域
|
||||
const navigateToEnrolledArea = (videoUrl: string, sectionName: string) => {
|
||||
console.log('跳转到已报名区域,播放视频:', videoUrl)
|
||||
console.log('章节名称:', sectionName)
|
||||
console.log('当前章节:', currentSection.value)
|
||||
// const navigateToEnrolledArea = (videoUrl: string, sectionName: string) => {
|
||||
// console.log('跳转到已报名区域,播放视频:', videoUrl)
|
||||
// console.log('章节名称:', sectionName)
|
||||
// console.log('当前章节:', currentSection.value)
|
||||
|
||||
// 使用路由跳转到学习页面
|
||||
router.push({
|
||||
name: 'CourseStudy',
|
||||
params: { id: courseId.value },
|
||||
query: {
|
||||
videoUrl: encodeURIComponent(videoUrl),
|
||||
sectionName: encodeURIComponent(sectionName),
|
||||
sectionId: currentSection.value?.id
|
||||
}
|
||||
})
|
||||
}
|
||||
// // 使用路由跳转到学习页面
|
||||
// router.push({
|
||||
// name: 'CourseStudy',
|
||||
// params: { id: courseId.value },
|
||||
// query: {
|
||||
// videoUrl: encodeURIComponent(videoUrl),
|
||||
// sectionName: encodeURIComponent(sectionName),
|
||||
// sectionId: currentSection.value?.id
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
// 更新视频播放器(备用方案)
|
||||
// const updateVideoPlayer = (videoUrl: string, sectionName: string) => {
|
||||
@ -1452,14 +1454,14 @@ const navigateToEnrolledArea = (videoUrl: string, sectionName: string) => {
|
||||
// }
|
||||
|
||||
// 预览章节(非视频内容)
|
||||
const previewSection = (section: CourseSection) => {
|
||||
console.log('预览章节:', section)
|
||||
// const previewSection = (section: CourseSection) => {
|
||||
// console.log('预览章节:', section)
|
||||
|
||||
previewModalTitle.value = section.name
|
||||
previewModalContent.value = `章节ID: ${section.id}\n章节名称: ${section.name}\n内容类型: ${getLessonTypeText(section)}`
|
||||
previewModalType.value = 'section'
|
||||
previewModalVisible.value = true
|
||||
}
|
||||
// previewModalTitle.value = section.name
|
||||
// previewModalContent.value = `章节ID: ${section.id}\n章节名称: ${section.name}\n内容类型: ${getLessonTypeText(section)}`
|
||||
// previewModalType.value = 'section'
|
||||
// previewModalVisible.value = true
|
||||
// }
|
||||
|
||||
// 关闭预览模态框
|
||||
const closePreviewModal = () => {
|
||||
@ -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() // 初始化模拟状态
|
||||
|
@ -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 { 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'
|
||||
@ -952,8 +967,9 @@ import QuillEditor from '@/components/common/QuillEditor.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const courseId = ref(Number(route.params.id))
|
||||
// const userStore = useUserStore()
|
||||
const message = useMessage()
|
||||
const courseId = ref(String(route.params.id))
|
||||
// const { loginModalVisible, registerModalVisible, handleAuthSuccess, showLoginModal } = useAuth()
|
||||
// enrollCourse 暂时未使用,后续需要时再启用
|
||||
|
||||
@ -999,14 +1015,14 @@ const examProgress = ref(0)
|
||||
// }
|
||||
|
||||
// 计算用户是否已报名
|
||||
const isUserEnrolled = computed(() => {
|
||||
// 必须同时满足:用户已登录 AND 已报名该课程
|
||||
return userStore.isLoggedIn && isEnrolled.value
|
||||
// const isUserEnrolled = computed(() => {
|
||||
// // 必须同时满足:用户已登录 AND 已报名该课程
|
||||
// return userStore.isLoggedIn && isEnrolled.value
|
||||
|
||||
// 临时测试不同状态:
|
||||
// return false // 强制显示未报名状态(灰色不可点击)
|
||||
// return true // 强制显示已报名状态(彩色可点击)
|
||||
})
|
||||
// // 临时测试不同状态:
|
||||
// // return false // 强制显示未报名状态(灰色不可点击)
|
||||
// // return true // 强制显示已报名状态(彩色可点击)
|
||||
// })
|
||||
|
||||
// 报名确认弹窗
|
||||
const enrollConfirmVisible = ref(false)
|
||||
@ -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) {
|
||||
groups.push({
|
||||
title: `第${i + 1}章 ${chapterTitles[i]}`,
|
||||
sections: chapterSections,
|
||||
expanded: i === 0 // 默认展开第一章
|
||||
})
|
||||
}
|
||||
sectionIndex += sectionsPerChapter[i]
|
||||
// 找出所有一级章节(level=1,这些是父章节)
|
||||
const parentChapters = sections.filter(section => section.level === 1)
|
||||
console.log('🔍 找到一级章节:', parentChapters)
|
||||
|
||||
// 按sortOrder降序排序一级章节(sortOrder越大越靠前)
|
||||
parentChapters.sort((a, b) => b.sort - a.sort)
|
||||
|
||||
// 为每个一级章节创建分组
|
||||
parentChapters.forEach((parentChapter, index) => {
|
||||
// 找出该章节下的所有子章节(level=2,parentId匹配)
|
||||
const childSections = sections.filter(section =>
|
||||
section.level === 2 && section.parentId === parentChapter.id
|
||||
)
|
||||
|
||||
// 按sortOrder降序排序子章节(sortOrder越大越靠前)
|
||||
childSections.sort((a, b) => b.sort - a.sort)
|
||||
|
||||
console.log(`🔍 章节"${parentChapter.name}"的子章节:`, childSections)
|
||||
|
||||
// 创建章节分组
|
||||
groups.push({
|
||||
title: parentChapter.name, // 使用后端返回的章节名称
|
||||
sections: childSections.length > 0 ? childSections : [parentChapter], // 如果有子章节就用子章节,否则用父章节本身
|
||||
expanded: index === 0 // 默认展开第一章
|
||||
})
|
||||
})
|
||||
|
||||
// 如果没有找到一级章节,可能所有章节都是同级的,直接作为一个组
|
||||
if (groups.length === 0 && sections.length > 0) {
|
||||
console.log('🔍 没有找到层级结构,将所有章节作为一组')
|
||||
// 按sortOrder降序排序(sortOrder越大越靠前)
|
||||
const sortedSections = [...sections].sort((a, b) => b.sort - a.sort)
|
||||
groups.push({
|
||||
title: '课程章节',
|
||||
sections: sortedSections,
|
||||
expanded: true
|
||||
})
|
||||
}
|
||||
|
||||
console.log('✅ 章节分组完成:', groups)
|
||||
return groups
|
||||
}
|
||||
|
||||
@ -1139,27 +1135,31 @@ const previewModalContent = ref('')
|
||||
const previewModalType = ref('')
|
||||
|
||||
// 视频播放相关
|
||||
const VIDEO_CONFIG = {
|
||||
// 本地视频(当前使用)
|
||||
LOCAL: '/video/first.mp4',
|
||||
// HLS流(服务器准备好后使用)
|
||||
HLS: 'http://110.42.96.65:55513/learn/index.m3u8'
|
||||
}
|
||||
// const VIDEO_CONFIG = {
|
||||
// // 本地视频(当前使用)
|
||||
// LOCAL: '/video/first.mp4',
|
||||
// // HLS流(服务器准备好后使用)
|
||||
// HLS: 'http://110.42.96.65:55513/learn/index.m3u8'
|
||||
// }
|
||||
|
||||
// 获取视频URL的函数
|
||||
const getVideoUrl = (section?: CourseSection) => {
|
||||
const outline = section?.outline?.trim()
|
||||
if (outline && (outline.endsWith('.mp4') || outline.endsWith('.m3u8'))) {
|
||||
return outline
|
||||
}
|
||||
// 当前使用本地视频,将来可以通过环境变量或配置切换
|
||||
return VIDEO_CONFIG.LOCAL
|
||||
}
|
||||
// const getVideoUrl = (section?: CourseSection) => {
|
||||
// const outline = section?.outline?.trim()
|
||||
// if (outline && (outline.endsWith('.mp4') || outline.endsWith('.m3u8'))) {
|
||||
// return outline
|
||||
// }
|
||||
// // 当前使用本地视频,将来可以通过环境变量或配置切换
|
||||
// return VIDEO_CONFIG.LOCAL
|
||||
// }
|
||||
|
||||
// 视频播放相关状态
|
||||
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) {
|
||||
try {
|
||||
ckplayer.value.remove()
|
||||
} catch (e) {
|
||||
console.log('清理播放器实例时出错:', e)
|
||||
}
|
||||
ckplayer.value = null
|
||||
}
|
||||
|
||||
// 检查CKPlayer是否已加载
|
||||
if (typeof window.ckplayer === 'undefined') {
|
||||
console.error('CKPlayer not loaded')
|
||||
return
|
||||
}
|
||||
|
||||
// 若容器暂未挂载,延迟重试一次,避免"未找到放置视频的容器"
|
||||
const containerEl = document.querySelector('#ckplayer_container') as HTMLElement | null
|
||||
if (!containerEl) {
|
||||
console.warn('Player container not found, retrying init...')
|
||||
setTimeout(() => initCKPlayer(url), 50)
|
||||
return
|
||||
}
|
||||
|
||||
// 判断视频类型
|
||||
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.js插件,MP4不需要插件
|
||||
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' // 跨域设置
|
||||
})
|
||||
}
|
||||
|
||||
// 加载章节视频
|
||||
const loadSectionVideo = async (section: CourseSection) => {
|
||||
try {
|
||||
// 创建播放器实例
|
||||
ckplayer.value = new window.ckplayer(videoObject)
|
||||
console.log('CKPlayer initialized successfully for:', isMP4 ? 'MP4' : 'HLS')
|
||||
videoLoading.value = true
|
||||
console.log('🔍 加载章节视频,章节ID:', section.id)
|
||||
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ 没有找到视频数据')
|
||||
// 如果没有视频数据,使用默认视频
|
||||
currentVideoUrl.value = 'https://example.com/default-video.mp4'
|
||||
currentVideoSection.value = section
|
||||
}
|
||||
} else {
|
||||
console.error('❌ 获取视频失败:', response.message)
|
||||
// 使用默认视频
|
||||
currentVideoUrl.value = 'https://example.com/default-video.mp4'
|
||||
currentVideoSection.value = section
|
||||
}
|
||||
} 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)
|
||||
// 优先根据type字段判断:0=视频
|
||||
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('视频')
|
||||
// 如果type为null,则根据outline判断
|
||||
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) => {
|
||||
// 优先根据type字段判断:1=资料
|
||||
if (section.type === 1) {
|
||||
return true
|
||||
}
|
||||
return 'badge-video' // 默认为视频
|
||||
// 如果type为null,则根据outline或名称判断
|
||||
return section.outline && (section.outline.includes('.pdf') || section.outline.includes('.ppt') || section.outline.includes('.zip'))
|
||||
}
|
||||
|
||||
const isHomeworkLesson = (section: CourseSection) => {
|
||||
// 优先根据type字段判断:3=作业
|
||||
if (section.type === 3) {
|
||||
return true
|
||||
}
|
||||
// 如果type为null,则根据名称判断
|
||||
return section.name.includes('作业') || section.name.includes('练习')
|
||||
}
|
||||
|
||||
const isExamLesson = (section: CourseSection) => {
|
||||
// 优先根据type字段判断:2=考试
|
||||
if (section.type === 2) {
|
||||
return true
|
||||
}
|
||||
// 如果type为null,则根据名称判断
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1821,14 +1762,14 @@ const handleSectionClick = (section: CourseSection) => {
|
||||
// }
|
||||
|
||||
// 预览章节(非视频内容)
|
||||
const previewSection = (section: CourseSection) => {
|
||||
console.log('预览章节:', section)
|
||||
// const previewSection = (section: CourseSection) => {
|
||||
// console.log('预览章节:', section)
|
||||
|
||||
previewModalTitle.value = section.name
|
||||
previewModalContent.value = `章节ID: ${section.id}\n章节名称: ${section.name}\n内容类型: ${getLessonTypeText(section)}`
|
||||
previewModalType.value = 'section'
|
||||
previewModalVisible.value = true
|
||||
}
|
||||
// previewModalTitle.value = section.name
|
||||
// previewModalContent.value = `章节ID: ${section.id}\n章节名称: ${section.name}\n内容类型: ${getLessonTypeText(section)}`
|
||||
// previewModalType.value = 'section'
|
||||
// previewModalVisible.value = true
|
||||
// }
|
||||
|
||||
// 关闭预览模态框
|
||||
const closePreviewModal = () => {
|
||||
@ -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;
|
||||
/* 移除固定高度,让内容自适应 */
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user