feat: 课件界面重构,对接接口;章节页面基础框架接口对接;一些样式优化

This commit is contained in:
yuk255 2025-09-18 19:52:57 +08:00
parent b20cc50f44
commit aa87b0e8e4
13 changed files with 2518 additions and 2043 deletions

View File

@ -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
}
}
}

View File

@ -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
View 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) : ''
}

View File

@ -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 {

View File

@ -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"

View File

@ -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', // ID0
level: 1 // 1=
type: null, //
sortOrder: chapterSortOrder, // 使
parentId: null, // IDnull
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.id0 (section.id === 0)
// 3. section.id'0' (section.id === '0')
// 4. section.idnullundefined
// 5. section.idID ('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]; // IDtoString()
};
//
@ -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);
}
// 300msAPI
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>

View File

@ -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)
// sortOrdernull
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)
// sortOrdernull
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)
// stateURL
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)
// APICourseSectionChapter
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;
}
/* 顶部工具栏 */

View File

@ -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'
]
//

View File

@ -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

View File

@ -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)
// URLURL
// 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

View File

@ -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;