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 { try {
console.log('🚀 发送删除课程章节请求:', { url: '/aiol/aiolCourseSection/delete', id }) console.log('🚀 发送删除课程章节请求:', { url: '/aiol/aiolCourseSection/delete', id })
const response = await ApiRequest.delete<any>('/aiol/aiolCourseSection/delete', { const response = await ApiRequest.delete<any>('/aiol/aiolCourseSection/delete', {id})
params: { id }
})
console.log('🗑️ 删除课程章节响应:', response) console.log('🗑️ 删除课程章节响应:', response)
return response return response
@ -405,6 +403,94 @@ export class TeachCourseApi {
throw error 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' } 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 @click="handleNewFolder">
新建文件夹 新建文件夹
</n-button> </n-button>
<n-button @click="handleRecycleBin"> <!-- <n-button @click="handleRecycleBin">
<template #icon> <template #icon>
<img src="/images/teacher/delete2.png" alt="回收站" class="action-icon"> <img src="/images/teacher/delete2.png" alt="回收站" class="action-icon">
</template> </template>
回收站 回收站
</n-button> </n-button> -->
<div class="search-container"> <div class="search-container">
<n-input <n-input
v-model:value="searchKeyword" v-model:value="searchKeyword"
@ -120,6 +120,8 @@ import FileInfoCard from '@/components/admin/FileInfoCard.vue'
import UploadFileModal from '@/views/teacher/course/UploadFileModal.vue' import UploadFileModal from '@/views/teacher/course/UploadFileModal.vue'
import RecycleConfirmModal from '@/views/teacher/resource/RecycleConfirmModal.vue' import RecycleConfirmModal from '@/views/teacher/resource/RecycleConfirmModal.vue'
import { Search } from '@vicons/ionicons5' import { Search } from '@vicons/ionicons5'
// import { useRoute } from 'vue-router'
// const route = useRoute()
// //
interface FileItem { interface FileItem {
@ -265,9 +267,9 @@ const handleNewFolder = () => {
showNewFolderModal.value = true showNewFolderModal.value = true
} }
const handleRecycleBin = () => { // const handleRecycleBin = () => {
window.location.href = '/teacher/recycle-bin' // route.push('/teacher/recycle-bin')
} // }
const closeNewFolderModal = () => { const closeNewFolderModal = () => {
showNewFolderModal.value = false 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> <template>
<div class="add-discussion"> <div class="add-discussion">
<!-- 页面标题 --> <!-- 页面标题 -->
<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> <h1 class="page-title">添加讨论</h1>
</div>
<!-- 讨论表单 --> <!-- 讨论表单 -->
<div class="discussion-form"> <div class="discussion-form">
@ -13,19 +22,10 @@
<!-- 富文本编辑器 --> <!-- 富文本编辑器 -->
<div class="rich-editor"> <div class="rich-editor">
<div style="border: 1px solid #ccc"> <div style="border: 1px solid #ccc">
<Toolbar <Toolbar style="border-bottom: 1px solid #ccc" :editor="editorRef" :defaultConfig="toolbarConfig"
style="border-bottom: 1px solid #ccc" :mode="mode" />
:editor="editorRef" <Editor style="height: 300px; overflow-y: hidden;" v-model="discussionContent" :defaultConfig="editorConfig"
:defaultConfig="toolbarConfig" :mode="mode" @onCreated="handleCreated" />
:mode="mode"
/>
<Editor
style="height: 300px; overflow-y: hidden;"
v-model="discussionContent"
:defaultConfig="editorConfig"
:mode="mode"
@onCreated="handleCreated"
/>
</div> </div>
</div> </div>
@ -37,7 +37,8 @@
<div class="section-selector-wrapper"> <div class="section-selector-wrapper">
<button @click="toggleSectionSelector" class="section-selector" :class="{ active: showSectionSelector }"> <button @click="toggleSectionSelector" class="section-selector" :class="{ active: showSectionSelector }">
<span>{{ selectedSection || '选择章节' }}</span> <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> </button>
<!-- 章节选择弹窗 --> <!-- 章节选择弹窗 -->
@ -77,10 +78,15 @@ import { NButton } from 'naive-ui'
import '@wangeditor/editor/dist/css/style.css' import '@wangeditor/editor/dist/css/style.css'
// @ts-ignore // @ts-ignore
import { Editor, Toolbar } from '@wangeditor/editor-for-vue' import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { ArrowBackOutline } from '@vicons/ionicons5'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const goBack = () => {
router.back()
}
// shallowRef // shallowRef
const editorRef = shallowRef() const editorRef = shallowRef()
@ -177,11 +183,17 @@ onMounted(() => {
padding: 20px; padding: 20px;
} }
.header-section{
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.page-title { .page-title {
font-size: 16px; font-size: 16px;
font-weight: normal; font-weight: normal;
color: #333; color: #333;
margin-bottom: 20px;
} }
.discussion-form { .discussion-form {

View File

@ -60,6 +60,7 @@
<!-- 补交时间选择器 --> <!-- 补交时间选择器 -->
<div class="form-item" v-if="formData.allowLateSubmission"> <div class="form-item" v-if="formData.allowLateSubmission">
<label class="form-label"></label>
<n-date-picker <n-date-picker
v-model:value="formData.lateSubmissionTime" v-model:value="formData.lateSubmissionTime"
type="datetime" type="datetime"
@ -73,7 +74,7 @@
<div class="form-column"> <div class="form-column">
<!-- 所属章节 --> <!-- 所属章节 -->
<div class="form-item"> <div class="form-item">
<label class="form-label required">所属章节:</label> <label class="form-label">所属章节:</label>
<n-select <n-select
v-model:value="formData.chapter" v-model:value="formData.chapter"
:options="chapterOptions" :options="chapterOptions"

View File

@ -28,18 +28,21 @@
</template> </template>
</n-button> </n-button>
<div class="title" v-if="isAddMode">新建章节</div> <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>
<div class="single-chapter-container"> <div class="single-chapter-container">
<div class="chapter-item flex-row"> <div class="chapter-item flex-row">
<span class="chapter-title">{{ currentChapter.name || '暂未设置章节名' }}</span> <span class="chapter-title">{{ currentChapter.name || '暂未设置章节名' }}</span>
<!-- 排序更新加载指示器 -->
<n-spin v-if="updatingSectionSort" size="small" class="sort-loading-indicator" />
</div> </div>
<draggable v-model="currentChapter.sections" item-key="id" class="sortable-section-list" <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" 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" :delay="0" :delay-on-touch-start="0" :touch-start-threshold="0" :force-fallback="false"
@end="onDragEnd"> :disabled="updatingSectionSort" @start="onDragStart" @end="onDragEnd">
<template #item="{ element: section }"> <template #item="{ element: section }">
<div class="chapter-content-item flex-row section-drag-item" <div class="chapter-content-item flex-row section-drag-item"
:class="{ 'active': activeCollapsePanels.includes(section.id.toString()) }" :class="{ 'active': activeCollapsePanels.includes(section.id.toString()) }"
@ -363,6 +366,7 @@ const message = useMessage();
// //
// const loading = ref(false); // 使 // const loading = ref(false); // 使
const saving = ref(false); const saving = ref(false);
const updatingSectionSort = ref(false); //
// //
const showDeleteModal = ref(false); const showDeleteModal = ref(false);
@ -405,7 +409,7 @@ const activeCollapsePanels = ref<string[]>(['1']);
// //
interface Chapter { interface Chapter {
id: number; id: number | string; // ID
name: string; name: string;
sections: any[]; sections: any[];
} }
@ -440,6 +444,7 @@ const currentChapter = computed(() => chapter.value);
// or // or
const isAddMode = computed(() => route.query.mode === 'add'); const isAddMode = computed(() => route.query.mode === 'add');
const isEditMode = computed(() => route.query.mode === 'edit');
// //
const sectionTypes = [ const sectionTypes = [
@ -526,27 +531,41 @@ const saveChapter = async () => {
console.log('📋 课程ID:', courseId); console.log('📋 课程ID:', courseId);
console.log('🔍 模式检测:', { isAddMode, editChapterId, currentChapterId: currentChapter.value.id }); console.log('🔍 模式检测:', { isAddMode, editChapterId, currentChapterId: currentChapter.value.id });
// sortOrder
const editChapterData = history.state?.editChapterData;
const chapterSortOrder = isEditMode.value && editChapterData ? editChapterData.sortOrder : null;
// - 使API // - 使API
const chapterData = { const chapterData = {
course_id: courseId, courseId: courseId,
name: currentChapter.value.name.trim(), name: currentChapter.value.name.trim(),
type: 0, // type: null, //
sort_order: 10, // sortOrder: chapterSortOrder, // 使
parent_id: '0', // ID0 parentId: null, // IDnull
level: 1 // 1= level: 1 // 1= 2=
}; };
console.log('<EFBFBD> 保存章节数据:', chapterData); console.log('📊 保存章节数据:', chapterData);
let response; 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('🆕 创建新章节'); console.log('🆕 创建新章节');
response = await TeachCourseApi.createCourseSection(chapterData); 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 { } else {
// // - ID
console.log('✏️ 编辑现有章节ID:', currentChapter.value.id); console.log('✏️ 编辑现有章节ID:', currentChapter.value.id);
response = await TeachCourseApi.editCourseSection({ response = await TeachCourseApi.editCourseSection({
id: currentChapter.value.id.toString(), id: String(currentChapter.value.id), //
...chapterData ...chapterData
}); });
} }
@ -555,10 +574,41 @@ const saveChapter = async () => {
// //
if (response.data && (response.data.success === true || response.data.code === 200 || response.data.code === 0)) { if (response.data && (response.data.success === true || response.data.code === 200 || response.data.code === 0)) {
message.success('章节保存成功!'); console.log('✅ 章节保存成功,开始保存小节...');
// // ID
console.log('✅ 章节保存成功'); 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 { } else {
console.error('❌ 章节保存失败,响应数据:', response.data); console.error('❌ 章节保存失败,响应数据:', response.data);
message.error('章节保存失败:' + (response.data?.message || '未知错误')); 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 () => { const handleDeleteConfirm = async () => {
if (!chapterToDelete.value) { if (!chapterToDelete.value) {
@ -644,7 +803,7 @@ const loadChapters = async () => {
console.log('📋 课程ID:', courseId); console.log('📋 课程ID:', courseId);
// //
if (isAddMode) { if (isAddMode.value) {
console.log('🆕 新增模式:创建空白章节'); console.log('🆕 新增模式:创建空白章节');
// //
const defaultChapter: Chapter = { const defaultChapter: Chapter = {
@ -676,7 +835,78 @@ const loadChapters = async () => {
return; 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; const editChapterId = route.query.chapterId as string;
if (editChapterId) { if (editChapterId) {
console.log('✏️ 编辑特定章节模式章节ID:', editChapterId); console.log('✏️ 编辑特定章节模式章节ID:', editChapterId);
@ -814,6 +1044,11 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', checkScreenSize); 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()) { if (section.lessonName && section.lessonName.trim()) {
// contentTitle使 // contentTitle使
section.contentTitle = section.lessonName; 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 sectionCount = currentChapter.value.sections.length + 1;
const lessonName = `新小节${sectionCount}`; const lessonName = `新小节${sectionCount}`;
const newSection: any = { const newSection: any = {
id: Date.now(), // 使ID id: `temp_${Date.now()}`, // 使+ID
lessonName: lessonName, lessonName: lessonName,
sectionType: 'video', // sectionType: 'video', //
// //
@ -935,7 +1183,7 @@ const addSection = () => {
currentChapter.value.sections.push(newSection); currentChapter.value.sections.push(newSection);
// accordion // accordion
activeCollapsePanels.value = [newSection.id.toString()]; activeCollapsePanels.value = [newSection.id]; // IDtoString()
}; };
// //
@ -1028,18 +1276,100 @@ const cancelDeleteSection = () => {
// //
const isDragging = ref(false); 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 = () => { const onDragStart = () => {
isDragging.value = true; isDragging.value = true;
console.log('开始拖拽小节'); console.log('开始拖拽小节');
}; };
// // -
const onDragEnd = (event: any) => { let sortUpdateTimeout: NodeJS.Timeout | null = null;
const onDragEnd = async (event: any) => {
isDragging.value = false; isDragging.value = false;
console.log('拖拽结束', event); console.log('拖拽结束', event);
console.log('新的小节顺序:', currentChapter.value.sections.map(s => s.lessonName)); 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; color: #0288D1;
transition: all 0.3s ease; 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> </style>

View File

@ -8,7 +8,7 @@
<n-button type="primary" @click="addChapter">添加章节</n-button> <n-button type="primary" @click="addChapter">添加章节</n-button>
<n-button @click="importChapters">导入</n-button> <n-button @click="importChapters">导入</n-button>
<n-button @click="exportChapters">导出</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"> <div class="search-container">
<n-input v-model:value="searchKeyword" placeholder="请输入想要搜索的内容" style="width: 200px;"> <n-input v-model:value="searchKeyword" placeholder="请输入想要搜索的内容" style="width: 200px;">
</n-input> </n-input>
@ -64,14 +64,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue' 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 type { DataTableColumns } from 'naive-ui'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ChevronForwardOutline } from '@vicons/ionicons5'
import ImportModal from '@/components/common/ImportModal.vue' import ImportModal from '@/components/common/ImportModal.vue'
import TeachCourseApi from '@/api/modules/teachCourse' import TeachCourseApi from '@/api/modules/teachCourse'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const dialog = useDialog()
// ID // ID
const courseId = ref(route.params.id as string) const courseId = ref(route.params.id as string)
@ -90,6 +92,7 @@ interface Chapter {
expanded?: boolean expanded?: boolean
level?: number level?: number
parentId?: string parentId?: string
sortOrder?: number | null // sortOrder
} }
const showImportModal = ref(false) const showImportModal = ref(false)
@ -117,6 +120,9 @@ const selectedChapters = ref<string[]>([])
// //
const chapterList = ref<Chapter[]>([]) const chapterList = ref<Chapter[]>([])
//
const chapterSortValues = ref<Record<string, string>>({})
// //
const flattenedChapters = computed(() => { const flattenedChapters = computed(() => {
const result: Chapter[] = [] const result: Chapter[] = []
@ -125,9 +131,27 @@ const flattenedChapters = computed(() => {
const chapters = chapterList.value.filter(item => item.level === 1) const chapters = chapterList.value.filter(item => item.level === 1)
const sections = chapterList.value.filter(item => item.level === 2) 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 => { sortedChapters.forEach(chapter => {
chapter.children = sections.filter(section => section.parentId === chapter.id) 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) result.push(chapter)
// //
@ -248,31 +272,180 @@ const exportChapters = () => {
message.info('导出章节功能') message.info('导出章节功能')
} }
const deleteSelected = async () => { // const deleteSelected = async () => {
if (selectedChapters.value.length === 0) return // if (selectedChapters.value.length === 0) return
// // //
message.info('删除选中章节') // message.info('')
} // }
const searchChapters = async () => { const searchChapters = async () => {
// //
message.info('搜索章节') message.info('搜索章节')
} }
const editChapter = (chapter: Chapter) => { const editChapter = async (chapter: Chapter) => {
console.log('编辑章节:', chapter) console.log('编辑章节:', chapter)
// ID
const courseId = route.params.id const courseId = route.params.id as string
if (courseId) { if (!courseId) {
router.push(`/teacher/chapter-editor-teacher/${courseId}?chapterId=${chapter.id}`)
} else {
message.error('课程ID不存在') 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) => { const deleteChapter = async (chapter: Chapter) => {
// const isChapterLevel = chapter.level === 1; // level=1
message.info(`删除章节: ${chapter.name}`) 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 // - 使 minWidth
@ -295,22 +468,24 @@ const columns: DataTableColumns<Chapter> = [
style: { style: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
cursor: isChapter ? 'pointer' : 'default', cursor: (isChapter && row.children && row.children.length > 0) ? 'pointer' : 'default',
marginLeft: isChapter ? '0px' : '-3px' 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: { style: {
marginRight: '8px', marginRight: '8px',
fontSize: '12px',
color: '#999', color: '#999',
transition: 'transform 0.2s', transition: 'transform 0.2s',
transform: row.expanded ? 'rotate(90deg)' : 'rotate(0deg)' transform: row.expanded ? 'rotate(90deg)' : 'rotate(0deg)',
cursor: 'pointer'
} }
}, [ }, {
'▶' default: () => h(ChevronForwardOutline)
]) : null, }) : (isChapter ? h('span', { style: { marginRight: '22px' } }) : null),
h('span', { h('span', {
style: { color: '#062333', fontSize: '13px' } style: { color: '#062333', fontSize: '13px' }
}, row.name) }, row.name)
@ -340,14 +515,47 @@ const columns: DataTableColumns<Chapter> = [
{ {
title: '排序', title: '排序',
key: 'sort', key: 'sort',
minWidth: 50, minWidth: 100,
render: (row: Chapter) => { render: (row: Chapter) => {
const isChapter = row.level === 1; // level=1 const isChapter = row.level === 1; // level=1
if (isChapter) { 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)
} }
}
}, },
{ {
title: '创建时间', title: '创建时间',
@ -362,6 +570,10 @@ const columns: DataTableColumns<Chapter> = [
key: 'actions', key: 'actions',
minWidth: 160, minWidth: 160,
render: (row: Chapter) => { render: (row: Chapter) => {
const isChapter = row.level === 1; // level=1
if (isChapter) {
//
return h('div', { style: { display: 'flex', gap: '16px', alignItems: 'center', justifyContent: 'center' } }, [ return h('div', { style: { display: 'flex', gap: '16px', alignItems: 'center', justifyContent: 'center' } }, [
h(NButton, { h(NButton, {
size: 'small', size: 'small',
@ -376,27 +588,54 @@ const columns: DataTableColumns<Chapter> = [
onClick: () => deleteChapter(row) onClick: () => deleteChapter(row)
}, { default: () => '删除' }) }, { 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 = () => { const fetchCourseChapters = () => {
loading.value = true
TeachCourseApi.getCourseSections(courseId.value).then(res => { TeachCourseApi.getCourseSections(courseId.value).then(res => {
console.log('章节数据:', res.data) console.log('章节数据:', res.data)
// APICourseSectionChapter // APICourseSectionChapter
const sections = res.data.result || [] const sections = res.data.result || []
chapterList.value = sections.map((section): Chapter => ({ chapterList.value = sections.map((section: any): Chapter => ({
id: section.id || '0', id: section.id || '0',
name: section.name || '', name: section.name || '',
type: section.type?.toString() || '-', 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, isParent: (section.level || 0) === 1,
expanded: false, expanded: false,
level: section.level || 0, // level
parentId: section.parentId || '', // API使parentId
sortOrder: section.sortOrder || null, // sortOrder
children: [] children: []
})) }))
console.log('处理后的章节数据:', chapterList.value)
//
chapterSortValues.value = {}
chapterList.value.forEach(chapter => {
if (chapter.level === 1) {
chapterSortValues.value[chapter.id] = chapter.sortOrder?.toString() || ''
}
})
}).catch(error => { }).catch(error => {
console.error('获取章节数据失败:', error) console.error('获取章节数据失败:', error)
message.error('获取章节数据失败') message.error('获取章节数据失败')
}).finally(() => {
loading.value = false
}) })
} }
@ -410,6 +649,9 @@ onMounted(() => {
width: 100%; width: 100%;
background: #fff; background: #fff;
overflow: auto; overflow: auto;
height: 100%;
display: flex;
flex-direction: column;
} }
/* 顶部工具栏 */ /* 顶部工具栏 */

View File

@ -147,13 +147,13 @@ const hideSidebar = computed(() => {
// //
const hideSidebarPaths = [ const hideSidebarPaths = [
'add-question', // 'add-question', //
'add-homework', // // 'add-homework', //
'template-import', 'template-import',
'review/', // 'review/',
'certificate/detail/', 'certificate/detail/',
'certificate/add', 'certificate/add',
'comment/', // 'comment/', //
'discussion/add' // 'discussion/add'
] ]
// //

View File

@ -1,20 +1,301 @@
<template> <template>
<div class="courseware-management"> <div class="chapter-management">
<MyResources></MyResources> <!-- 头部操作区域 -->
<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> </div>
</template> </template>
<script setup lang="ts"> <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> </script>
<style scoped> <style scoped>
.courseware-management { .chapter-management {
width: 100%; padding: 0;
background: #fff; 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; display: flex;
flex-direction: column; 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;
}
.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> </style>

File diff suppressed because it is too large Load Diff

View File

@ -31,57 +31,17 @@
<!-- 内容区域 --> <!-- 内容区域 -->
<div class="content"> <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-header">
<div class="file-info"> <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"> <div class="file-details">
<h3>{{ currentFile.name }}</h3> <h3>{{ getDisplayFileName(currentFile) }}</h3>
<div class="file-meta"> <div class="file-meta">
<span>文件大小{{ currentFile.size }}</span> <span>文件大小{{ currentFile.size }}</span>
<span>创建时间{{ currentFile.createTime }}</span>
<span>创建人{{ currentFile.creator }}</span>
</div> </div>
</div> </div>
</div> </div>
@ -91,14 +51,14 @@
<div class="preview-content"> <div class="preview-content">
<!-- 图片预览 --> <!-- 图片预览 -->
<div v-if="isImage(currentFile.type)" class="image-preview"> <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>
<!-- 视频预览 --> <!-- 视频预览 -->
<div v-else-if="isVideo(currentFile.type)" class="video-preview"> <div v-else-if="isVideo(currentFile.type)" class="video-preview">
<video controls :src="getPreviewUrl(currentFile)"> <div class="video-container">
您的浏览器不支持视频播放 <div ref="dplayerRef" class="dplayer-container"></div>
</video> </div>
</div> </div>
<!-- PDF预览 --> <!-- PDF预览 -->
@ -110,10 +70,10 @@
<div v-else-if="isDocument(currentFile.type)" class="document-preview"> <div v-else-if="isDocument(currentFile.type)" class="document-preview">
<div class="document-placeholder"> <div class="document-placeholder">
<div class="file-preview-icon"> <div class="file-preview-icon">
<img :src="getFileIcon(currentFile.type)" :alt="currentFile.type" /> <n-icon size="64" :component="getFileIcon(currentFile.type)" />
</div> </div>
<div class="file-preview-info"> <div class="file-preview-info">
<h4>{{ currentFile.name }}</h4> <h4>{{ getDisplayFileName(currentFile) }}</h4>
<p class="file-description">该文件类型暂不支持在线预览</p> <p class="file-description">该文件类型暂不支持在线预览</p>
<n-button type="primary" @click="downloadFile">下载查看</n-button> <n-button type="primary" @click="downloadFile">下载查看</n-button>
</div> </div>
@ -124,10 +84,10 @@
<div v-else class="unsupported-preview"> <div v-else class="unsupported-preview">
<div class="unsupported-placeholder"> <div class="unsupported-placeholder">
<div class="file-preview-icon"> <div class="file-preview-icon">
<img :src="getFileIcon(currentFile.type)" :alt="currentFile.type" /> <n-icon size="64" :component="getFileIcon(currentFile.type)" />
</div> </div>
<div class="file-preview-info"> <div class="file-preview-info">
<h4>{{ currentFile.name }}</h4> <h4>{{ getDisplayFileName(currentFile) }}</h4>
<p class="file-description">该文件类型暂不支持预览</p> <p class="file-description">该文件类型暂不支持预览</p>
<n-button type="primary" @click="downloadFile">下载查看</n-button> <n-button type="primary" @click="downloadFile">下载查看</n-button>
</div> </div>
@ -146,12 +106,29 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui' import { useMessage } from 'naive-ui'
import { ArrowBackOutline } from '@vicons/ionicons5' 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 router = useRouter()
const message = useMessage() const message = useMessage()
@ -166,92 +143,93 @@ interface FileItem {
isTop: boolean isTop: boolean
children?: FileItem[] children?: FileItem[]
parentId?: number parentId?: number
originalData?: any // API
} }
// / // /
const currentFile = ref<FileItem | null>(null) const currentFile = ref<FileItem | null>(null)
// //
const breadcrumbs = ref<FileItem[]>([]) const breadcrumbs = ref<FileItem[]>([])
//
const folderItems = ref<FileItem[]>([])
// API //
const mockFileData: FileItem[] = [ const dplayerRef = ref<HTMLElement | null>(null)
{ const dplayer = ref<any>(null)
id: 1, const videoQualities = ref<VideoQuality[]>([])
name: '教学资料文件夹', const currentQuality = ref<string>('')
type: 'folder',
size: '1MB', //
creator: '王建国', interface VideoQuality {
createTime: '2025.07.25 09:20', name: string
isTop: true, url: string
children: [ type?: string
{ }
id: 2,
name: '课程大纲.xlsx', //
type: 'excel', const getDisplayFileName = (item: FileItem): string => {
size: '1MB', if (item.type === 'folder') {
creator: '王建国', return item.name
createTime: '2025.07.25 09:20', }
isTop: false,
parentId: 1 // fileUrl
}, const fileUrl = item.originalData?.fileUrl || ''
{ let extension = ''
id: 3,
name: '教学计划.docx', if (fileUrl) {
type: 'word', // URL
size: '2MB', const urlMatch = fileUrl.match(/\.([a-zA-Z0-9]+)(?:\?|$)/)
creator: '王建国', if (urlMatch) {
createTime: '2025.07.25 09:20', extension = urlMatch[1].toLowerCase()
isTop: false, }
parentId: 1 }
},
{ // URL
id: 4, if (!extension) {
name: '教学PPT.pptx', switch (item.type) {
type: 'ppt', case 'video':
size: '5MB', extension = 'mp4'
creator: '王建国', break
createTime: '2025.07.25 09:20', case 'image':
isTop: false, extension = 'jpg'
parentId: 1 break
}, case 'pdf':
{ extension = 'pdf'
id: 5, break
name: '教学视频.mp4', case 'word':
type: 'video', extension = 'docx'
size: '100MB', break
creator: '王建国', case 'excel':
createTime: '2025.07.25 09:20', extension = 'xlsx'
isTop: false, break
parentId: 1 case 'ppt':
}, extension = 'pptx'
{ break
id: 6, default:
name: '学习指南.pdf', extension = 'file'
type: 'pdf', }
size: '3MB', }
creator: '王建国',
createTime: '2025.07.25 09:20', //
isTop: false, const nameHasExtension = /\.[a-zA-Z0-9]+$/.test(item.name)
parentId: 1
if (nameHasExtension) {
return item.name
} else {
return `${item.name}.${extension}`
} }
]
} }
]
// //
const getFileIcon = (type: string) => { const getFileIcon = (type: string) => {
const iconMap: { [key: string]: string } = { const iconMap: { [key: string]: any } = {
folder: '/images/teacher/folder.jpg', folder: FolderOutline,
excel: '/images/activity/xls.png', excel: GridOutline,
word: '/images/activity/wrod.png', word: DocumentTextOutline,
pdf: '/images/activity/pdf.png', pdf: DocumentTextOutline,
ppt: '/images/activity/ppt.png', ppt: EaselOutline,
video: '/images/activity/file.png', video: VideocamOutline,
image: '/images/activity/image.png' 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 isPdf = (type: string) => type === 'pdf'
const isDocument = (type: string) => ['word', 'excel', 'ppt', 'txt'].includes(type) const isDocument = (type: string) => ['word', 'excel', 'ppt', 'txt'].includes(type)
// URLURL // URL
const getPreviewUrl = (file: FileItem) => { const getPreviewUrl = (file: FileItem) => {
// URL // 使 fileUrl
return `/api/files/preview/${file.id}` return file.originalData?.fileUrl || ''
} }
// // URL
const formatTime = (timeStr: string) => { const parseVideoQualities = (fileUrl: string): VideoQuality[] => {
return timeStr.replace(/\./g, '-') 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'
}]
} }
// ID const qualities: VideoQuality[] = []
const findFileById = (files: FileItem[], id: number): FileItem | null => {
for (const file of files) { for (const url of urls) {
if (file.id === id) { const quality = extractQualityFromUrl(url)
return file qualities.push({
} name: quality,
if (file.children) { url: url,
const found = findFileById(file.children, id) type: url.includes('.m3u8') ? 'hls' : 'normal'
if (found) return found })
}
}
return null
} }
//
return qualities.sort((a, b) => {
const aRes = getResolutionValue(a.name)
const bRes = getResolutionValue(b.name)
return bRes - aRes
})
}
// URL
const extractQualityFromUrl = (url: string): string => {
const patterns = [
{ pattern: /\/(\d+p)\//i, extract: (match: RegExpMatchArray) => match[1] },
{ pattern: /\/(\d+)\//i, extract: (match: RegExpMatchArray) => `${match[1]}p` }
]
for (const { pattern, extract } of patterns) {
const match = url.match(pattern)
if (match) {
return extract(match).toUpperCase()
}
}
//
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 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
// 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) => { const buildBreadcrumbs = (file: FileItem, folderData?: FileItem) => {
const crumbs: FileItem[] = [] const crumbs: FileItem[] = []
let current = file
while (current) { //
crumbs.unshift(current) if (folderData) {
if (current.parentId) { crumbs.push(folderData)
current = findFileById(mockFileData, current.parentId) as FileItem
} else {
break
} }
//
if (file.type !== 'folder') {
crumbs.push(file)
} }
breadcrumbs.value = crumbs breadcrumbs.value = crumbs
} }
// //
const loadFile = (fileId: number) => { // const onQualityChange = () => {
const file = findFileById(mockFileData, fileId) // if (currentFile.value && dplayer.value) {
if (file) { // const selectedQuality = videoQualities.value.find(q => q.name === currentQuality.value)
currentFile.value = file // if (selectedQuality) {
buildBreadcrumbs(file) // dplayer.value.switchQuality(selectedQuality.url)
// console.log(':', selectedQuality.name)
// }
// }
// }
if (file.type === 'folder' && file.children) { //
// const loadFile = async () => {
folderItems.value = [...file.children].sort((a, b) => { try {
if (a.isTop && !b.isTop) return -1 // router state
if (!a.isTop && b.isTop) return 1 const state = history.state as any
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 { } else {
message.error('文件不存在') message.error('文件数据不存在')
router.back() router.back()
} }
} 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) => { const navigateTo = (crumb: FileItem) => {
if (crumb.id !== currentFile.value?.id) { if (crumb.id !== currentFile.value?.id) {
router.push({ // 使 router.back()
name: 'FileViewer', router.back()
params: { fileId: crumb.id.toString() }
})
} }
} }
@ -355,44 +561,71 @@ const goBack = () => {
// //
const downloadFile = () => { const downloadFile = () => {
if (currentFile.value) { if (currentFile.value) {
// try {
message.success(`开始下载:${currentFile.value.name}`) let fileUrl = currentFile.value.originalData?.fileUrl
if (!fileUrl) {
message.error('文件下载地址不存在')
return
}
// //
const link = document.createElement('a') if (isVideo(currentFile.value.type)) {
link.href = getPreviewUrl(currentFile.value) const qualities = getVideoQualities(currentFile.value)
link.download = currentFile.value.name if (qualities.length > 0) {
link.click() //
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(() => { onMounted(() => {
const fileId = parseInt(route.params.fileId as string) loadFile()
if (fileId) {
loadFile(fileId)
}
}) })
// //
const unwatchRoute = router.afterEach(() => {
const fileId = parseInt(route.params.fileId as string)
if (fileId) {
loadFile(fileId)
}
})
//
onUnmounted(() => { onUnmounted(() => {
unwatchRoute() if (dplayer.value) {
try {
dplayer.value.destroy()
} catch (e) {
console.warn('销毁播放器失败:', e)
}
dplayer.value = null
}
}) })
</script> </script>
<style scoped> <style scoped>
.file-viewer { .file-viewer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100%;
background: #fff; background: #fff;
} }
@ -632,8 +865,9 @@ onUnmounted(() => {
.preview-content { .preview-content {
background: #f5f6fa; background: #f5f6fa;
width: 100%;
border-radius: 8px; border-radius: 8px;
padding: 32px 24px; padding: 10px 24px;
min-height: 500px; min-height: 500px;
display: flex; display: flex;
align-items: center; 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%; width: 100%;
max-width: 800px; 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; 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预览 */ /* PDF预览 */

File diff suppressed because it is too large Load Diff

View File

@ -4,9 +4,19 @@
<div class="header-section"> <div class="header-section">
<!-- 左侧头像名称时间 --> <!-- 左侧头像名称时间 -->
<div class="user-info"> <div class="user-info">
<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"> <div class="avatar">
<img src="/images/activity/1.png" alt="头像" /> <img src="/images/activity/1.png" alt="头像" />
</div> </div>
</div>
<div class="user-details"> <div class="user-details">
<div class="user-name">王伦国</div> <div class="user-name">王伦国</div>
<div class="submit-time">2025-07-21</div> <div class="submit-time">2025-07-21</div>
@ -95,6 +105,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { NButton, NDivider } from 'naive-ui' 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({ const homeworkInfo = ref({
@ -172,6 +190,12 @@ const sendReply = () => {
gap: 15px; gap: 15px;
} }
.avatar-section{
display: flex;
align-items: center;
gap: 10px;
}
.avatar { .avatar {
width: 50px; width: 50px;
height: 50px; height: 50px;