feat: 课件界面重构,对接接口;章节页面基础框架接口对接;一些样式优化
This commit is contained in:
parent
b20cc50f44
commit
aa87b0e8e4
@ -373,9 +373,7 @@ export class TeachCourseApi {
|
||||
try {
|
||||
console.log('🚀 发送删除课程章节请求:', { url: '/aiol/aiolCourseSection/delete', id })
|
||||
|
||||
const response = await ApiRequest.delete<any>('/aiol/aiolCourseSection/delete', {
|
||||
params: { id }
|
||||
})
|
||||
const response = await ApiRequest.delete<any>('/aiol/aiolCourseSection/delete', {id})
|
||||
|
||||
console.log('🗑️ 删除课程章节响应:', response)
|
||||
return response
|
||||
@ -405,6 +403,94 @@ export class TeachCourseApi {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询课程课件列表
|
||||
*/
|
||||
static async queryCourseMaterials(params: { courseId: string; resourceType: number|string; name?:string }): Promise<ApiResponseWithResult<any[]>> {
|
||||
try {
|
||||
const response = await ApiRequest.get<{ result: any[] }>(`/aiol/aiolResource/course_materials`, params)
|
||||
|
||||
console.log('📑 查询课程课件响应:', response)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('❌ 查询课程课件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 上传视频课件
|
||||
*/
|
||||
static async uploadCursorVideo(data: { courseId: string; file: File; name:string }): Promise<ApiResponseWithResult<any[]>> {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('courseId', data.courseId)
|
||||
formData.append('file', data.file)
|
||||
formData.append('name', data.name)
|
||||
|
||||
const response = await ApiRequest.post<{ result: any[] }>(`/aiol/aiolResource/video_upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('📑 上传视频课件响应:', response)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('❌ 上传视频课件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档上传
|
||||
*/
|
||||
static async uploadCursorDocument(data: { courseId: string; file: File; name:string }): Promise<ApiResponseWithResult<any[]>> {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('courseId', data.courseId)
|
||||
formData.append('file', data.file)
|
||||
formData.append('name', data.name)
|
||||
|
||||
const response = await ApiRequest.post<{ result: any[] }>(`/aiol/aiolResource/document_upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('📑 上传文档课件响应:', response)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('❌ 上传文档课件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片上传
|
||||
*/
|
||||
static async uploadCursorImage(data: { courseId: string; file: File; name:string }): Promise<ApiResponseWithResult<any[]>> {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('courseId', data.courseId)
|
||||
formData.append('file', data.file)
|
||||
formData.append('name', data.name)
|
||||
|
||||
const response = await ApiRequest.post<{ result: any[] }>(`/aiol/aiolResource/image_upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('📑 上传图片课件响应:', response)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('❌ 上传图片课件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认导出
|
||||
@ -507,4 +593,42 @@ export class ClassApi {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件下载链接
|
||||
*/
|
||||
static async getFileDownloadUrl(fileId: string | number): Promise<ApiResponseWithResult<{ downloadUrl: string }>> {
|
||||
try {
|
||||
const response = await ApiRequest.get<{ result: { downloadUrl: string } }>(`/aiol/aiolResource/${fileId}/download_url`)
|
||||
console.log('📥 获取文件下载链接响应:', response)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('❌ 获取文件下载链接失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接下载文件
|
||||
*/
|
||||
static async downloadFile(fileId: string | number): Promise<Response> {
|
||||
try {
|
||||
const response = await fetch(`/api/aiol/aiolResource/${fileId}/download`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token') || ''}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败: ${response.statusText}`)
|
||||
}
|
||||
|
||||
console.log('📥 文件下载响应:', response)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('❌ 文件下载失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
@ -12,12 +12,12 @@
|
||||
<n-button @click="handleNewFolder">
|
||||
新建文件夹
|
||||
</n-button>
|
||||
<n-button @click="handleRecycleBin">
|
||||
<!-- <n-button @click="handleRecycleBin">
|
||||
<template #icon>
|
||||
<img src="/images/teacher/delete2.png" alt="回收站" class="action-icon">
|
||||
</template>
|
||||
回收站
|
||||
</n-button>
|
||||
</n-button> -->
|
||||
<div class="search-container">
|
||||
<n-input
|
||||
v-model:value="searchKeyword"
|
||||
@ -120,6 +120,8 @@ import FileInfoCard from '@/components/admin/FileInfoCard.vue'
|
||||
import UploadFileModal from '@/views/teacher/course/UploadFileModal.vue'
|
||||
import RecycleConfirmModal from '@/views/teacher/resource/RecycleConfirmModal.vue'
|
||||
import { Search } from '@vicons/ionicons5'
|
||||
// import { useRoute } from 'vue-router'
|
||||
// const route = useRoute()
|
||||
|
||||
// 文件类型定义
|
||||
interface FileItem {
|
||||
@ -265,9 +267,9 @@ const handleNewFolder = () => {
|
||||
showNewFolderModal.value = true
|
||||
}
|
||||
|
||||
const handleRecycleBin = () => {
|
||||
window.location.href = '/teacher/recycle-bin'
|
||||
}
|
||||
// const handleRecycleBin = () => {
|
||||
// route.push('/teacher/recycle-bin')
|
||||
// }
|
||||
|
||||
const closeNewFolderModal = () => {
|
||||
showNewFolderModal.value = false
|
||||
|
37
src/utils/download.ts
Normal file
37
src/utils/download.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 文件下载工具
|
||||
*/
|
||||
|
||||
/**
|
||||
* 通过 URL 下载文件
|
||||
* @param url 文件下载地址
|
||||
* @param filename 保存的文件名
|
||||
*/
|
||||
export const downloadFileFromUrl = (url: string, filename?: string) => {
|
||||
try {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.target = '_blank'
|
||||
|
||||
if (filename) {
|
||||
link.download = filename
|
||||
}
|
||||
|
||||
// 添加到DOM,触发下载,然后移除
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
} catch (error) {
|
||||
console.error('下载文件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @param filename 文件名或URL
|
||||
*/
|
||||
export const getFileExtension = (filename: string): string => {
|
||||
const lastDotIndex = filename.lastIndexOf('.')
|
||||
return lastDotIndex !== -1 ? filename.slice(lastDotIndex) : ''
|
||||
}
|
@ -1,7 +1,16 @@
|
||||
<template>
|
||||
<div class="add-discussion">
|
||||
<!-- 页面标题 -->
|
||||
<h1 class="page-title">添加讨论</h1>
|
||||
<div class="header-section">
|
||||
<n-button quaternary circle size="large" @click="goBack">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowBackOutline />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
<h1 class="page-title">添加讨论</h1>
|
||||
</div>
|
||||
|
||||
<!-- 讨论表单 -->
|
||||
<div class="discussion-form">
|
||||
@ -13,19 +22,10 @@
|
||||
<!-- 富文本编辑器 -->
|
||||
<div class="rich-editor">
|
||||
<div style="border: 1px solid #ccc">
|
||||
<Toolbar
|
||||
style="border-bottom: 1px solid #ccc"
|
||||
:editor="editorRef"
|
||||
:defaultConfig="toolbarConfig"
|
||||
:mode="mode"
|
||||
/>
|
||||
<Editor
|
||||
style="height: 300px; overflow-y: hidden;"
|
||||
v-model="discussionContent"
|
||||
:defaultConfig="editorConfig"
|
||||
:mode="mode"
|
||||
@onCreated="handleCreated"
|
||||
/>
|
||||
<Toolbar style="border-bottom: 1px solid #ccc" :editor="editorRef" :defaultConfig="toolbarConfig"
|
||||
:mode="mode" />
|
||||
<Editor style="height: 300px; overflow-y: hidden;" v-model="discussionContent" :defaultConfig="editorConfig"
|
||||
:mode="mode" @onCreated="handleCreated" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -37,7 +37,8 @@
|
||||
<div class="section-selector-wrapper">
|
||||
<button @click="toggleSectionSelector" class="section-selector" :class="{ active: showSectionSelector }">
|
||||
<span>{{ selectedSection || '选择章节' }}</span>
|
||||
<img :src="showSectionSelector ? '/images/teacher/箭头-蓝.png' : '/images/teacher/箭头-灰.png'" alt="箭头" class="arrow-icon" />
|
||||
<img :src="showSectionSelector ? '/images/teacher/箭头-蓝.png' : '/images/teacher/箭头-灰.png'" alt="箭头"
|
||||
class="arrow-icon" />
|
||||
</button>
|
||||
|
||||
<!-- 章节选择弹窗 -->
|
||||
@ -56,11 +57,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="form-actions">
|
||||
<n-button @click="cancelDiscussion">取消</n-button>
|
||||
<n-button type="primary" @click="publishDiscussion">发布</n-button>
|
||||
</div>
|
||||
<!-- 操作按钮 -->
|
||||
<div class="form-actions">
|
||||
<n-button @click="cancelDiscussion">取消</n-button>
|
||||
<n-button type="primary" @click="publishDiscussion">发布</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户协议 -->
|
||||
@ -77,10 +78,15 @@ import { NButton } from 'naive-ui'
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
// @ts-ignore
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { ArrowBackOutline } from '@vicons/ionicons5'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 编辑器实例,必须用 shallowRef
|
||||
const editorRef = shallowRef()
|
||||
|
||||
@ -177,11 +183,17 @@ onMounted(() => {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-section{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.discussion-form {
|
||||
|
@ -60,6 +60,7 @@
|
||||
|
||||
<!-- 补交时间选择器 -->
|
||||
<div class="form-item" v-if="formData.allowLateSubmission">
|
||||
<label class="form-label"></label>
|
||||
<n-date-picker
|
||||
v-model:value="formData.lateSubmissionTime"
|
||||
type="datetime"
|
||||
@ -73,7 +74,7 @@
|
||||
<div class="form-column">
|
||||
<!-- 所属章节 -->
|
||||
<div class="form-item">
|
||||
<label class="form-label required">所属章节:</label>
|
||||
<label class="form-label">所属章节:</label>
|
||||
<n-select
|
||||
v-model:value="formData.chapter"
|
||||
:options="chapterOptions"
|
||||
|
@ -28,18 +28,21 @@
|
||||
</template>
|
||||
</n-button>
|
||||
<div class="title" v-if="isAddMode">新建章节</div>
|
||||
<div class="title" v-else>编辑章节</div>
|
||||
<div class="title" v-else-if="isEditMode">编辑章节</div>
|
||||
<div class="title" v-else>章节编辑器</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="single-chapter-container">
|
||||
<div class="chapter-item flex-row">
|
||||
<span class="chapter-title">{{ currentChapter.name || '暂未设置章节名' }}</span>
|
||||
<!-- 排序更新加载指示器 -->
|
||||
<n-spin v-if="updatingSectionSort" size="small" class="sort-loading-indicator" />
|
||||
</div>
|
||||
<draggable v-model="currentChapter.sections" item-key="id" class="sortable-section-list"
|
||||
handle=".section-drag-handle" animation="200" ghost-class="ghost" drag-class="drag" chosen-class="chosen"
|
||||
:delay="0" :delay-on-touch-start="0" :touch-start-threshold="0" :force-fallback="false" @start="onDragStart"
|
||||
@end="onDragEnd">
|
||||
:delay="0" :delay-on-touch-start="0" :touch-start-threshold="0" :force-fallback="false"
|
||||
:disabled="updatingSectionSort" @start="onDragStart" @end="onDragEnd">
|
||||
<template #item="{ element: section }">
|
||||
<div class="chapter-content-item flex-row section-drag-item"
|
||||
:class="{ 'active': activeCollapsePanels.includes(section.id.toString()) }"
|
||||
@ -363,6 +366,7 @@ const message = useMessage();
|
||||
// 加载状态
|
||||
// const loading = ref(false); // 已删除,不再使用
|
||||
const saving = ref(false);
|
||||
const updatingSectionSort = ref(false); // 小节排序更新状态
|
||||
|
||||
// 控制删除确认模态框的显示状态
|
||||
const showDeleteModal = ref(false);
|
||||
@ -405,7 +409,7 @@ const activeCollapsePanels = ref<string[]>(['1']);
|
||||
|
||||
// 定义章节的类型
|
||||
interface Chapter {
|
||||
id: number;
|
||||
id: number | string; // 支持数字和字符串ID
|
||||
name: string;
|
||||
sections: any[];
|
||||
}
|
||||
@ -440,6 +444,7 @@ const currentChapter = computed(() => chapter.value);
|
||||
|
||||
// 当前页面编辑模式 新增or编辑
|
||||
const isAddMode = computed(() => route.query.mode === 'add');
|
||||
const isEditMode = computed(() => route.query.mode === 'edit');
|
||||
|
||||
// 小节类型选项
|
||||
const sectionTypes = [
|
||||
@ -526,27 +531,41 @@ const saveChapter = async () => {
|
||||
console.log('📋 课程ID:', courseId);
|
||||
console.log('🔍 模式检测:', { isAddMode, editChapterId, currentChapterId: currentChapter.value.id });
|
||||
|
||||
// 获取章节的sortOrder(如果是编辑模式)
|
||||
const editChapterData = history.state?.editChapterData;
|
||||
const chapterSortOrder = isEditMode.value && editChapterData ? editChapterData.sortOrder : null;
|
||||
|
||||
// 构建保存数据 - 使用新API的数据结构
|
||||
const chapterData = {
|
||||
course_id: courseId,
|
||||
courseId: courseId,
|
||||
name: currentChapter.value.name.trim(),
|
||||
type: 0, // 视频类型
|
||||
sort_order: 10, // 固定排序
|
||||
parent_id: '0', // 章的父ID为0
|
||||
level: 1 // 1=章
|
||||
type: null, // 章节没有类型,只有小节有
|
||||
sortOrder: chapterSortOrder, // 使用从编辑数据中获取的排序
|
||||
parentId: null, // 章的父ID为null
|
||||
level: 1 // 1=章 2=节
|
||||
};
|
||||
|
||||
console.log('<EFBFBD> 保存章节数据:', chapterData);
|
||||
console.log('📊 保存章节数据:', chapterData);
|
||||
let response;
|
||||
if (isAddMode || currentChapter.value.id === 0) {
|
||||
|
||||
// 判断是新增还是编辑模式 - 保持ID的原始类型
|
||||
if (isAddMode.value || !currentChapter.value.id || currentChapter.value.id === 0 || currentChapter.value.id === '0') {
|
||||
// 新增章节
|
||||
console.log('🆕 创建新章节');
|
||||
response = await TeachCourseApi.createCourseSection(chapterData);
|
||||
|
||||
console.log('🔍 创建章节响应:', response);
|
||||
|
||||
// 如果创建成功,更新章节ID
|
||||
if (response.data && response.data.success) {
|
||||
currentChapter.value.id = response.data.result;
|
||||
console.log('✅ 章节创建成功,新ID:', currentChapter.value.id);
|
||||
}
|
||||
} else {
|
||||
// 编辑现有章节
|
||||
// 编辑现有章节 - 确保ID是字符串类型
|
||||
console.log('✏️ 编辑现有章节,ID:', currentChapter.value.id);
|
||||
response = await TeachCourseApi.editCourseSection({
|
||||
id: currentChapter.value.id.toString(),
|
||||
id: String(currentChapter.value.id), // 转换为字符串但不改变原始数据
|
||||
...chapterData
|
||||
});
|
||||
}
|
||||
@ -555,10 +574,41 @@ const saveChapter = async () => {
|
||||
|
||||
// 检查不同的成功响应格式
|
||||
if (response.data && (response.data.success === true || response.data.code === 200 || response.data.code === 0)) {
|
||||
message.success('章节保存成功!');
|
||||
|
||||
// 移除自动返回,让用户可以继续编辑
|
||||
console.log('✅ 章节保存成功');
|
||||
console.log('✅ 章节保存成功,开始保存小节...');
|
||||
|
||||
// 如果是新增章节,更新所有小节的父ID
|
||||
if (isAddMode.value || !currentChapter.value.id || currentChapter.value.id === 0 || currentChapter.value.id === '0') {
|
||||
if (response.data.success && response.data.result) {
|
||||
const newChapterId = response.data.result;
|
||||
currentChapter.value.sections.forEach(section => {
|
||||
// 如果是新创建的小节,更新其父ID引用
|
||||
if (!section.id || section.id === 0 || section.id === '0') {
|
||||
section.parentChapterId = newChapterId;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 章节保存成功后,批量保存所有小节
|
||||
let allSectionsSaved = true;
|
||||
for (let i = 0; i < currentChapter.value.sections.length; i++) {
|
||||
const section = currentChapter.value.sections[i];
|
||||
if (section.lessonName && section.lessonName.trim()) {
|
||||
const sectionSaved = await saveSection(section, i + 1); // 传递排序号,从1开始
|
||||
if (!sectionSaved) {
|
||||
allSectionsSaved = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allSectionsSaved) {
|
||||
message.success('章节和所有小节保存成功!');
|
||||
router.back(); // 保存成功后返回上一页
|
||||
} else {
|
||||
message.warning('章节保存成功,但部分小节保存失败');
|
||||
}
|
||||
|
||||
console.log('✅ 章节和小节保存完成');
|
||||
} else {
|
||||
console.error('❌ 章节保存失败,响应数据:', response.data);
|
||||
message.error('章节保存失败:' + (response.data?.message || '未知错误'));
|
||||
@ -571,6 +621,115 @@ const saveChapter = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 保存单个小节
|
||||
const saveSection = async (section: any, sortOrder: number = 1) => {
|
||||
console.log('💾 保存小节函数被调用', section, 'sortOrder:', sortOrder);
|
||||
|
||||
try {
|
||||
if (!userStore.user?.id) {
|
||||
console.log('❌ 用户未登录');
|
||||
message.error('用户未登录,无法保存小节');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!section.lessonName || !section.lessonName.trim()) {
|
||||
console.log('⚠️ 小节名称为空');
|
||||
message.error('小节名称不能为空');
|
||||
return false;
|
||||
}
|
||||
|
||||
const courseId = route.params.courseId as string;
|
||||
if (!courseId) {
|
||||
console.error('❌ 课程ID不存在');
|
||||
message.error('课程ID不存在');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 确保父章节存在且已保存
|
||||
if (!currentChapter.value.id || currentChapter.value.id === 0 || currentChapter.value.id === '0') {
|
||||
console.log('⚠️ 父章节尚未保存,先保存章节');
|
||||
message.warning('请先保存章节');
|
||||
return false;
|
||||
}
|
||||
|
||||
const sectionData = {
|
||||
courseId: courseId,
|
||||
name: section.lessonName.trim(),
|
||||
type: 0, // 基础类型,暂不传递具体的小节类型
|
||||
sortOrder: sortOrder, // 使用传入的排序号
|
||||
parentId: String(currentChapter.value.id), // 确保父章节ID是字符串
|
||||
level: 2 // 2=节
|
||||
};
|
||||
|
||||
console.log('📋 保存小节数据:', sectionData);
|
||||
console.log('🔍 小节ID信息:', {
|
||||
id: section.id,
|
||||
idType: typeof section.id,
|
||||
idValue: section.id
|
||||
});
|
||||
|
||||
let response;
|
||||
// 判断是新增还是编辑:
|
||||
// 新增条件:
|
||||
// 1. section.id不存在 (!section.id)
|
||||
// 2. section.id为0 (section.id === 0)
|
||||
// 3. section.id为字符串'0' (section.id === '0')
|
||||
// 4. section.id为null或undefined
|
||||
// 5. section.id是临时ID (以'temp_'开头的字符串)
|
||||
|
||||
const isTemporaryId = typeof section.id === 'string' && section.id.startsWith('temp_');
|
||||
const hasValidId = section.id &&
|
||||
section.id !== 0 &&
|
||||
section.id !== '0' &&
|
||||
!isTemporaryId; // 排除临时ID
|
||||
|
||||
const isNewSection = !hasValidId;
|
||||
|
||||
console.log('🔍 小节模式判断:', {
|
||||
hasValidId,
|
||||
isNewSection,
|
||||
isTemporaryId,
|
||||
sectionId: section.id,
|
||||
idType: typeof section.id
|
||||
});
|
||||
|
||||
if (isNewSection) {
|
||||
// 新增小节 - 不传递ID
|
||||
console.log('🆕 创建新小节');
|
||||
response = await TeachCourseApi.createCourseSection(sectionData);
|
||||
|
||||
// 如果创建成功,更新小节ID
|
||||
if (response.data && response.data.success && response.data.result) {
|
||||
section.id = response.data.result;
|
||||
console.log('✅ 小节创建成功,新ID:', section.id);
|
||||
}
|
||||
} else {
|
||||
// 编辑现有小节 - 确保ID是字符串类型
|
||||
console.log('✏️ 编辑现有小节,ID:', section.id);
|
||||
response = await TeachCourseApi.editCourseSection({
|
||||
id: String(section.id), // 转换为字符串但不改变原始数据
|
||||
...sectionData
|
||||
});
|
||||
}
|
||||
|
||||
console.log('🔍 保存小节完整响应:', response);
|
||||
|
||||
// 检查响应格式
|
||||
if (response.data && (response.data.success === true || response.data.code === 200 || response.data.code === 0)) {
|
||||
console.log('✅ 小节保存成功');
|
||||
return true;
|
||||
} else {
|
||||
console.error('❌ 小节保存失败,响应数据:', response.data);
|
||||
message.error('小节保存失败:' + (response.data?.message || '未知错误'));
|
||||
return false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('❌ 保存小节失败:', error);
|
||||
message.error('保存小节失败:' + (error.message || '网络错误'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!chapterToDelete.value) {
|
||||
@ -644,7 +803,7 @@ const loadChapters = async () => {
|
||||
console.log('📋 课程ID:', courseId);
|
||||
|
||||
// 检查是否为新增模式
|
||||
if (isAddMode) {
|
||||
if (isAddMode.value) {
|
||||
console.log('🆕 新增模式:创建空白章节');
|
||||
// 创建空的章节用于编辑
|
||||
const defaultChapter: Chapter = {
|
||||
@ -676,7 +835,78 @@ const loadChapters = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 编辑模式:加载现有章节数据
|
||||
// 检查是否为编辑模式且有传递的数据
|
||||
if (isEditMode.value) {
|
||||
console.log('✏️ 编辑模式:从路由state获取数据');
|
||||
const editChapterData = history.state?.editChapterData;
|
||||
|
||||
if (editChapterData) {
|
||||
console.log('📋 从state获取的章节数据:', editChapterData);
|
||||
|
||||
// 构建章节结构 - 保持原始ID类型
|
||||
const newChapter: Chapter = {
|
||||
id: editChapterData.id, // 不进行类型转换,保持原始字符串ID
|
||||
name: editChapterData.name || '未命名章节',
|
||||
sections: []
|
||||
};
|
||||
|
||||
// 转换小节数据
|
||||
if (editChapterData.sections && Array.isArray(editChapterData.sections)) {
|
||||
console.log('🔍 原始小节数据:', editChapterData.sections);
|
||||
editChapterData.sections.forEach((sectionData: any) => {
|
||||
const sectionId = sectionData.id; // 保持原始ID类型
|
||||
console.log('🔍 处理小节:', { originalId: sectionData.id, name: sectionData.name });
|
||||
|
||||
newChapter.sections.push({
|
||||
id: sectionId, // 直接使用原始ID,不进行类型转换
|
||||
lessonName: sectionData.name || '未命名小节',
|
||||
sectionType: 'video', // 默认为视频类型
|
||||
// 视频相关字段
|
||||
videoUploadOption: '',
|
||||
videoFiles: [],
|
||||
// 资料相关字段
|
||||
materialUploadOption: '',
|
||||
materialFiles: [],
|
||||
// 原有字段(保持兼容性)
|
||||
coursewareName: '课件准备PPT',
|
||||
coursewareUploadOption: '',
|
||||
coursewareFiles: [],
|
||||
contentTitle: sectionData.name || '未命名小节',
|
||||
// contentDescription: '节描述'
|
||||
});
|
||||
});
|
||||
console.log('🔍 转换后的小节数据:', newChapter.sections);
|
||||
}
|
||||
|
||||
// 如果没有小节,添加一个空的小节用于编辑
|
||||
if (newChapter.sections.length === 0) {
|
||||
newChapter.sections.push({
|
||||
id: 0,
|
||||
lessonName: '',
|
||||
sectionType: 'video',
|
||||
videoUploadOption: '',
|
||||
videoFiles: [],
|
||||
materialUploadOption: '',
|
||||
materialFiles: [],
|
||||
coursewareName: '',
|
||||
coursewareUploadOption: '',
|
||||
coursewareFiles: [],
|
||||
contentTitle: '',
|
||||
});
|
||||
}
|
||||
|
||||
// 更新章节数据
|
||||
chapter.value = newChapter;
|
||||
|
||||
// 默认展开第一个小节
|
||||
autoExpandFirstSection();
|
||||
|
||||
message.success(`成功加载章节:${chapter.value.name}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑模式:加载现有章节数据(兼容旧版本URL参数方式)
|
||||
const editChapterId = route.query.chapterId as string;
|
||||
if (editChapterId) {
|
||||
console.log('✏️ 编辑特定章节模式,章节ID:', editChapterId);
|
||||
@ -814,6 +1044,11 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkScreenSize);
|
||||
// 清理排序更新定时器
|
||||
if (sortUpdateTimeout) {
|
||||
clearTimeout(sortUpdateTimeout);
|
||||
sortUpdateTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 删除不再需要的作业相关方法,现在使用下拉框
|
||||
@ -902,7 +1137,20 @@ const handleLessonBlur = async (section: any) => {
|
||||
if (section.lessonName && section.lessonName.trim()) {
|
||||
// 同步更新 contentTitle,使侧边栏显示的名称与编辑的名称一致
|
||||
section.contentTitle = section.lessonName;
|
||||
// 移除自动保存,只有点击保存按钮才保存
|
||||
|
||||
// 如果章节已经保存(有有效ID),则自动保存小节
|
||||
if (currentChapter.value.id && currentChapter.value.id !== 0) {
|
||||
console.log('📝 章节已存在,自动保存小节');
|
||||
// 找到当前小节在数组中的索引,用于确定排序号
|
||||
const sectionIndex = currentChapter.value.sections.findIndex((s: any) => s.id === section.id);
|
||||
const sortOrder = sectionIndex >= 0 ? sectionIndex + 1 : 1;
|
||||
const saved = await saveSection(section, sortOrder);
|
||||
if (saved) {
|
||||
message.success('小节保存成功');
|
||||
}
|
||||
} else {
|
||||
console.log('📝 章节尚未保存,小节将在章节保存时一并保存');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -916,7 +1164,7 @@ const addSection = () => {
|
||||
const sectionCount = currentChapter.value.sections.length + 1;
|
||||
const lessonName = `新小节${sectionCount}`;
|
||||
const newSection: any = {
|
||||
id: Date.now(), // 使用时间戳作为临时ID,确保唯一性
|
||||
id: `temp_${Date.now()}`, // 使用字符串前缀+时间戳作为临时ID,确保唯一性且易识别
|
||||
lessonName: lessonName,
|
||||
sectionType: 'video', // 默认为视频类型
|
||||
// 视频相关字段
|
||||
@ -935,7 +1183,7 @@ const addSection = () => {
|
||||
currentChapter.value.sections.push(newSection);
|
||||
|
||||
// 自动展开新添加的小节(accordion模式下会自动关闭其他面板)
|
||||
activeCollapsePanels.value = [newSection.id.toString()];
|
||||
activeCollapsePanels.value = [newSection.id]; // 临时ID已经是字符串,不需要toString()
|
||||
};
|
||||
|
||||
// 新增的按钮点击方法
|
||||
@ -1028,18 +1276,100 @@ const cancelDeleteSection = () => {
|
||||
// 拖拽排序相关状态
|
||||
const isDragging = ref(false);
|
||||
|
||||
// 更新小节排序的函数
|
||||
const updateSectionSort = async () => {
|
||||
try {
|
||||
console.log('🔄 开始更新小节排序...');
|
||||
updatingSectionSort.value = true;
|
||||
|
||||
const courseId = route.params.courseId as string;
|
||||
|
||||
if (!courseId) {
|
||||
console.error('❌ 课程ID不存在');
|
||||
message.error('课程ID不存在');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!currentChapter.value?.id) {
|
||||
console.error('❌ 章节ID不存在');
|
||||
message.error('章节ID不存在');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 遍历所有小节,更新它们的排序
|
||||
const updatePromises = currentChapter.value.sections.map(async (section, index) => {
|
||||
// 跳过临时ID的小节(还没有保存的新小节)
|
||||
const isTemporaryId = typeof section.id === 'string' && section.id.startsWith('temp_');
|
||||
if (!section.id || section.id === 0 || section.id === '0' || isTemporaryId) {
|
||||
console.log('⏭️ 跳过临时小节:', section.lessonName);
|
||||
return Promise.resolve(); // 跳过临时小节
|
||||
}
|
||||
|
||||
console.log(`📝 更新小节 "${section.lessonName}" 排序为: ${index + 1}`);
|
||||
|
||||
const sectionData = {
|
||||
id: String(section.id),
|
||||
courseId: courseId,
|
||||
name: section.lessonName.trim(),
|
||||
type: 0,
|
||||
sortOrder: index + 1, // 从1开始的排序号
|
||||
parentId: String(currentChapter.value.id),
|
||||
level: 2
|
||||
};
|
||||
|
||||
return TeachCourseApi.editCourseSection(sectionData);
|
||||
});
|
||||
|
||||
// 等待所有更新完成
|
||||
const results = await Promise.allSettled(updatePromises);
|
||||
|
||||
// 检查结果
|
||||
const failedCount = results.filter(result =>
|
||||
result.status === 'rejected' ||
|
||||
(result.status === 'fulfilled' && result.value && !result.value.data?.success)
|
||||
).length;
|
||||
|
||||
if (failedCount === 0) {
|
||||
console.log('✅ 所有小节排序更新成功');
|
||||
message.success('小节顺序已更新');
|
||||
return true;
|
||||
} else {
|
||||
console.log(`⚠️ ${failedCount} 个小节排序更新失败`);
|
||||
message.warning(`部分小节排序更新失败 (${failedCount}个)`);
|
||||
return false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('❌ 更新小节排序失败:', error);
|
||||
message.error('更新小节排序失败:' + (error.message || '网络错误'));
|
||||
return false;
|
||||
} finally {
|
||||
updatingSectionSort.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 拖拽开始事件
|
||||
const onDragStart = () => {
|
||||
isDragging.value = true;
|
||||
console.log('开始拖拽小节');
|
||||
};
|
||||
|
||||
// 拖拽结束事件
|
||||
const onDragEnd = (event: any) => {
|
||||
// 拖拽结束事件 - 添加防抖功能
|
||||
let sortUpdateTimeout: NodeJS.Timeout | null = null;
|
||||
const onDragEnd = async (event: any) => {
|
||||
isDragging.value = false;
|
||||
console.log('拖拽结束', event);
|
||||
console.log('新的小节顺序:', currentChapter.value.sections.map(s => s.lessonName));
|
||||
message.success('小节顺序已更新');
|
||||
|
||||
// 清除之前的定时器(如果存在)
|
||||
if (sortUpdateTimeout) {
|
||||
clearTimeout(sortUpdateTimeout);
|
||||
}
|
||||
|
||||
// 设置300ms延迟,避免快速拖动时多次调用API
|
||||
sortUpdateTimeout = setTimeout(async () => {
|
||||
await updateSectionSort();
|
||||
sortUpdateTimeout = null;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 获取小节类型标签
|
||||
@ -1859,4 +2189,21 @@ const goBack = () => {
|
||||
color: #0288D1;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 排序加载指示器样式 */
|
||||
.sort-loading-indicator {
|
||||
margin-left: 8px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 拖动禁用状态 */
|
||||
.sortable-section-list[disabled] {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sortable-section-list[disabled] .section-drag-handle {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
@ -8,7 +8,7 @@
|
||||
<n-button type="primary" @click="addChapter">添加章节</n-button>
|
||||
<n-button @click="importChapters">导入</n-button>
|
||||
<n-button @click="exportChapters">导出</n-button>
|
||||
<n-button type="error" :disabled="selectedChapters.length === 0" @click="deleteSelected">删除</n-button>
|
||||
<!-- <n-button type="error" :disabled="selectedChapters.length === 0" @click="deleteSelected">删除</n-button> -->
|
||||
<div class="search-container">
|
||||
<n-input v-model:value="searchKeyword" placeholder="请输入想要搜索的内容" style="width: 200px;">
|
||||
</n-input>
|
||||
@ -64,14 +64,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, h } from 'vue'
|
||||
import { NButton, useMessage, NDataTable, NInput, NSpace } from 'naive-ui'
|
||||
import { NButton, useMessage, NDataTable, NInput, NSpace, NIcon, useDialog } from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ChevronForwardOutline } from '@vicons/ionicons5'
|
||||
import ImportModal from '@/components/common/ImportModal.vue'
|
||||
import TeachCourseApi from '@/api/modules/teachCourse'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const dialog = useDialog()
|
||||
|
||||
// 获取当前课程ID
|
||||
const courseId = ref(route.params.id as string)
|
||||
@ -90,6 +92,7 @@ interface Chapter {
|
||||
expanded?: boolean
|
||||
level?: number
|
||||
parentId?: string
|
||||
sortOrder?: number | null // 添加sortOrder字段用于排序
|
||||
}
|
||||
|
||||
const showImportModal = ref(false)
|
||||
@ -117,6 +120,9 @@ const selectedChapters = ref<string[]>([])
|
||||
// 章节列表数据
|
||||
const chapterList = ref<Chapter[]>([])
|
||||
|
||||
// 章节排序输入值(用于双向绑定)
|
||||
const chapterSortValues = ref<Record<string, string>>({})
|
||||
|
||||
// 扁平化章节列表(用于显示和分页)
|
||||
const flattenedChapters = computed(() => {
|
||||
const result: Chapter[] = []
|
||||
@ -125,9 +131,27 @@ const flattenedChapters = computed(() => {
|
||||
const chapters = chapterList.value.filter(item => item.level === 1)
|
||||
const sections = chapterList.value.filter(item => item.level === 2)
|
||||
|
||||
// 对章节按sortOrder排序,null值排在最后
|
||||
const sortedChapters = chapters.sort((a, b) => {
|
||||
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)
|
||||
})
|
||||
|
||||
// 为每个章节添加其子节
|
||||
chapters.forEach(chapter => {
|
||||
chapter.children = sections.filter(section => section.parentId === chapter.id)
|
||||
sortedChapters.forEach(chapter => {
|
||||
const chapterSections = sections.filter(section => section.parentId === chapter.id)
|
||||
|
||||
// 对子节按sortOrder排序,null值排在最后
|
||||
const sortedSections = chapterSections.sort((a, b) => {
|
||||
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)
|
||||
})
|
||||
|
||||
chapter.children = sortedSections
|
||||
result.push(chapter)
|
||||
|
||||
// 如果章节展开,添加其子节
|
||||
@ -248,31 +272,180 @@ const exportChapters = () => {
|
||||
message.info('导出章节功能')
|
||||
}
|
||||
|
||||
const deleteSelected = async () => {
|
||||
if (selectedChapters.value.length === 0) return
|
||||
// 删除逻辑
|
||||
message.info('删除选中章节')
|
||||
}
|
||||
// const deleteSelected = async () => {
|
||||
// if (selectedChapters.value.length === 0) return
|
||||
// // 删除逻辑
|
||||
// message.info('删除选中章节')
|
||||
// }
|
||||
|
||||
const searchChapters = async () => {
|
||||
// 搜索逻辑
|
||||
message.info('搜索章节')
|
||||
}
|
||||
|
||||
const editChapter = (chapter: Chapter) => {
|
||||
const editChapter = async (chapter: Chapter) => {
|
||||
console.log('编辑章节:', chapter)
|
||||
// 跳转到章节编辑器页面,传递章节ID参数表示编辑模式
|
||||
const courseId = route.params.id
|
||||
if (courseId) {
|
||||
router.push(`/teacher/chapter-editor-teacher/${courseId}?chapterId=${chapter.id}`)
|
||||
} else {
|
||||
|
||||
const courseId = route.params.id as string
|
||||
if (!courseId) {
|
||||
message.error('课程ID不存在')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取该章节下的所有小节数据
|
||||
const sectionsResponse = await TeachCourseApi.getCourseSections(courseId)
|
||||
const allSections = sectionsResponse.data.result || []
|
||||
|
||||
// 筛选出属于当前章节的小节
|
||||
const chapterSections = allSections.filter((section: any) =>
|
||||
section.level === 2 && section.parentId === chapter.id
|
||||
)
|
||||
|
||||
// 构建完整的章节数据结构
|
||||
const editChapterData = {
|
||||
id: chapter.id,
|
||||
name: chapter.name,
|
||||
type: chapter.type,
|
||||
level: chapter.level,
|
||||
parentId: chapter.parentId,
|
||||
sortOrder: chapter.sortOrder, // 添加章节的sortOrder
|
||||
sections: chapterSections.map((section: any) => ({
|
||||
id: section.id,
|
||||
name: section.name,
|
||||
type: section.type,
|
||||
sortOrder: section.sortOrder,
|
||||
parentId: section.parentId,
|
||||
level: section.level,
|
||||
createTime: section.createTime
|
||||
}))
|
||||
}
|
||||
|
||||
console.log('编辑章节完整数据:', editChapterData)
|
||||
|
||||
// 通过路由state传递数据,而不是URL参数
|
||||
router.push({
|
||||
path: `/teacher/chapter-editor-teacher/${courseId}`,
|
||||
query: { mode: 'edit' },
|
||||
state: { editChapterData }
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取章节数据失败:', error)
|
||||
message.error('获取章节数据失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteChapter = async (chapter: Chapter) => {
|
||||
// 删除单个章节
|
||||
message.info(`删除章节: ${chapter.name}`)
|
||||
const isChapterLevel = chapter.level === 1; // 是否为章节(level=1)
|
||||
const chapterName = chapter.name;
|
||||
|
||||
// 如果是章节,需要检查是否有下属小节
|
||||
let confirmMessage = `确定要删除"${chapterName}"吗?`;
|
||||
if (isChapterLevel) {
|
||||
// 查找该章节下的所有小节
|
||||
const childSections = chapterList.value.filter(item =>
|
||||
item.level === 2 && item.parentId === chapter.id
|
||||
);
|
||||
|
||||
if (childSections.length > 0) {
|
||||
confirmMessage = `确定要删除章节"${chapterName}"吗?\n\n删除章节将同时删除其下的 ${childSections.length} 个小节:\n${childSections.map(s => s.name).join('、')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 二次确认弹窗
|
||||
dialog.warning({
|
||||
title: '删除确认',
|
||||
content: confirmMessage,
|
||||
positiveText: '确定删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
if (isChapterLevel) {
|
||||
// 如果是章节,需要先删除其下的所有小节,再删除章节本身
|
||||
const childSections = chapterList.value.filter(item =>
|
||||
item.level === 2 && item.parentId === chapter.id
|
||||
);
|
||||
|
||||
// 先删除所有子小节
|
||||
for (const section of childSections) {
|
||||
console.log('删除小节:', section.name, 'ID:', section.id);
|
||||
await TeachCourseApi.deleteCourseSection(section.id);
|
||||
}
|
||||
|
||||
// 再删除章节本身
|
||||
console.log('删除章节:', chapter.name, 'ID:', chapter.id);
|
||||
await TeachCourseApi.deleteCourseSection(chapter.id);
|
||||
|
||||
message.success(`章节"${chapterName}"及其下属小节删除成功`);
|
||||
} else {
|
||||
// 如果是小节,直接删除
|
||||
console.log('删除小节:', chapter.name, 'ID:', chapter.id);
|
||||
await TeachCourseApi.deleteCourseSection(chapter.id);
|
||||
|
||||
message.success(`小节"${chapterName}"删除成功`);
|
||||
}
|
||||
|
||||
// 重新加载章节列表
|
||||
await fetchCourseChapters();
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('删除失败:', error);
|
||||
message.error(`删除失败:${error.message || '未知错误'}`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
message.info('已取消删除');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 更新章节排序
|
||||
const updateChapterSort = async (chapter: Chapter, newSortOrder: number | null) => {
|
||||
console.log('更新章节排序:', chapter.name, '新排序:', newSortOrder)
|
||||
|
||||
if (chapter.sortOrder === newSortOrder) {
|
||||
// 排序没有变化,不需要更新
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建更新数据
|
||||
const updateData = {
|
||||
id: chapter.id,
|
||||
courseId: courseId.value,
|
||||
name: chapter.name,
|
||||
type: chapter.type === '-' ? null : parseInt(chapter.type) || null,
|
||||
sortOrder: newSortOrder,
|
||||
parentId: chapter.parentId || null,
|
||||
level: chapter.level || 1
|
||||
}
|
||||
|
||||
console.log('发送章节排序更新请求:', updateData)
|
||||
|
||||
// 调用编辑接口更新排序
|
||||
const response = await TeachCourseApi.editCourseSection(updateData)
|
||||
|
||||
if (response.data && (response.data.success === true || response.data.code === 200 || response.data.code === 0)) {
|
||||
// 更新成功,更新本地数据
|
||||
chapter.sortOrder = newSortOrder
|
||||
chapterSortValues.value[chapter.id] = newSortOrder?.toString() || ''
|
||||
message.success(`章节 "${chapter.name}" 排序更新成功`)
|
||||
|
||||
// 重新加载数据以确保排序正确
|
||||
fetchCourseChapters()
|
||||
} else {
|
||||
console.error('章节排序更新失败:', response.data)
|
||||
message.error('章节排序更新失败:' + (response.data?.message || '未知错误'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('更新章节排序失败:', error)
|
||||
message.error('更新章节排序失败:' + (error.message || '网络错误'))
|
||||
}
|
||||
}
|
||||
|
||||
// 表格列配置 - 使用 minWidth 实现响应式
|
||||
@ -295,22 +468,24 @@ const columns: DataTableColumns<Chapter> = [
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: isChapter ? 'pointer' : 'default',
|
||||
cursor: (isChapter && row.children && row.children.length > 0) ? 'pointer' : 'default',
|
||||
marginLeft: isChapter ? '0px' : '-3px'
|
||||
},
|
||||
onClick: isChapter ? () => toggleChapter(row) : undefined
|
||||
onClick: (isChapter && row.children && row.children.length > 0) ? () => toggleChapter(row) : undefined
|
||||
}, [
|
||||
isChapter ? h('i', {
|
||||
// 只有章节且有子节时才显示箭头
|
||||
(isChapter && row.children && row.children.length > 0) ? h(NIcon, {
|
||||
size: 14,
|
||||
style: {
|
||||
marginRight: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
transition: 'transform 0.2s',
|
||||
transform: row.expanded ? 'rotate(90deg)' : 'rotate(0deg)'
|
||||
transform: row.expanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
cursor: 'pointer'
|
||||
}
|
||||
}, [
|
||||
'▶'
|
||||
]) : null,
|
||||
}, {
|
||||
default: () => h(ChevronForwardOutline)
|
||||
}) : (isChapter ? h('span', { style: { marginRight: '22px' } }) : null),
|
||||
h('span', {
|
||||
style: { color: '#062333', fontSize: '13px' }
|
||||
}, row.name)
|
||||
@ -340,13 +515,46 @@ const columns: DataTableColumns<Chapter> = [
|
||||
{
|
||||
title: '排序',
|
||||
key: 'sort',
|
||||
minWidth: 50,
|
||||
minWidth: 100,
|
||||
render: (row: Chapter) => {
|
||||
const isChapter = row.level === 1; // level=1 表示章
|
||||
if (isChapter) {
|
||||
return h('span', { style: { color: '#BABABA' } }, '-')
|
||||
// 章节显示可编辑的排序输入框
|
||||
return h('div', {
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}
|
||||
}, [
|
||||
h(NInput, {
|
||||
value: chapterSortValues.value[row.id] ?? (row.sortOrder?.toString() || ''),
|
||||
size: 'small',
|
||||
style: { width: '60px', textAlign: 'center' },
|
||||
placeholder: '排序',
|
||||
'onUpdate:value': (value: string) => {
|
||||
// 实时更新输入框的值
|
||||
chapterSortValues.value[row.id] = value;
|
||||
},
|
||||
onBlur: () => {
|
||||
const inputValue = chapterSortValues.value[row.id];
|
||||
const newSortOrder = inputValue ? parseInt(inputValue) || null : null;
|
||||
updateChapterSort(row, newSortOrder);
|
||||
},
|
||||
onKeydown: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
const inputValue = chapterSortValues.value[row.id];
|
||||
const newSortOrder = inputValue ? parseInt(inputValue) || null : null;
|
||||
updateChapterSort(row, newSortOrder);
|
||||
(e.target as HTMLInputElement).blur(); // 失去焦点
|
||||
}
|
||||
}
|
||||
})
|
||||
])
|
||||
} else {
|
||||
// 小节显示固定的排序值
|
||||
return h('span', { style: { color: '#062333', fontSize: '12px' } }, row.sort)
|
||||
}
|
||||
return h('span', { style: { color: '#062333', fontSize: '12px' } }, row.sort)
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -362,41 +570,72 @@ const columns: DataTableColumns<Chapter> = [
|
||||
key: 'actions',
|
||||
minWidth: 160,
|
||||
render: (row: Chapter) => {
|
||||
return h('div', { style: { display: 'flex', gap: '16px', alignItems: 'center', justifyContent: 'center' } }, [
|
||||
h(NButton, {
|
||||
size: 'small',
|
||||
type: 'info',
|
||||
secondary: true,
|
||||
onClick: () => editChapter(row)
|
||||
}, { default: () => '编辑' }),
|
||||
h(NButton, {
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
secondary: true,
|
||||
onClick: () => deleteChapter(row)
|
||||
}, { default: () => '删除' })
|
||||
])
|
||||
const isChapter = row.level === 1; // level=1 表示章
|
||||
|
||||
if (isChapter) {
|
||||
// 章节显示编辑和删除按钮
|
||||
return h('div', { style: { display: 'flex', gap: '16px', alignItems: 'center', justifyContent: 'center' } }, [
|
||||
h(NButton, {
|
||||
size: 'small',
|
||||
type: 'info',
|
||||
secondary: true,
|
||||
onClick: () => editChapter(row)
|
||||
}, { default: () => '编辑' }),
|
||||
h(NButton, {
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
secondary: true,
|
||||
onClick: () => deleteChapter(row)
|
||||
}, { default: () => '删除' })
|
||||
])
|
||||
} else {
|
||||
// 小节只显示删除按钮
|
||||
return h('div', { style: { display: 'flex', gap: '16px', alignItems: 'center', justifyContent: 'center' } }, [
|
||||
h(NButton, {
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
secondary: true,
|
||||
onClick: () => deleteChapter(row)
|
||||
}, { default: () => '删除' })
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const fetchCourseChapters = () => {
|
||||
loading.value = true
|
||||
TeachCourseApi.getCourseSections(courseId.value).then(res => {
|
||||
console.log('章节数据:', res.data)
|
||||
// 将API返回的CourseSection数据映射为本地Chapter格式
|
||||
const sections = res.data.result || []
|
||||
chapterList.value = sections.map((section): Chapter => ({
|
||||
chapterList.value = sections.map((section: any): Chapter => ({
|
||||
id: section.id || '0',
|
||||
name: section.name || '',
|
||||
type: section.type?.toString() || '-',
|
||||
sort: section.sort_order?.toString() || '-',
|
||||
sort: section.sortOrder?.toString() || '-', // 根据API数据使用sortOrder
|
||||
createTime: section.createTime || '', // 根据API数据使用createTime
|
||||
isParent: (section.level || 0) === 1,
|
||||
expanded: false,
|
||||
level: section.level || 0, // 添加level字段
|
||||
parentId: section.parentId || '', // 根据API数据使用parentId
|
||||
sortOrder: section.sortOrder || null, // 添加sortOrder字段用于排序功能
|
||||
children: []
|
||||
}))
|
||||
console.log('处理后的章节数据:', chapterList.value)
|
||||
|
||||
// 初始化章节排序输入值
|
||||
chapterSortValues.value = {}
|
||||
chapterList.value.forEach(chapter => {
|
||||
if (chapter.level === 1) {
|
||||
chapterSortValues.value[chapter.id] = chapter.sortOrder?.toString() || ''
|
||||
}
|
||||
})
|
||||
}).catch(error => {
|
||||
console.error('获取章节数据失败:', error)
|
||||
message.error('获取章节数据失败')
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
@ -410,6 +649,9 @@ onMounted(() => {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 顶部工具栏 */
|
||||
|
@ -147,13 +147,13 @@ const hideSidebar = computed(() => {
|
||||
// 定义需要隐藏侧边栏的页面路径
|
||||
const hideSidebarPaths = [
|
||||
'add-question', // 新增试题页面
|
||||
'add-homework', // 添加作业页面
|
||||
// 'add-homework', // 添加作业页面
|
||||
'template-import',
|
||||
'review/',
|
||||
// 'review/',
|
||||
'certificate/detail/',
|
||||
'certificate/add',
|
||||
'comment/', // 查看讨论页面
|
||||
'discussion/add'
|
||||
// 'discussion/add'
|
||||
]
|
||||
|
||||
// 检查当前路径是否包含需要隐藏侧边栏的路径
|
||||
|
@ -1,20 +1,301 @@
|
||||
<template>
|
||||
<div class="courseware-management">
|
||||
<MyResources></MyResources>
|
||||
<div class="chapter-management">
|
||||
<!-- 头部操作区域 -->
|
||||
<div class="resources-header">
|
||||
<div class="resources-title">
|
||||
<h1>课件资源</h1>
|
||||
</div>
|
||||
<div class="resources-actions">
|
||||
<!-- <n-button @click="importChapters">导入</n-button>
|
||||
<n-button @click="exportChapters">导出</n-button> -->
|
||||
<!-- <n-button @click="">上传文件</n-button> -->
|
||||
<div class="search-container">
|
||||
<n-input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="请输入关键字"
|
||||
@keyup.enter="handleSearch">
|
||||
<template #suffix>
|
||||
<n-icon>
|
||||
<Search />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件网格 -->
|
||||
<div class="files-grid">
|
||||
<div v-for="folder in filteredFolders" :key="folder.id" class="file-item"
|
||||
@click="handleFolderClick(folder)">
|
||||
<!-- 文件图标 -->
|
||||
<div class="file-icon" @mouseenter.stop="showInfoCard(folder, $event)" @mouseleave.stop="hideInfoCard">
|
||||
<img src="/images/profile/folder.png" alt="文件夹图标" class="folder-icon">
|
||||
</div>
|
||||
<!-- 文件名称 -->
|
||||
<div class="file-name" :title="folder.name">
|
||||
{{ folder.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 悬停信息卡片(固定定位,显示在文件图标右侧) -->
|
||||
<!-- <FileInfoCard v-if="infoCardVisible && currentHoverFolder" :name="currentHoverFolder.name"
|
||||
:size="formatFileSize(currentHoverFolder.fileCount)" :modified="formatDate(currentHoverFolder.modifiedAt)"
|
||||
:style="{ position: 'fixed', top: infoCardPosition.top + 'px', left: infoCardPosition.left + 'px', zIndex: 3000 }" /> -->
|
||||
<!-- <ImportModal v-model:show="showImportModal" template-name="custom_template.xlsx" import-type="custom"
|
||||
@success="handleImportSuccess" @template-download="handleTemplateDownload" /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MyResources from '@/components/admin/MyResources.vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { NInput, NIcon } from 'naive-ui'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { Search } from '@vicons/ionicons5'
|
||||
// import ImportModal from '@/components/common/ImportModal.vue'
|
||||
// import FileInfoCard from '@/components/admin/FileInfoCard.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 文件夹类型定义
|
||||
interface FolderItem {
|
||||
id: string|number
|
||||
name: string
|
||||
type: 'folder'
|
||||
fileCount: number
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
// const showImportModal = ref(false)
|
||||
|
||||
// const handleImportSuccess = () => {
|
||||
// message.success('章节导入成功')
|
||||
// }
|
||||
|
||||
// const handleTemplateDownload = () => {
|
||||
// message.success('模板下载成功')
|
||||
// }
|
||||
|
||||
// 搜索关键词
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// 响应式数据
|
||||
// const hoveredFolder = ref<string | null>(null)
|
||||
const infoCardVisible = ref(false)
|
||||
const currentHoverFolder = ref<FolderItem | null>(null)
|
||||
const infoCardPosition = ref<{ top: number; left: number }>({ top: 0, left: 0 })
|
||||
|
||||
// 固定的三个文件夹数据(与FolderBrowser中的数据结构保持一致)
|
||||
const folders = ref<FolderItem[]>([
|
||||
{
|
||||
id: '0',
|
||||
name: '视频',
|
||||
type: 'folder',
|
||||
fileCount: 8,
|
||||
modifiedAt: '2024-01-14 14:30'
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
name: '图片',
|
||||
type: 'folder',
|
||||
fileCount: 12,
|
||||
modifiedAt: '2024-01-15 09:25'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '文档',
|
||||
type: 'folder',
|
||||
fileCount: 25,
|
||||
modifiedAt: '2024-01-13 16:45'
|
||||
}
|
||||
])
|
||||
|
||||
// 计算属性:过滤后的文件夹列表
|
||||
const filteredFolders = computed(() => {
|
||||
if (!searchKeyword.value) {
|
||||
return folders.value
|
||||
}
|
||||
return folders.value.filter((folder: FolderItem) =>
|
||||
folder.name.toLowerCase().includes(searchKeyword.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
// 方法
|
||||
// const formatFileSize = (count: number) => {
|
||||
// return `${count} 个文件`
|
||||
// }
|
||||
|
||||
// const formatDate = (dateString: string) => {
|
||||
// return dateString
|
||||
// }
|
||||
|
||||
const handleSearch = () => {
|
||||
console.log('搜索:', searchKeyword.value)
|
||||
}
|
||||
|
||||
const handleFolderClick = (folder: FolderItem) => {
|
||||
console.log('点击文件夹:', folder.name)
|
||||
// 跳转到文件夹浏览页面
|
||||
router.push({
|
||||
name: 'FolderBrowser',
|
||||
params: {
|
||||
id: route.params.id, // 传递当前课程ID
|
||||
folderId: folder.id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 悬浮信息卡片定位到图标右侧
|
||||
const showInfoCard = (folder: FolderItem, event: MouseEvent) => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const iconRect = target.getBoundingClientRect()
|
||||
currentHoverFolder.value = folder
|
||||
infoCardPosition.value = {
|
||||
top: iconRect.top + window.scrollY,
|
||||
left: iconRect.right + window.scrollX + 12
|
||||
}
|
||||
infoCardVisible.value = true
|
||||
}
|
||||
|
||||
const hideInfoCard = () => {
|
||||
infoCardVisible.value = false
|
||||
currentHoverFolder.value = null
|
||||
}
|
||||
|
||||
// const handleItemMouseLeave = () => {
|
||||
// hideInfoCard()
|
||||
// hoveredFolder.value = null
|
||||
// }
|
||||
|
||||
// const importChapters = () => {
|
||||
// showImportModal.value = true
|
||||
// }
|
||||
|
||||
// const exportChapters = () => {
|
||||
// message.info('导出章节')
|
||||
// }
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 可以在这里加载文件夹信息
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.courseware-management {
|
||||
width: 100%;
|
||||
.chapter-management {
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
height: 100%;;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 头部操作区域 */
|
||||
.resources-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
background: white;
|
||||
padding: 20px 30px;
|
||||
border-bottom: 1px solid #E8E8E8;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resources-title h1 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.resources-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
/* 文件网格 */
|
||||
.files-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 20px;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px 15px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 1.5px solid #D8D8D8;
|
||||
background: white;
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
|
||||
.file-item:hover {
|
||||
background: #F5F8FB;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
margin-top: -20px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.resources-header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.resources-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.files-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 更小屏幕的响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.files-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
padding: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
File diff suppressed because it is too large
Load Diff
@ -31,57 +31,17 @@
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="content">
|
||||
<!-- 文件夹视图 -->
|
||||
<div v-if="currentFile && currentFile.type === 'folder'" class="folder-view">
|
||||
<div class="folder-header">
|
||||
<h3>{{ currentFile.name }}</h3>
|
||||
<div class="folder-info">
|
||||
<span>创建时间:{{ currentFile.createTime }}</span>
|
||||
<span>创建人:{{ currentFile.creator }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹内容列表 -->
|
||||
<div class="folder-content">
|
||||
<div class="file-grid">
|
||||
<div
|
||||
v-for="item in folderItems"
|
||||
:key="item.id"
|
||||
class="file-item"
|
||||
@click="viewItem(item)"
|
||||
@dblclick="openItem(item)"
|
||||
>
|
||||
<div class="file-icon">
|
||||
<img :src="getFileIcon(item.type)" :alt="item.type" />
|
||||
<div v-if="item.isTop" class="top-badge">置顶</div>
|
||||
</div>
|
||||
<div class="file-name" :title="item.name">{{ item.name }}</div>
|
||||
<div class="file-meta">
|
||||
<span class="file-size">{{ item.size }}</span>
|
||||
<span class="file-time">{{ formatTime(item.createTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="folderItems.length === 0" class="empty-state">
|
||||
<div class="empty-icon">📁</div>
|
||||
<p>该文件夹暂无内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件预览视图 -->
|
||||
<div v-else-if="currentFile" class="file-preview">
|
||||
<div v-if="currentFile" class="file-preview">
|
||||
<div class="file-header">
|
||||
<div class="file-info">
|
||||
<img :src="getFileIcon(currentFile.type)" :alt="currentFile.type" class="file-type-icon" />
|
||||
<div class="file-type-icon">
|
||||
<n-icon size="64" :component="getFileIcon(currentFile.type)" />
|
||||
</div>
|
||||
<div class="file-details">
|
||||
<h3>{{ currentFile.name }}</h3>
|
||||
<h3>{{ getDisplayFileName(currentFile) }}</h3>
|
||||
<div class="file-meta">
|
||||
<span>文件大小:{{ currentFile.size }}</span>
|
||||
<span>创建时间:{{ currentFile.createTime }}</span>
|
||||
<span>创建人:{{ currentFile.creator }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -91,14 +51,14 @@
|
||||
<div class="preview-content">
|
||||
<!-- 图片预览 -->
|
||||
<div v-if="isImage(currentFile.type)" class="image-preview">
|
||||
<img :src="getPreviewUrl(currentFile)" :alt="currentFile.name" />
|
||||
<img :src="getPreviewUrl(currentFile)" :alt="getDisplayFileName(currentFile)" />
|
||||
</div>
|
||||
|
||||
<!-- 视频预览 -->
|
||||
<div v-else-if="isVideo(currentFile.type)" class="video-preview">
|
||||
<video controls :src="getPreviewUrl(currentFile)">
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
<div class="video-container">
|
||||
<div ref="dplayerRef" class="dplayer-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF预览 -->
|
||||
@ -110,10 +70,10 @@
|
||||
<div v-else-if="isDocument(currentFile.type)" class="document-preview">
|
||||
<div class="document-placeholder">
|
||||
<div class="file-preview-icon">
|
||||
<img :src="getFileIcon(currentFile.type)" :alt="currentFile.type" />
|
||||
<n-icon size="64" :component="getFileIcon(currentFile.type)" />
|
||||
</div>
|
||||
<div class="file-preview-info">
|
||||
<h4>{{ currentFile.name }}</h4>
|
||||
<h4>{{ getDisplayFileName(currentFile) }}</h4>
|
||||
<p class="file-description">该文件类型暂不支持在线预览</p>
|
||||
<n-button type="primary" @click="downloadFile">下载查看</n-button>
|
||||
</div>
|
||||
@ -124,10 +84,10 @@
|
||||
<div v-else class="unsupported-preview">
|
||||
<div class="unsupported-placeholder">
|
||||
<div class="file-preview-icon">
|
||||
<img :src="getFileIcon(currentFile.type)" :alt="currentFile.type" />
|
||||
<n-icon size="64" :component="getFileIcon(currentFile.type)" />
|
||||
</div>
|
||||
<div class="file-preview-info">
|
||||
<h4>{{ currentFile.name }}</h4>
|
||||
<h4>{{ getDisplayFileName(currentFile) }}</h4>
|
||||
<p class="file-description">该文件类型暂不支持预览</p>
|
||||
<n-button type="primary" @click="downloadFile">下载查看</n-button>
|
||||
</div>
|
||||
@ -146,12 +106,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { ArrowBackOutline } from '@vicons/ionicons5'
|
||||
import { downloadFileFromUrl, getFileExtension } from '@/utils/download'
|
||||
import {
|
||||
FolderOutline,
|
||||
VideocamOutline,
|
||||
ImageOutline,
|
||||
DocumentTextOutline,
|
||||
GridOutline,
|
||||
EaselOutline,
|
||||
AttachOutline
|
||||
} from '@vicons/ionicons5'
|
||||
|
||||
// 声明全局 DPlayer 类型
|
||||
declare global {
|
||||
interface Window {
|
||||
DPlayer: any
|
||||
Hls: any
|
||||
}
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
@ -166,92 +143,93 @@ interface FileItem {
|
||||
isTop: boolean
|
||||
children?: FileItem[]
|
||||
parentId?: number
|
||||
originalData?: any // 保存API返回的原始数据
|
||||
}
|
||||
|
||||
// 当前查看的文件/文件夹
|
||||
const currentFile = ref<FileItem | null>(null)
|
||||
// 面包屑导航
|
||||
const breadcrumbs = ref<FileItem[]>([])
|
||||
// 文件夹内容
|
||||
const folderItems = ref<FileItem[]>([])
|
||||
|
||||
// 模拟文件数据(实际项目中应该从API获取)
|
||||
const mockFileData: FileItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: '教学资料文件夹',
|
||||
type: 'folder',
|
||||
size: '1MB',
|
||||
creator: '王建国',
|
||||
createTime: '2025.07.25 09:20',
|
||||
isTop: true,
|
||||
children: [
|
||||
{
|
||||
id: 2,
|
||||
name: '课程大纲.xlsx',
|
||||
type: 'excel',
|
||||
size: '1MB',
|
||||
creator: '王建国',
|
||||
createTime: '2025.07.25 09:20',
|
||||
isTop: false,
|
||||
parentId: 1
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '教学计划.docx',
|
||||
type: 'word',
|
||||
size: '2MB',
|
||||
creator: '王建国',
|
||||
createTime: '2025.07.25 09:20',
|
||||
isTop: false,
|
||||
parentId: 1
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '教学PPT.pptx',
|
||||
type: 'ppt',
|
||||
size: '5MB',
|
||||
creator: '王建国',
|
||||
createTime: '2025.07.25 09:20',
|
||||
isTop: false,
|
||||
parentId: 1
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '教学视频.mp4',
|
||||
type: 'video',
|
||||
size: '100MB',
|
||||
creator: '王建国',
|
||||
createTime: '2025.07.25 09:20',
|
||||
isTop: false,
|
||||
parentId: 1
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: '学习指南.pdf',
|
||||
type: 'pdf',
|
||||
size: '3MB',
|
||||
creator: '王建国',
|
||||
createTime: '2025.07.25 09:20',
|
||||
isTop: false,
|
||||
parentId: 1
|
||||
}
|
||||
]
|
||||
// 视频播放器相关
|
||||
const dplayerRef = ref<HTMLElement | null>(null)
|
||||
const dplayer = ref<any>(null)
|
||||
const videoQualities = ref<VideoQuality[]>([])
|
||||
const currentQuality = ref<string>('')
|
||||
|
||||
// 视频画质类型定义
|
||||
interface VideoQuality {
|
||||
name: string
|
||||
url: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
// 获取文件扩展名并格式化显示名称
|
||||
const getDisplayFileName = (item: FileItem): string => {
|
||||
if (item.type === 'folder') {
|
||||
return item.name
|
||||
}
|
||||
]
|
||||
|
||||
// 从原始数据的fileUrl中提取文件扩展名
|
||||
const fileUrl = item.originalData?.fileUrl || ''
|
||||
let extension = ''
|
||||
|
||||
if (fileUrl) {
|
||||
// 尝试从URL中提取扩展名
|
||||
const urlMatch = fileUrl.match(/\.([a-zA-Z0-9]+)(?:\?|$)/)
|
||||
if (urlMatch) {
|
||||
extension = urlMatch[1].toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
// 如果URL中没有扩展名,根据文件类型推断
|
||||
if (!extension) {
|
||||
switch (item.type) {
|
||||
case 'video':
|
||||
extension = 'mp4'
|
||||
break
|
||||
case 'image':
|
||||
extension = 'jpg'
|
||||
break
|
||||
case 'pdf':
|
||||
extension = 'pdf'
|
||||
break
|
||||
case 'word':
|
||||
extension = 'docx'
|
||||
break
|
||||
case 'excel':
|
||||
extension = 'xlsx'
|
||||
break
|
||||
case 'ppt':
|
||||
extension = 'pptx'
|
||||
break
|
||||
default:
|
||||
extension = 'file'
|
||||
}
|
||||
}
|
||||
|
||||
// 检查文件名是否已经包含扩展名
|
||||
const nameHasExtension = /\.[a-zA-Z0-9]+$/.test(item.name)
|
||||
|
||||
if (nameHasExtension) {
|
||||
return item.name
|
||||
} else {
|
||||
return `${item.name}.${extension}`
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件图标
|
||||
const getFileIcon = (type: string) => {
|
||||
const iconMap: { [key: string]: string } = {
|
||||
folder: '/images/teacher/folder.jpg',
|
||||
excel: '/images/activity/xls.png',
|
||||
word: '/images/activity/wrod.png',
|
||||
pdf: '/images/activity/pdf.png',
|
||||
ppt: '/images/activity/ppt.png',
|
||||
video: '/images/activity/file.png',
|
||||
image: '/images/activity/image.png'
|
||||
const iconMap: { [key: string]: any } = {
|
||||
folder: FolderOutline,
|
||||
excel: GridOutline,
|
||||
word: DocumentTextOutline,
|
||||
pdf: DocumentTextOutline,
|
||||
ppt: EaselOutline,
|
||||
video: VideocamOutline,
|
||||
image: ImageOutline
|
||||
}
|
||||
return iconMap[type] || '/images/activity/file.png'
|
||||
return iconMap[type] || AttachOutline
|
||||
}
|
||||
|
||||
// 判断文件类型
|
||||
@ -260,90 +238,318 @@ const isVideo = (type: string) => ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'v
|
||||
const isPdf = (type: string) => type === 'pdf'
|
||||
const isDocument = (type: string) => ['word', 'excel', 'ppt', 'txt'].includes(type)
|
||||
|
||||
// 获取预览URL(实际项目中应该是真实的文件URL)
|
||||
// 获取预览URL
|
||||
const getPreviewUrl = (file: FileItem) => {
|
||||
// 这里应该返回实际的文件预览URL
|
||||
return `/api/files/preview/${file.id}`
|
||||
// 使用原始数据中的 fileUrl
|
||||
return file.originalData?.fileUrl || ''
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeStr: string) => {
|
||||
return timeStr.replace(/\./g, '-')
|
||||
// 解析视频多画质URL
|
||||
const parseVideoQualities = (fileUrl: string): VideoQuality[] => {
|
||||
if (!fileUrl) return []
|
||||
|
||||
// 按逗号分割URL
|
||||
const urls = fileUrl.split(',').map(url => url.trim()).filter(url => url)
|
||||
|
||||
if (urls.length <= 1) {
|
||||
return [{
|
||||
name: '原画',
|
||||
url: fileUrl,
|
||||
type: fileUrl.includes('.m3u8') ? 'hls' : 'normal'
|
||||
}]
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// 根据ID查找文件
|
||||
const findFileById = (files: FileItem[], id: number): FileItem | null => {
|
||||
for (const file of files) {
|
||||
if (file.id === id) {
|
||||
return file
|
||||
}
|
||||
if (file.children) {
|
||||
const found = findFileById(file.children, id)
|
||||
if (found) return found
|
||||
// 从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()
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
// 尝试从路径推断
|
||||
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 '原画'
|
||||
}
|
||||
|
||||
// 构建面包屑导航
|
||||
const buildBreadcrumbs = (file: FileItem) => {
|
||||
const crumbs: FileItem[] = []
|
||||
let current = file
|
||||
// 获取分辨率数值(用于排序)
|
||||
const getResolutionValue = (quality: string): number => {
|
||||
const resolutionMap: { [key: string]: number } = {
|
||||
'1080P': 1080,
|
||||
'720P': 720,
|
||||
'480P': 480,
|
||||
'360P': 360,
|
||||
'原画': 9999
|
||||
}
|
||||
return resolutionMap[quality] || 0
|
||||
}
|
||||
|
||||
// 获取默认画质
|
||||
const getDefaultQuality = (qualities: VideoQuality[]): VideoQuality | null => {
|
||||
if (qualities.length === 0) return null
|
||||
|
||||
while (current) {
|
||||
crumbs.unshift(current)
|
||||
if (current.parentId) {
|
||||
current = findFileById(mockFileData, current.parentId) as FileItem
|
||||
} else {
|
||||
break
|
||||
// 优先选择720P
|
||||
const preferred = qualities.find(q => q.name === '720P')
|
||||
if (preferred) return preferred
|
||||
|
||||
// 其次选择1080P
|
||||
const fallback = qualities.find(q => q.name === '1080P')
|
||||
if (fallback) return fallback
|
||||
|
||||
// 最后返回第一个
|
||||
return qualities[0]
|
||||
}
|
||||
|
||||
// 获取视频画质选项
|
||||
const getVideoQualities = (file: FileItem): VideoQuality[] => {
|
||||
const fileUrl = getPreviewUrl(file)
|
||||
if (!fileUrl || !isVideo(file.type)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return parseVideoQualities(fileUrl)
|
||||
}
|
||||
|
||||
// 初始化视频播放器
|
||||
const initVideoPlayer = async (file: FileItem) => {
|
||||
await nextTick()
|
||||
|
||||
if (!dplayerRef.value) {
|
||||
console.error('视频容器未找到')
|
||||
return
|
||||
}
|
||||
|
||||
// 销毁之前的播放器
|
||||
if (dplayer.value) {
|
||||
try {
|
||||
dplayer.value.destroy()
|
||||
} catch (e) {
|
||||
console.warn('销毁播放器失败:', e)
|
||||
}
|
||||
dplayer.value = null
|
||||
}
|
||||
|
||||
const fileUrl = getPreviewUrl(file)
|
||||
if (!fileUrl) {
|
||||
console.error('视频URL不存在')
|
||||
return
|
||||
}
|
||||
|
||||
const qualities = getVideoQualities(file)
|
||||
videoQualities.value = qualities
|
||||
|
||||
// 设置默认画质
|
||||
const defaultQuality = getDefaultQuality(qualities)
|
||||
let videoUrl = fileUrl
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查DPlayer是否存在
|
||||
if (!window.DPlayer) {
|
||||
await loadDPlayerScript()
|
||||
}
|
||||
|
||||
const options: any = {
|
||||
container: dplayerRef.value,
|
||||
autoplay: false,
|
||||
theme: '#1890ff',
|
||||
loop: false,
|
||||
lang: 'zh-cn',
|
||||
screenshot: true,
|
||||
hotkey: true,
|
||||
preload: 'auto',
|
||||
volume: 0.8,
|
||||
video: {
|
||||
url: videoUrl,
|
||||
type: isHLS ? 'hls' : 'normal'
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有多个画质,添加画质选择
|
||||
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)
|
||||
})
|
||||
|
||||
console.log('✅ DPlayer 初始化成功')
|
||||
} catch (error) {
|
||||
console.error('❌ 初始化DPlayer失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载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[] = []
|
||||
|
||||
// 如果有文件夹数据,添加到面包屑
|
||||
if (folderData) {
|
||||
crumbs.push(folderData)
|
||||
}
|
||||
|
||||
// 添加当前文件
|
||||
if (file.type !== 'folder') {
|
||||
crumbs.push(file)
|
||||
}
|
||||
|
||||
breadcrumbs.value = crumbs
|
||||
}
|
||||
|
||||
// 画质切换处理
|
||||
// const onQualityChange = () => {
|
||||
// if (currentFile.value && dplayer.value) {
|
||||
// const selectedQuality = videoQualities.value.find(q => q.name === currentQuality.value)
|
||||
// if (selectedQuality) {
|
||||
// dplayer.value.switchQuality(selectedQuality.url)
|
||||
// console.log('切换画质到:', selectedQuality.name)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// 加载文件数据
|
||||
const loadFile = (fileId: number) => {
|
||||
const file = findFileById(mockFileData, fileId)
|
||||
if (file) {
|
||||
currentFile.value = file
|
||||
buildBreadcrumbs(file)
|
||||
const loadFile = async () => {
|
||||
try {
|
||||
// 从 router state 获取数据
|
||||
const state = history.state as any
|
||||
|
||||
if (file.type === 'folder' && file.children) {
|
||||
// 对文件夹内容进行排序:置顶文件在前
|
||||
folderItems.value = [...file.children].sort((a, b) => {
|
||||
if (a.isTop && !b.isTop) return -1
|
||||
if (!a.isTop && b.isTop) return 1
|
||||
return 0
|
||||
})
|
||||
if (state?.fileData) {
|
||||
const fileData = JSON.parse(state.fileData) as FileItem
|
||||
const folderData = state.folderData ? JSON.parse(state.folderData) as FileItem : undefined
|
||||
|
||||
currentFile.value = fileData
|
||||
buildBreadcrumbs(fileData, folderData)
|
||||
|
||||
console.log('📄 加载文件数据:', fileData)
|
||||
|
||||
// 如果是视频文件,初始化播放器
|
||||
if (isVideo(fileData.type)) {
|
||||
await nextTick()
|
||||
await initVideoPlayer(fileData)
|
||||
}
|
||||
} else {
|
||||
message.error('文件数据不存在')
|
||||
router.back()
|
||||
}
|
||||
} else {
|
||||
message.error('文件不存在')
|
||||
} catch (error) {
|
||||
console.error('解析文件数据失败:', error)
|
||||
message.error('加载文件数据失败')
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
|
||||
// 查看文件/文件夹项目
|
||||
const viewItem = (_item: FileItem) => {
|
||||
// 选中效果等功能待实现
|
||||
|
||||
}
|
||||
|
||||
// 打开文件/文件夹
|
||||
const openItem = (item: FileItem) => {
|
||||
router.push({
|
||||
name: 'FileViewer',
|
||||
params: { fileId: item.id.toString() }
|
||||
})
|
||||
}
|
||||
|
||||
// 导航到面包屑位置
|
||||
const navigateTo = (crumb: FileItem) => {
|
||||
if (crumb.id !== currentFile.value?.id) {
|
||||
router.push({
|
||||
name: 'FileViewer',
|
||||
params: { fileId: crumb.id.toString() }
|
||||
})
|
||||
// 使用 router.back() 回到上一级
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
|
||||
@ -355,44 +561,71 @@ const goBack = () => {
|
||||
// 下载文件
|
||||
const downloadFile = () => {
|
||||
if (currentFile.value) {
|
||||
// 这里实现文件下载逻辑
|
||||
message.success(`开始下载:${currentFile.value.name}`)
|
||||
|
||||
// 模拟下载
|
||||
const link = document.createElement('a')
|
||||
link.href = getPreviewUrl(currentFile.value)
|
||||
link.download = currentFile.value.name
|
||||
link.click()
|
||||
try {
|
||||
let fileUrl = currentFile.value.originalData?.fileUrl
|
||||
if (!fileUrl) {
|
||||
message.error('文件下载地址不存在')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是视频文件,选择最高画质进行下载
|
||||
if (isVideo(currentFile.value.type)) {
|
||||
const qualities = getVideoQualities(currentFile.value)
|
||||
if (qualities.length > 0) {
|
||||
// 获取最高画质(第一个,因为已经按从高到低排序)
|
||||
const highestQuality = qualities[0]
|
||||
fileUrl = highestQuality.url
|
||||
console.log('🎬 选择最高画质下载:', highestQuality.name, highestQuality.url)
|
||||
}
|
||||
}
|
||||
|
||||
message.info(`正在准备下载:${currentFile.value.name}`)
|
||||
|
||||
// 生成下载文件名
|
||||
let downloadFileName = currentFile.value.name
|
||||
const urlExtension = getFileExtension(fileUrl)
|
||||
const nameExtension = getFileExtension(currentFile.value.name)
|
||||
|
||||
// 如果文件名没有扩展名,但URL有扩展名,则添加扩展名
|
||||
if (!nameExtension && urlExtension) {
|
||||
downloadFileName = currentFile.value.name + urlExtension
|
||||
}
|
||||
|
||||
// 使用工具函数进行下载
|
||||
downloadFileFromUrl(fileUrl, downloadFileName)
|
||||
|
||||
message.success(`开始下载:${downloadFileName}`)
|
||||
} catch (error) {
|
||||
console.error('下载文件失败:', error)
|
||||
message.error('文件下载失败,请重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
const fileId = parseInt(route.params.fileId as string)
|
||||
if (fileId) {
|
||||
loadFile(fileId)
|
||||
}
|
||||
loadFile()
|
||||
})
|
||||
|
||||
// 监听路由参数变化
|
||||
const unwatchRoute = router.afterEach(() => {
|
||||
const fileId = parseInt(route.params.fileId as string)
|
||||
if (fileId) {
|
||||
loadFile(fileId)
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时清理监听器
|
||||
// 组件卸载时清理播放器
|
||||
onUnmounted(() => {
|
||||
unwatchRoute()
|
||||
if (dplayer.value) {
|
||||
try {
|
||||
dplayer.value.destroy()
|
||||
} catch (e) {
|
||||
console.warn('销毁播放器失败:', e)
|
||||
}
|
||||
dplayer.value = null
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@ -632,8 +865,9 @@ onUnmounted(() => {
|
||||
|
||||
.preview-content {
|
||||
background: #f5f6fa;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
padding: 32px 24px;
|
||||
padding: 10px 24px;
|
||||
min-height: 500px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -649,10 +883,77 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
/* 视频预览 */
|
||||
.video-preview video {
|
||||
.video-preview {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
max-height: 70vh; /* 限制最大高度为视口的70% */
|
||||
}
|
||||
|
||||
.video-container {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-height: 60vh; /* 限制视频容器最大高度 */
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dplayer-container {
|
||||
width: 100%;
|
||||
max-height: 60vh; /* 确保播放器不超过60%视口高度 */
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* DPlayer 内部样式优化 */
|
||||
.dplayer-container :deep(.dplayer-video-wrap) {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
max-height: 60vh !important;
|
||||
}
|
||||
|
||||
.dplayer-container :deep(.dplayer-video) {
|
||||
max-height: 60vh !important;
|
||||
max-width: 100% !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
object-fit: contain !important;
|
||||
}
|
||||
|
||||
.quality-selector {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quality-selector label {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.quality-selector select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quality-selector select:focus {
|
||||
outline: none;
|
||||
border-color: #0288d1;
|
||||
}
|
||||
|
||||
/* PDF预览 */
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -4,9 +4,19 @@
|
||||
<div class="header-section">
|
||||
<!-- 左侧:头像、名称、时间 -->
|
||||
<div class="user-info">
|
||||
<div class="avatar">
|
||||
<img src="/images/activity/1.png" alt="头像" />
|
||||
<div class="avatar-section">
|
||||
<n-button quaternary circle size="large" @click="goBack">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowBackOutline />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
<div class="avatar">
|
||||
<img src="/images/activity/1.png" alt="头像" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-details">
|
||||
<div class="user-name">王伦国</div>
|
||||
<div class="submit-time">2025-07-21</div>
|
||||
@ -65,36 +75,44 @@
|
||||
回复
|
||||
</n-button>
|
||||
|
||||
<!-- 回复输入区域 -->
|
||||
<div class="reply-input-section" v-if="showReplyInput">
|
||||
<div class="reply-input-container">
|
||||
<textarea v-model="replyContent" placeholder="请输入回复内容" class="reply-input"></textarea>
|
||||
<div class="reply-actions">
|
||||
<n-button type="primary" class="send-btn" @click="sendReply">
|
||||
发送
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 回复输入区域 -->
|
||||
<div class="reply-input-section" v-if="showReplyInput">
|
||||
<div class="reply-input-container">
|
||||
<textarea v-model="replyContent" placeholder="请输入回复内容" class="reply-input"></textarea>
|
||||
<div class="reply-actions">
|
||||
<n-button type="primary" class="send-btn" @click="sendReply">
|
||||
发送
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的回复区域 -->
|
||||
<div class="my-reply-section" v-if="myReply">
|
||||
<h3 class="reply-title">我的回复</h3>
|
||||
<div class="reply-item">
|
||||
<div class="reply-content">
|
||||
<span class="user-tag">本人</span>
|
||||
<span class="user-name">{{ myReply.userName }}:</span>
|
||||
<span class="reply-text">{{ myReply.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 我的回复区域 -->
|
||||
<div class="my-reply-section" v-if="myReply">
|
||||
<h3 class="reply-title">我的回复</h3>
|
||||
<div class="reply-item">
|
||||
<div class="reply-content">
|
||||
<span class="user-tag">本人</span>
|
||||
<span class="user-name">{{ myReply.userName }}:</span>
|
||||
<span class="reply-text">{{ myReply.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { NButton, NDivider } from 'naive-ui'
|
||||
import { ArrowBackOutline } from '@vicons/ionicons5'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 作业信息
|
||||
const homeworkInfo = ref({
|
||||
@ -172,6 +190,12 @@ const sendReply = () => {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.avatar-section{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
|
Loading…
x
Reference in New Issue
Block a user