fix:修复章节预览弹窗部分报错;修改评论页面布局;隐藏资源菜单
This commit is contained in:
parent
82ae528785
commit
03f9d1b423
@ -7,7 +7,7 @@
|
||||
<div v-else class="document-content">
|
||||
<!-- 文档信息 -->
|
||||
<div class="document-info">
|
||||
<h4>{{ detailData.name || '文档标题' }}</h4>
|
||||
<h4>{{ detailData.name}}.{{ detailData.fileUrl.split('.').pop() }}</h4>
|
||||
<div class="document-meta">
|
||||
<n-space>
|
||||
<n-tag type="success" size="small">
|
||||
@ -65,7 +65,7 @@
|
||||
</n-icon>
|
||||
</div>
|
||||
<div class="document-type-info">
|
||||
<h4>{{ detailData.name }}</h4>
|
||||
<h4>{{ detailData.name }}.{{ detailData.fileUrl.split('.').pop() }}</h4>
|
||||
<p class="file-description">{{ detailData.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -73,7 +73,7 @@
|
||||
<!-- 操作按钮 -->
|
||||
<div class="document-operations">
|
||||
<n-space>
|
||||
<n-button type="primary" size="large" @click="downloadDocument">
|
||||
<n-button type="primary" @click="downloadDocument">
|
||||
<template #icon>
|
||||
<n-icon><DownloadOutline /></n-icon>
|
||||
</template>
|
||||
|
@ -59,8 +59,7 @@
|
||||
<!-- 作业描述 -->
|
||||
<div class="homework-description">
|
||||
<h5>作业描述</h5>
|
||||
<div class="description-content">
|
||||
{{ detailData.description || '暂无作业要求说明' }}
|
||||
<div class="description-content" v-html="detailData.description">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -12,7 +12,9 @@
|
||||
<n-space>
|
||||
<n-tag type="info" size="small">
|
||||
<template #icon>
|
||||
<n-icon><PlayCircleOutline /></n-icon>
|
||||
<n-icon>
|
||||
<PlayCircleOutline />
|
||||
</n-icon>
|
||||
</template>
|
||||
视频
|
||||
</n-tag>
|
||||
@ -40,24 +42,6 @@
|
||||
<template #description>视频加载中...</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
|
||||
<!-- 画质选择器(仅在需要手动切换时显示) -->
|
||||
<div v-if="videoQualities.length > 1 && !dplayer?.switchQuality" class="quality-selector">
|
||||
<n-space>
|
||||
<span class="quality-label">画质:</span>
|
||||
<n-button-group>
|
||||
<n-button
|
||||
v-for="(quality, index) in videoQualities"
|
||||
:key="index"
|
||||
:type="currentQualityIndex === index ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="switchQuality(index)"
|
||||
>
|
||||
{{ quality.name }}
|
||||
</n-button>
|
||||
</n-button-group>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-video">
|
||||
@ -99,11 +83,9 @@ import {
|
||||
NIcon,
|
||||
NSpin,
|
||||
NResult,
|
||||
NButton,
|
||||
NDivider,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NButtonGroup,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { PlayCircleOutline } from '@vicons/ionicons5'
|
||||
@ -178,10 +160,10 @@ const getQualityNameFromUrl = (url: string, index: number): string => {
|
||||
}
|
||||
|
||||
// 获取画质名称 - 根据链接内容判断画质
|
||||
const getQualityName = (index: number) => {
|
||||
const quality = videoQualities.value[index]
|
||||
return quality?.name || `画质${index + 1}`
|
||||
}
|
||||
// const getQualityName = (index: number) => {
|
||||
// const quality = videoQualities.value[index]
|
||||
// return quality?.name || `画质${index + 1}`
|
||||
// }
|
||||
|
||||
// 获取当前播放的视频源
|
||||
const currentVideoSource = computed(() => {
|
||||
@ -219,6 +201,7 @@ const initDPlayer = async () => {
|
||||
defaultQuality: currentQualityIndex.value
|
||||
} : {}
|
||||
|
||||
|
||||
// 创建新的DPlayer实例
|
||||
dplayer = new DPlayer({
|
||||
container: dplayerContainer.value,
|
||||
@ -255,25 +238,8 @@ const initDPlayer = async () => {
|
||||
airplay: false,
|
||||
chromecast: false
|
||||
})
|
||||
videoLoading.value = false
|
||||
|
||||
// 监听播放器事件
|
||||
dplayer.on('loadstart', () => {
|
||||
videoLoading.value = true
|
||||
})
|
||||
|
||||
dplayer.on('loadedmetadata', () => {
|
||||
videoLoading.value = false
|
||||
})
|
||||
|
||||
dplayer.on('canplay', () => {
|
||||
videoLoading.value = false
|
||||
})
|
||||
|
||||
dplayer.on('error', (info?: any) => {
|
||||
videoLoading.value = false
|
||||
console.error('DPlayer播放错误:', info)
|
||||
emit('error', '视频播放失败,请检查视频链接是否有效')
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
videoLoading.value = false
|
||||
@ -282,60 +248,6 @@ const initDPlayer = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 切换画质
|
||||
const switchQuality = async (index: number) => {
|
||||
if (index === currentQualityIndex.value || !dplayer || videoQualities.value.length <= 1) return
|
||||
|
||||
try {
|
||||
// 如果DPlayer支持内置画质切换,使用switchQuality方法
|
||||
if (typeof dplayer.switchQuality === 'function') {
|
||||
dplayer.switchQuality(index)
|
||||
return
|
||||
}
|
||||
|
||||
// 否则手动切换(保存当前播放状态)
|
||||
const currentTime = dplayer.video.currentTime
|
||||
const isPaused = dplayer.video.paused
|
||||
const currentVolume = dplayer.volume()
|
||||
|
||||
currentQualityIndex.value = index
|
||||
|
||||
// 重新初始化播放器
|
||||
await initDPlayer()
|
||||
|
||||
// 恢复播放状态
|
||||
if (dplayer && dplayer.video) {
|
||||
const waitForLoad = () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const checkLoad = () => {
|
||||
if (dplayer && dplayer.video.readyState >= 2) {
|
||||
try {
|
||||
dplayer.video.currentTime = currentTime
|
||||
dplayer.volume(currentVolume)
|
||||
if (!isPaused) {
|
||||
dplayer.play().catch(console.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('恢复播放状态时出错:', error)
|
||||
}
|
||||
resolve()
|
||||
} else {
|
||||
setTimeout(checkLoad, 100)
|
||||
}
|
||||
}
|
||||
checkLoad()
|
||||
})
|
||||
}
|
||||
|
||||
await waitForLoad()
|
||||
}
|
||||
|
||||
message.success(`已切换到${getQualityName(index)}`)
|
||||
} catch (error) {
|
||||
console.error('切换画质失败:', error)
|
||||
message.error('切换画质失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: number | string) => {
|
||||
@ -450,57 +362,6 @@ onUnmounted(() => {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.quality-selector {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.quality-label {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.no-video {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.video-description {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.video-description h5 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description-content {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #555;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.video-details {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.quality-count,
|
||||
.duration,
|
||||
.file-size {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* DPlayer 样式覆盖 */
|
||||
:deep(.dplayer) {
|
||||
border-radius: 8px;
|
||||
@ -534,17 +395,5 @@ onUnmounted(() => {
|
||||
.dplayer-container {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.quality-selector {
|
||||
position: static;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
margin-top: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.quality-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -81,12 +81,12 @@
|
||||
</div>
|
||||
|
||||
<!-- 我的下载 -->
|
||||
<div :class="['image-text_26', { active: activeTab === 'download' }]" @click="handleMenuSelect('download')">
|
||||
<!-- <div :class="['image-text_26', { active: activeTab === 'download' }]" @click="handleMenuSelect('download')">
|
||||
<img class="thumbnail_41 default-icon" referrerpolicy="no-referrer" src="/images/profile/download.png" />
|
||||
<img class="thumbnail_41 hover-icon" referrerpolicy="no-referrer"
|
||||
src="/images/profile/download-active.png" />
|
||||
<span class="text-group_26">我的下载</span>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -149,7 +149,7 @@ const flattenedChapters = computed(() => {
|
||||
if (a.sortOrder === null && b.sortOrder === null) return 0
|
||||
if (a.sortOrder === null) return 1
|
||||
if (b.sortOrder === null) return -1
|
||||
return (a.sortOrder || 0) - (b.sortOrder || 0)
|
||||
return (b.sortOrder || 0) - (a.sortOrder || 0)
|
||||
})
|
||||
|
||||
// 为每个章节添加其子节
|
||||
|
@ -85,36 +85,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加讨论模态框 -->
|
||||
<n-modal v-model:show="showAddDiscussionModal" preset="card" title="添加讨论" style="width: 600px">
|
||||
<div class="add-discussion-form">
|
||||
<n-form :model="newDiscussion" label-placement="top">
|
||||
<n-form-item label="讨论标题">
|
||||
<n-input v-model:value="newDiscussion.title" placeholder="请输入讨论标题" />
|
||||
</n-form-item>
|
||||
<n-form-item label="所属章节">
|
||||
<n-select v-model:value="newDiscussion.chapterId" :options="chapterOptions" placeholder="请选择章节" />
|
||||
</n-form-item>
|
||||
<n-form-item label="讨论内容">
|
||||
<n-input v-model:value="newDiscussion.content" type="textarea" placeholder="请输入讨论内容" :rows="6" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<n-button @click="showAddDiscussionModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="addDiscussion">确定</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { NButton, NInput, NDropdown, NIcon, NModal, NForm, NFormItem, NSelect, useMessage } from 'naive-ui'
|
||||
import { NButton, NInput, NDropdown, NIcon, useMessage } from 'naive-ui'
|
||||
import { DiscussionApi } from '@/api/modules/teachCourse'
|
||||
|
||||
const message = useMessage()
|
||||
@ -123,12 +100,11 @@ const route = useRoute()
|
||||
|
||||
// 响应式数据
|
||||
const searchKeyword = ref('')
|
||||
const showAddDiscussionModal = ref(false)
|
||||
const newDiscussion = ref({
|
||||
title: '',
|
||||
chapterId: '',
|
||||
content: ''
|
||||
})
|
||||
// const newDiscussion = ref({
|
||||
// title: '',
|
||||
// chapterId: '',
|
||||
// content: ''
|
||||
// })
|
||||
|
||||
// 计算属性:排序后的讨论列表(置顶的在前)
|
||||
const sortedDiscussions = computed(() => {
|
||||
@ -215,12 +191,6 @@ const formatTime = (timeStr: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 章节选项
|
||||
const chapterOptions = [
|
||||
{ label: '第一章:基础概念', value: 'chapter1' },
|
||||
{ label: '第二章:进阶应用', value: 'chapter2' },
|
||||
{ label: '第三章:实战项目', value: 'chapter3' }
|
||||
]
|
||||
|
||||
// 在组件挂载时加载讨论列表
|
||||
onMounted(() => {
|
||||
@ -290,15 +260,6 @@ const deleteDiscussion = () => {
|
||||
message.success('删除成功')
|
||||
}
|
||||
|
||||
const addDiscussion = () => {
|
||||
message.success('添加成功')
|
||||
showAddDiscussionModal.value = false
|
||||
newDiscussion.value = {
|
||||
title: '',
|
||||
chapterId: '',
|
||||
content: ''
|
||||
}
|
||||
}
|
||||
|
||||
const viewComments = (discussion: any) => {
|
||||
router.push({
|
||||
|
@ -14,14 +14,8 @@
|
||||
</template>
|
||||
返回
|
||||
</n-button>
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">{{ discussionInfo.title || '加载中...' }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<n-button type="primary" @click="goToAddReply">
|
||||
添加回复
|
||||
</n-button>
|
||||
<div class="search-box">
|
||||
<n-input v-model:value="searchKeyword" placeholder="请输入关键词" style="width: 200px;" clearable
|
||||
@clear="clearSearch" @keyup.enter="handleSearch" />
|
||||
@ -32,8 +26,12 @@
|
||||
|
||||
<!-- 讨论详情卡片 -->
|
||||
<div v-if="discussionInfo.title" class="discussion-detail-card">
|
||||
<h1 class="page-title">{{ discussionInfo.title || '加载中...' }}</h1>
|
||||
<div class="discussion-content-preview" v-if="discussionInfo.content">
|
||||
<div class="content-text" v-html="discussionInfo.content"></div>
|
||||
<div class="page-content" v-html="discussionInfo.content"></div>
|
||||
</div>
|
||||
<div class="discussion-content-preview" v-if="discussionInfo.chapterName">
|
||||
<div class="content-text" v-html="discussionInfo.chapterName"></div>
|
||||
</div>
|
||||
<div class="discussion-meta">
|
||||
<span class="timestamp">{{ discussionInfo.timestamp }}</span>
|
||||
@ -42,7 +40,32 @@
|
||||
|
||||
<!-- 回复列表 -->
|
||||
<div class="replies-list">
|
||||
<!-- 搜索结果提示 -->
|
||||
<div class="reply-box">
|
||||
<div class="reply-total">全部评论 ({{ sortedReplies.length }}条)</div>
|
||||
|
||||
<div class="message-box">
|
||||
<div class="avatar">
|
||||
<img :src="userStore.user?.avatar"
|
||||
:alt="userStore.user?.profile?.realName || userStore.user?.nickname || userStore.user?.username"
|
||||
class="avatar">
|
||||
<div class="avatar-text">
|
||||
{{ userStore.user?.profile?.realName || userStore.user?.nickname || userStore.user?.username
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="textarea">
|
||||
<n-input v-model:value="newReply.content" type="textarea" placeholder="请输入回复内容" :rows="6" />
|
||||
<div class="submit-box">
|
||||
<n-button>取消</n-button>
|
||||
<n-button type="primary" @click="addReply">
|
||||
发布
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果提示 -->
|
||||
<div v-if="searchKeyword.trim()" class="search-result-tip">
|
||||
<span v-if="sortedReplies.length === 0" class="no-result">
|
||||
未找到包含"{{ searchKeyword.trim() }}"的回复
|
||||
@ -57,6 +80,8 @@
|
||||
<p>暂无回复数据</p>
|
||||
</div>
|
||||
|
||||
<!-- 输入框 -->
|
||||
|
||||
<!-- 回复项列表 -->
|
||||
<div v-for="reply in sortedReplies" :key="reply.id" class="reply-item">
|
||||
<!-- 用户信息 -->
|
||||
@ -174,23 +199,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加回复模态框 -->
|
||||
<n-modal v-model:show="showAddReplyModal" preset="card" title="添加回复" style="width: 600px">
|
||||
<div class="add-reply-form">
|
||||
<n-form :model="newReply" label-placement="top">
|
||||
<n-form-item label="回复内容">
|
||||
<n-input v-model:value="newReply.content" type="textarea" placeholder="请输入回复内容" :rows="6" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<n-button @click="showAddReplyModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="addReply">确定</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- 回复回复模态框 -->
|
||||
<n-modal v-model:show="showReplyToReplyModal" preset="card" title="回复评论" style="width: 600px">
|
||||
<div class="reply-to-reply-form">
|
||||
@ -221,6 +229,9 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { NButton, NInput, NIcon, NModal, NForm, NFormItem, useMessage } from 'naive-ui'
|
||||
import { DiscussionApi } from '@/api/modules/teachCourse'
|
||||
import type { CreateCommentRequest } from '@/api/modules/teachCourse'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
@ -589,10 +600,6 @@ const submitReplyToReply = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到添加回复页面
|
||||
const goToAddReply = () => {
|
||||
showAddReplyModal.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -607,7 +614,6 @@ const goToAddReply = () => {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1.5px solid #f6f6f6;
|
||||
}
|
||||
@ -631,10 +637,15 @@ const goToAddReply = () => {
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: #0C99DA;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
color: #333333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.discussion-title {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
@ -683,11 +694,9 @@ const goToAddReply = () => {
|
||||
/* 讨论详情卡片 */
|
||||
.discussion-detail-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
margin: 0 20px 24px 20px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.discussion-header {
|
||||
@ -751,15 +760,14 @@ const goToAddReply = () => {
|
||||
}
|
||||
|
||||
.discussion-content-preview {
|
||||
margin-top: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
background: #f8f9fa;
|
||||
padding: 16px;
|
||||
padding: 9px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@ -796,6 +804,40 @@ const goToAddReply = () => {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.reply-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 20px 20px 20px;
|
||||
border-bottom: 1.5px solid #F1F3F4;
|
||||
background: #fff;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.reply-total {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.message-box {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit-box{
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.reply-item {
|
||||
display: flex;
|
||||
padding: 0 20px 20px 20px;
|
||||
@ -834,7 +876,6 @@ const goToAddReply = () => {
|
||||
|
||||
/* 回复内容 */
|
||||
.reply-content {
|
||||
padding-top: 6px;
|
||||
flex: 1;
|
||||
margin-right: 16px;
|
||||
}
|
||||
@ -859,7 +900,6 @@ const goToAddReply = () => {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.reply-meta {
|
||||
@ -918,14 +958,7 @@ const goToAddReply = () => {
|
||||
|
||||
/* 嵌套回复样式 */
|
||||
.nested-replies {
|
||||
margin-top: 16px;
|
||||
padding-left: 20px;
|
||||
border-left: 2px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
border-radius: 0 8px 8px 0;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 8px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.nested-reply-item {
|
||||
|
@ -111,6 +111,7 @@ import { useRouter } from 'vue-router'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { ArrowBackOutline } from '@vicons/ionicons5'
|
||||
import { downloadFileFromUrl, getFileExtension } from '@/utils/download'
|
||||
import DPlayer from 'dplayer'
|
||||
import {
|
||||
FolderOutline,
|
||||
VideocamOutline,
|
||||
@ -121,13 +122,6 @@ import {
|
||||
AttachOutline
|
||||
} from '@vicons/ionicons5'
|
||||
|
||||
// 声明全局 DPlayer 类型
|
||||
declare global {
|
||||
interface Window {
|
||||
DPlayer: any
|
||||
Hls: any
|
||||
}
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
@ -155,7 +149,7 @@ const breadcrumbs = ref<FileItem[]>([])
|
||||
const dplayerRef = ref<HTMLElement | null>(null)
|
||||
const dplayer = ref<any>(null)
|
||||
const videoQualities = ref<VideoQuality[]>([])
|
||||
const currentQuality = ref<string>('')
|
||||
// const currentQuality = ref<string>('')
|
||||
|
||||
// 视频画质类型定义
|
||||
interface VideoQuality {
|
||||
@ -255,78 +249,50 @@ const parseVideoQualities = (fileUrl: string): VideoQuality[] => {
|
||||
return [{
|
||||
name: '原画',
|
||||
url: fileUrl,
|
||||
type: fileUrl.includes('.m3u8') ? 'hls' : 'normal'
|
||||
type: 'auto'
|
||||
}]
|
||||
}
|
||||
|
||||
const qualities: VideoQuality[] = []
|
||||
|
||||
for (const url of urls) {
|
||||
const quality = extractQualityFromUrl(url)
|
||||
qualities.push({
|
||||
name: quality,
|
||||
url: url,
|
||||
type: url.includes('.m3u8') ? 'hls' : 'normal'
|
||||
})
|
||||
}
|
||||
|
||||
// 按画质排序(从高到低)
|
||||
return qualities.sort((a, b) => {
|
||||
const aRes = getResolutionValue(a.name)
|
||||
const bRes = getResolutionValue(b.name)
|
||||
return bRes - aRes
|
||||
})
|
||||
}
|
||||
|
||||
// 从URL中提取画质信息
|
||||
const extractQualityFromUrl = (url: string): string => {
|
||||
const patterns = [
|
||||
{ pattern: /\/(\d+p)\//i, extract: (match: RegExpMatchArray) => match[1] },
|
||||
{ pattern: /\/(\d+)\//i, extract: (match: RegExpMatchArray) => `${match[1]}p` }
|
||||
]
|
||||
|
||||
for (const { pattern, extract } of patterns) {
|
||||
const match = url.match(pattern)
|
||||
if (match) {
|
||||
return extract(match).toUpperCase()
|
||||
// 按画质排序:480p -> 720p -> 1080p
|
||||
return urls.sort((a: string, b: string) => {
|
||||
const getQualityOrder = (url: string): number => {
|
||||
if (url.includes('1080p') || url.includes('1080')) return 3
|
||||
if (url.includes('720p') || url.includes('720')) return 2
|
||||
if (url.includes('480p') || url.includes('480')) return 1
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试从路径推断
|
||||
if (url.includes('1080')) return '1080P'
|
||||
if (url.includes('720')) return '720P'
|
||||
if (url.includes('480')) return '480P'
|
||||
if (url.includes('360')) return '360P'
|
||||
|
||||
return '原画'
|
||||
return getQualityOrder(a) - getQualityOrder(b)
|
||||
}).map((url: string, index: number) => ({
|
||||
name: getQualityNameFromUrl(url, index),
|
||||
url,
|
||||
type: 'auto'
|
||||
}))
|
||||
}
|
||||
|
||||
// 获取分辨率数值(用于排序)
|
||||
const getResolutionValue = (quality: string): number => {
|
||||
const resolutionMap: { [key: string]: number } = {
|
||||
'1080P': 1080,
|
||||
'720P': 720,
|
||||
'480P': 480,
|
||||
'360P': 360,
|
||||
'原画': 9999
|
||||
}
|
||||
return resolutionMap[quality] || 0
|
||||
// 根据URL获取画质名称
|
||||
const getQualityNameFromUrl = (url: string, index: number): string => {
|
||||
if (url.includes('1080p') || url.includes('1080')) return '1080p'
|
||||
if (url.includes('720p') || url.includes('720')) return '720p'
|
||||
if (url.includes('480p') || url.includes('480')) return '480p'
|
||||
|
||||
const defaultQualities = ['480p', '720p', '1080p']
|
||||
return defaultQualities[index] || `画质${index + 1}`
|
||||
}
|
||||
|
||||
// 获取默认画质
|
||||
const getDefaultQuality = (qualities: VideoQuality[]): VideoQuality | null => {
|
||||
if (qualities.length === 0) return null
|
||||
// 获取默认画质索引
|
||||
const getDefaultQualityIndex = (qualities: VideoQuality[]): number => {
|
||||
if (qualities.length === 0) return 0
|
||||
|
||||
// 优先选择720P
|
||||
const preferred = qualities.find(q => q.name === '720P')
|
||||
if (preferred) return preferred
|
||||
// 优先选择720p
|
||||
const preferred = qualities.findIndex(q => q.name === '720p')
|
||||
if (preferred !== -1) return preferred
|
||||
|
||||
// 其次选择1080P
|
||||
const fallback = qualities.find(q => q.name === '1080P')
|
||||
if (fallback) return fallback
|
||||
// 其次选择480p
|
||||
const fallback = qualities.findIndex(q => q.name === '480p')
|
||||
if (fallback !== -1) return fallback
|
||||
|
||||
// 最后返回第一个
|
||||
return qualities[0]
|
||||
// 最后返回第一个(最低画质)
|
||||
return 0
|
||||
}
|
||||
|
||||
// 获取视频画质选项
|
||||
@ -339,6 +305,18 @@ const getVideoQualities = (file: FileItem): VideoQuality[] => {
|
||||
return parseVideoQualities(fileUrl)
|
||||
}
|
||||
|
||||
// 获取当前播放的视频源
|
||||
const getCurrentVideoSource = (qualities: VideoQuality[], qualityIndex: number) => {
|
||||
if (qualities.length === 0) return null
|
||||
|
||||
const currentQuality = qualities[qualityIndex]
|
||||
return {
|
||||
url: currentQuality.url,
|
||||
pic: currentFile.value?.originalData?.thumbnailUrl || '',
|
||||
type: 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化视频播放器
|
||||
const initVideoPlayer = async (file: FileItem) => {
|
||||
await nextTick()
|
||||
@ -367,75 +345,62 @@ const initVideoPlayer = async (file: FileItem) => {
|
||||
const qualities = getVideoQualities(file)
|
||||
videoQualities.value = qualities
|
||||
|
||||
// 设置默认画质
|
||||
const defaultQuality = getDefaultQuality(qualities)
|
||||
let videoUrl = fileUrl
|
||||
// 获取默认画质索引
|
||||
const defaultQualityIndex = getDefaultQualityIndex(qualities)
|
||||
const currentVideoSource = getCurrentVideoSource(qualities, defaultQualityIndex)
|
||||
|
||||
if (defaultQuality) {
|
||||
currentQuality.value = defaultQuality.name
|
||||
videoUrl = defaultQuality.url
|
||||
}
|
||||
|
||||
// 检查是否需要加载HLS.js
|
||||
const isHLS = videoUrl.includes('.m3u8')
|
||||
if (isHLS && !window.Hls && !(window as any).Hls) {
|
||||
await loadHLSScript()
|
||||
if (!currentVideoSource) {
|
||||
console.error('无法获取视频源')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查DPlayer是否存在
|
||||
if (!window.DPlayer) {
|
||||
await loadDPlayerScript()
|
||||
}
|
||||
// 清空容器内容
|
||||
dplayerRef.value.innerHTML = ''
|
||||
|
||||
// 准备画质配置
|
||||
const qualityConfig = qualities.length > 1 ? {
|
||||
quality: qualities,
|
||||
defaultQuality: defaultQualityIndex
|
||||
} : {}
|
||||
|
||||
const options: any = {
|
||||
container: dplayerRef.value,
|
||||
autoplay: false,
|
||||
theme: '#1890ff',
|
||||
video: {
|
||||
url: currentVideoSource.url,
|
||||
pic: currentVideoSource.pic,
|
||||
type: 'auto',
|
||||
...qualityConfig,
|
||||
},
|
||||
subtitle: {
|
||||
url: '',
|
||||
type: 'webvtt',
|
||||
fontSize: '20px',
|
||||
bottom: '40px',
|
||||
color: '#fff'
|
||||
},
|
||||
contextmenu: [
|
||||
{
|
||||
text: '视频信息',
|
||||
click: () => {
|
||||
message.info(`视频: ${file.name || '未知'}`)
|
||||
}
|
||||
}
|
||||
],
|
||||
theme: '#FADFA3',
|
||||
loop: false,
|
||||
lang: 'zh-cn',
|
||||
screenshot: true,
|
||||
screenshot: false,
|
||||
hotkey: true,
|
||||
preload: 'auto',
|
||||
volume: 0.8,
|
||||
video: {
|
||||
url: videoUrl,
|
||||
type: isHLS ? 'hls' : 'normal'
|
||||
}
|
||||
preload: 'metadata',
|
||||
volume: 0.7,
|
||||
mutex: true,
|
||||
pictureInPicture: false,
|
||||
airplay: false,
|
||||
chromecast: false
|
||||
}
|
||||
|
||||
// 如果有多个画质,添加画质选择
|
||||
if (qualities.length > 1) {
|
||||
options.video.quality = qualities.map(q => ({
|
||||
name: q.name,
|
||||
url: q.url,
|
||||
type: q.type || (q.url.includes('.m3u8') ? 'hls' : 'normal')
|
||||
}))
|
||||
options.video.defaultQuality = 0 // 默认选择第一个(最高画质)
|
||||
}
|
||||
|
||||
// HLS特殊处理
|
||||
if (isHLS && (window as any).Hls && (window as any).Hls.isSupported()) {
|
||||
options.video.customType = {
|
||||
hls: function(video: HTMLVideoElement, _player: any) {
|
||||
const hls = new (window as any).Hls({
|
||||
debug: false,
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false
|
||||
})
|
||||
hls.loadSource(video.src)
|
||||
hls.attachMedia(video)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dplayer.value = new window.DPlayer(options)
|
||||
|
||||
// 监听画质切换
|
||||
dplayer.value.on('quality_end', (quality: any) => {
|
||||
currentQuality.value = quality.name
|
||||
console.log('画质切换到:', quality.name)
|
||||
})
|
||||
dplayer.value = new DPlayer(options)
|
||||
|
||||
console.log('✅ DPlayer 初始化成功')
|
||||
} catch (error) {
|
||||
@ -443,49 +408,6 @@ const initVideoPlayer = async (file: FileItem) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载DPlayer脚本
|
||||
const loadDPlayerScript = (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.DPlayer) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/dplayer@1.27.1/dist/DPlayer.min.js'
|
||||
script.onload = () => {
|
||||
console.log('✅ DPlayer脚本加载成功')
|
||||
resolve()
|
||||
}
|
||||
script.onerror = (error) => {
|
||||
console.error('❌ DPlayer脚本加载失败:', error)
|
||||
reject(new Error('Failed to load DPlayer'))
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
// 加载HLS.js脚本
|
||||
const loadHLSScript = (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if ((window as any).Hls) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.15/dist/hls.min.js'
|
||||
script.onload = () => {
|
||||
console.log('✅ HLS.js脚本加载成功')
|
||||
resolve()
|
||||
}
|
||||
script.onerror = (error) => {
|
||||
console.error('❌ HLS.js脚本加载失败:', error)
|
||||
reject(new Error('Failed to load HLS.js'))
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
// 构建面包屑导航
|
||||
const buildBreadcrumbs = (file: FileItem, folderData?: FileItem) => {
|
||||
const crumbs: FileItem[] = []
|
||||
|
@ -25,15 +25,9 @@
|
||||
<!-- 空状态占位 -->
|
||||
<div v-if="filteredHomeworks.length === 0 && !loading" class="empty-state">
|
||||
<n-empty
|
||||
description="还没有作业"
|
||||
description="暂无数据"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="empty-icon">📝</div>
|
||||
</template>
|
||||
<template #extra>
|
||||
<span class="empty-extra">请先去创建作业吧~</span>
|
||||
</template>
|
||||
</n-empty>
|
||||
</div>
|
||||
|
||||
@ -48,7 +42,7 @@
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="content-left">
|
||||
<p class="homework-desc">{{ homework.content }}</p>
|
||||
<p class="homework-desc" v-html="homework.content"></p>
|
||||
<div class="homework-info">
|
||||
<div class="info-item">
|
||||
<img class="icon" src="/images/teacher/发布人.png" alt="发布人" />
|
||||
|
@ -24,7 +24,7 @@
|
||||
<!-- 右侧按钮和搜索 -->
|
||||
<div class="actions-container">
|
||||
<n-button class="action-btn export-btn" ghost type="primary" size="small">导出</n-button>
|
||||
<n-button class="action-btn remove-btn" ghost type="error" size="small">移除</n-button>
|
||||
<!-- <n-button class="action-btn remove-btn" ghost type="error" size="small">移除</n-button> -->
|
||||
<div class="search-container">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="searchText" placeholder="请输入学生姓名" :style="{ width: '200px' }" size="medium" />
|
||||
|
Loading…
x
Reference in New Issue
Block a user