fix: 课程管理下的页面样式和逻辑补全优化并添加了文件预览页面

This commit is contained in:
yuk255 2025-09-01 20:51:13 +08:00
parent 590af0951f
commit 02dfa15e75
19 changed files with 2844 additions and 1213 deletions

View File

@ -2,23 +2,21 @@
<div class="course-category">
<!-- 顶部 -->
<div class="top">
<div class="nav-links">
<a href="" class="active">进行中</a>
<a href="">已结束</a>
<a href="">草稿箱</a>
</div>
<n-tabs v-model:value="activeTab" size="large">
<n-tab-pane name="ongoing" tab="进行中" />
<n-tab-pane name="finished" tab="已结束" />
<n-tab-pane name="draft" tab="草稿箱" />
</n-tabs>
<div class="actions">
<button class="create-btn" @click="navigateToCreateCourse">创建课程</button>
<n-button type="primary" @click="navigateToCreateCourse">创建课程</n-button>
<div class="search-container">
<input type="text" placeholder="请输入想要搜索的内容">
<button class="search-btn">搜索</button>
<n-input v-model:value="searchValue" type="text" placeholder="请输入想要搜索的内容" />
<n-button type="primary" class="search-btn">搜索</n-button>
</div>
</div>
</div>
<!-- 主体 -->
<div class="course-container">
<div class="course-grid">
@ -26,23 +24,21 @@
<div class="course-image-container">
<div class="section-title" :class="{ 'offline': course.status === '下架中' }">{{ course.status }}
</div>
<n-popselect
:options="getOptionsForCourse(course)"
trigger="hover"
placement="bottom-end"
:render-label="renderOptionLabel"
@update:value="(value: string) => handleOptionSelect(value, course)"
>
<div class="more-options">
<span class="more-icon"></span>
<div class="options-menu">
<template v-if="course.status === '发布中'">
<a href="#" class="option-item"><img src="/images/teacher/下架.png" alt="">下架</a>
<a href="javascript:void(0)" class="option-item"><img src="/images/teacher/小编辑.png" alt="">编辑</a>
<a href="#" class="option-item"><img src="/images/teacher/移动.png" alt="">移动</a>
<a href="#" class="option-item"><img src="/images/teacher/删除.png" alt="">删除</a>
</template>
<template v-else-if="course.status === '下架中'">
<a href="#" class="option-item"><img src="/images/teacher/加号.png" alt="">发布</a>
<a href="javascript:void(0)" class="option-item"><img src="/images/teacher/小编辑.png" alt="">编辑</a>
<a href="#" class="option-item"><img src="/images/teacher/移动.png" alt="">移动</a>
<a href="#" class="option-item"><img src="/images/teacher/删除.png" alt="">删除</a>
</template>
</div>
<span class="more-icon">
<n-icon size="20">
<EllipsisVerticalSharp />
</n-icon>
</span>
</div>
</n-popselect>
</div>
<div class="course-info" @click="router.push(`/teacher/course-editor/${course.id}`)" style="cursor: pointer;">
<img :src="course.image" alt="">
@ -76,32 +72,196 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, h } from 'vue';
import { useRouter } from 'vue-router';
import { EllipsisVerticalSharp } from '@vicons/ionicons5';
import { useMessage, useDialog } from 'naive-ui';
const router = useRouter();
const message = useMessage();
const dialog = useDialog();
//
const courseList = ref([
{ id: 1, name: '前端开发基础课程', status: '发布中', image: '/images/teacher/fj.png' },
{ id: 2, name: 'Vue.js 实战教程', status: '发布中', image: '/images/teacher/fj.png' },
{ id: 3, name: 'React 入门到精通', status: '发布中', image: '/images/teacher/fj.png' },
{ id: 4, name: 'Node.js 后端开发', status: '下架中', image: '/images/teacher/fj.png' },
{ id: 5, name: 'TypeScript 高级教程', status: '发布中', image: '/images/teacher/fj.png' },
{ id: 6, name: 'JavaScript 设计模式', status: '发布中', image: '/images/teacher/fj.png' },
{ id: 7, name: 'CSS 动画与特效', status: '下架中', image: '/images/teacher/fj.png' },
{ id: 8, name: 'HTML5 新特性详解', status: '发布中', image: '/images/teacher/fj.png' },
{ id: 9, name: 'Web 性能优化指南', status: '发布中', image: '/images/teacher/fj.png' },
{ id: 10, name: '移动端适配实战', status: '发布中', image: '/images/teacher/fj.png' },
{ id: 11, name: '微信小程序开发', status: '下架中', image: '/images/teacher/fj.png' },
{ id: 12, name: 'Flutter 跨平台开发', status: '发布中', image: '/images/teacher/fj.png' },
{ id: 1, name: '前端开发基础课程', status: '发布中', image: '/images/teacher/fj.png', students: 120 },
{ id: 2, name: 'Vue.js 实战教程', status: '发布中', image: '/images/teacher/fj.png', students: 95 },
{ id: 3, name: 'React 入门到精通', status: '发布中', image: '/images/teacher/fj.png', students: 87 },
{ id: 4, name: 'Node.js 后端开发', status: '下架中', image: '/images/teacher/fj.png', students: 65 },
{ id: 5, name: 'TypeScript 高级教程', status: '发布中', image: '/images/teacher/fj.png', students: 73 },
{ id: 6, name: 'JavaScript 设计模式', status: '发布中', image: '/images/teacher/fj.png', students: 56 },
{ id: 7, name: 'CSS 动画与特效', status: '下架中', image: '/images/teacher/fj.png', students: 42 },
{ id: 8, name: 'HTML5 新特性详解', status: '发布中', image: '/images/teacher/fj.png', students: 89 },
{ id: 9, name: 'Web 性能优化指南', status: '发布中', image: '/images/teacher/fj.png', students: 67 },
{ id: 10, name: '移动端适配实战', status: '发布中', image: '/images/teacher/fj.png', students: 54 },
{ id: 11, name: '微信小程序开发', status: '下架中', image: '/images/teacher/fj.png', students: 38 },
{ id: 12, name: 'Flutter 跨平台开发', status: '发布中', image: '/images/teacher/fj.png', students: 29 },
]);
const searchValue = ref<string>('')
const activeTab = ref<string>('ongoing')
//
const navigateToCreateCourse = () => {
router.push('/teacher/course-create');
};
//
const getOptionsForCourse = (course: any) => {
if (course.status === '发布中') {
return [
{ label: '下架', value: 'offline', icon: '/images/teacher/下架.png' },
{ label: '编辑', value: 'edit', icon: '/images/teacher/小编辑.png' },
{ label: '移动', value: 'move', icon: '/images/teacher/移动.png' },
{ label: '删除', value: 'delete', icon: '/images/teacher/删除.png' }
];
} else if (course.status === '下架中') {
return [
{ label: '发布', value: 'publish', icon: '/images/teacher/加号.png' },
{ label: '编辑', value: 'edit', icon: '/images/teacher/小编辑.png' },
{ label: '移动', value: 'move', icon: '/images/teacher/移动.png' },
{ label: '删除', value: 'delete', icon: '/images/teacher/删除.png' }
];
}
return [];
};
//
const renderOptionLabel = (option: any) => {
return h('div', {
style: {
display: 'flex',
alignItems: 'center',
gap: '6px'
}
}, [
h('img', {
src: option.icon,
alt: '',
style: {
width: '13px',
height: '13px'
}
}),
option.label
]);
};
//
const handleOptionSelect = (value: string, course: any) => {
console.log('选择了操作:', value, '课程:', course);
// value
switch (value) {
case 'edit':
// -
router.push(`/teacher/course-create/${course.id}`);
break;
case 'delete':
//
handleDeleteCourse(course);
break;
case 'offline':
//
handleOfflineCourse(course);
break;
case 'publish':
//
handlePublishCourse(course);
break;
case 'move':
//
handleMoveCourse(course);
break;
default:
break;
}
};
//
const handleDeleteCourse = (course: any) => {
dialog.warning({
title: '确认删除',
content: `确定要删除课程"${course.name}"吗?此操作不可撤销。`,
positiveText: '确定删除',
negativeText: '取消',
onPositiveClick: () => {
const index = courseList.value.findIndex(c => c.id === course.id);
if (index > -1) {
courseList.value.splice(index, 1);
message.success(`课程"${course.name}"已删除`);
}
}
});
};
//
const handleOfflineCourse = (course: any) => {
const targetCourse = courseList.value.find(c => c.id === course.id);
if (targetCourse) {
targetCourse.status = '下架中';
message.success(`课程"${course.name}"已下架`);
}
};
//
const handlePublishCourse = (course: any) => {
const targetCourse = courseList.value.find(c => c.id === course.id);
if (targetCourse) {
targetCourse.status = '发布中';
message.success(`课程"${course.name}"已发布`);
}
};
//
const handleMoveCourse = (course: any) => {
const currentIndex = courseList.value.findIndex(c => c.id === course.id);
const totalCourses = courseList.value.length;
dialog.create({
title: '移动课程位置',
content: () => h('div', [
h('p', `课程"${course.name}"当前位置:第 ${currentIndex + 1}`),
h('p', { style: 'margin-top: 10px; margin-bottom: 10px;' }, '移动到位置:'),
h('input', {
type: 'number',
min: 1,
max: totalCourses,
value: currentIndex + 1,
style: 'width: 100%; padding: 8px; border: 1px solid #d9d9d9; border-radius: 4px;',
placeholder: `请输入位置 (1-${totalCourses})`,
id: 'movePositionInput'
}),
h('p', {
style: 'margin-top: 8px; font-size: 12px; color: #666;'
}, `提示:输入 1-${totalCourses} 之间的数字`)
]),
positiveText: '确定移动',
negativeText: '取消',
onPositiveClick: () => {
const input = document.getElementById('movePositionInput') as HTMLInputElement;
const newPosition = parseInt(input.value);
if (isNaN(newPosition) || newPosition < 1 || newPosition > totalCourses) {
message.error(`请输入有效的位置 (1-${totalCourses})`);
return false; //
}
//
const targetIndex = newPosition - 1;
if (targetIndex !== currentIndex) {
//
const [movedCourse] = courseList.value.splice(currentIndex, 1);
//
courseList.value.splice(targetIndex, 0, movedCourse);
message.success(`课程"${course.name}"已移动到第 ${newPosition}`);
} else {
message.info('位置未发生变化');
}
return true; //
}
});
};
</script>
@ -125,10 +285,9 @@ const navigateToCreateCourse = () => {
}
.top {
padding: 20px;
padding: 10px 20px;
white-space: nowrap;
/* 进一步减少左右padding */
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
@ -139,94 +298,21 @@ const navigateToCreateCourse = () => {
}
.nav-links {
display: flex;
align-items: center;
flex: 1;
}
.actions {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 12px;
}
.top a {
margin-right: 74px;
display: inline-block;
height: 60px;
line-height: 60px;
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 16px;
color: #333333;
text-decoration: none;
position: relative;
}
.top a.active {
color: #1890ff;
}
.top a.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background-color: #1890ff;
}
.create-btn {
background-color: #0288D1;
color: white;
border: none;
padding: 7px 16px;
border-radius: 2px;
font-size: 14px;
cursor: pointer;
margin-right: 24px;
/* 增加按钮与搜索框间距 */
transition: background-color 0.3s;
}
.create-btn:hover {
background-color: #40a9ff;
}
.search-container {
display: flex;
align-items: center;
border: 1px solid #d9d9d9;
border-radius: 2px;
overflow: hidden;
}
.search-container input {
border: none;
padding: 7px 12px;
outline: none;
font-size: 14px;
width: 240px;
/* 增加搜索框宽度 */
}
.search-btn {
background-color: #0288D1;
border: none;
border-left: 1px solid #d9d9d9;
padding: 8px 16px;
cursor: pointer;
font-size: 16px;
color: #FFFFFF;
transition: background-color 0.3s;
line-height: 18px;
text-align: left;
font-style: normal;
text-transform: none;
}
.search-btn:hover {
background-color: #e8e8e8;
}
.course-container {
@ -266,7 +352,7 @@ const navigateToCreateCourse = () => {
.course-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-5px);
/* transform: translateY(-5px); */
}
.course-image-container {
@ -307,10 +393,12 @@ const navigateToCreateCourse = () => {
font-size: 18px;
font-weight: bold;
color: #333;
display: block;
padding: 0;
margin: 0;
display: flex;
line-height: 1;
width: 32px;
height: 32px;
justify-content: center;
align-items: center;
}
.course-image-container img {
@ -318,83 +406,6 @@ const navigateToCreateCourse = () => {
height: 13px;
}
/* 添加更多选项按钮样式 */
.more-options {
position: relative;
cursor: pointer;
}
.more-icon {
font-size: 18px;
font-weight: bold;
color: #333;
display: block;
padding: 5px;
margin: 0;
line-height: 1;
}
.options-menu {
position: absolute;
top: 100%;
right: 0;
background: white;
border: 1px solid #eee;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
display: none;
z-index: 10;
width: auto;
min-width: 59px;
height: auto;
}
.more-options:hover .options-menu {
display: block;
}
.option-item {
display: flex;
align-items: center;
padding: 5px 8px;
color: #333;
text-decoration: none;
font-size: 12px;
text-align: center;
height: 24px;
line-height: 14px;
box-sizing: border-box;
border-bottom: 1px solid #f0f0f0;
}
.icon-edit,
.icon-view,
.icon-delete,
.icon-more {
margin-right: 4px;
color: #1890ff;
}
.icon-view {
color: #52c41a;
}
.icon-delete {
color: #f5222d;
}
.icon-more {
color: #faad14;
}
.option-item:last-child {
border-bottom: none;
}
.option-item:hover {
background-color: #f5f5f5;
}
.course-info {
display: flex;
flex-direction: column;
@ -523,12 +534,15 @@ const navigateToCreateCourse = () => {
border-color: #1890ff;
}
:deep(.n-tabs.n-tabs--top .n-tab-pane){
padding: 0;
}
/* 响应式设计 - 让卡片图片和文字响应式放大 */
/* 超大屏幕 (≥1920px) - 图片和文字放大 */
@media (min-width: 1920px) {
.course-category {
max-width: 1800px;
margin: 0 auto;
background: #fff;
border-radius: 8px;
@ -575,7 +589,6 @@ const navigateToCreateCourse = () => {
/* 超大屏幕 (1600px+) */
@media (min-width: 1600px) {
.course-category {
max-width: 1500px;
margin: 0 auto;
}

View File

@ -3,6 +3,17 @@
<div class="form-container">
<!-- 表单内容 -->
<div class="form-content">
<div class="header-left">
<n-button quaternary circle size="large" @click="goBack">
<template #icon>
<n-icon>
<ArrowBackOutline />
</n-icon>
</template>
</n-button>
<h2 class="page-title">{{ pageTitle }}</h2>
</div>
<!-- 上半部分两列布局 -->
<div class="form-row">
<!-- 左列 -->
@ -163,9 +174,10 @@
</template>
<script setup lang="ts">
import { reactive, ref, shallowRef, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { reactive, ref, shallowRef, onBeforeUnmount, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useMessage } from 'naive-ui'
import { ArrowBackOutline } from '@vicons/ionicons5';
import {
NInput,
NDatePicker,
@ -179,8 +191,16 @@ import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
const router = useRouter()
const route = useRoute()
const message = useMessage()
//
const isEditMode = computed(() => !!route.params.id)
const courseId = computed(() => route.params.id as string)
//
const pageTitle = computed(() => isEditMode.value ? '编辑课程' : '创建课程')
// shallowRef
const editorRef = shallowRef()
@ -225,6 +245,80 @@ const formData = reactive({
requiredPoints: 60
})
//
const mockCourseData = {
1: {
courseName: '前端开发基础课程',
courseCategory: 'frontend',
instructors: ['李清林', '张老师'],
sort: '1',
startTime: new Date('2024-01-15 09:00:00').getTime(),
endTime: new Date('2024-03-15 18:00:00').getTime(),
studentType: 'partial',
selectedClasses: ['frontend-class', 'fullstack-class'],
courseDescription: '<p>这是一门全面的前端开发基础课程涵盖HTML、CSS、JavaScript等核心技术。</p><p>课程特色:</p><ul><li>理论与实践相结合</li><li>项目驱动学习</li><li>一对一指导</li></ul>',
stopOnLeave: true,
videoSpeedControl: true,
showVideoText: false,
pointsEnabled: true,
earnPoints: 80,
requiredPoints: 50,
courseCover: '/images/teacher/fj.png'
},
2: {
courseName: 'Vue.js 实战教程',
courseCategory: 'frontend',
instructors: ['刘树光'],
sort: '2',
startTime: new Date('2024-02-01 10:00:00').getTime(),
endTime: new Date('2024-04-01 17:00:00').getTime(),
studentType: 'all',
selectedClasses: [],
courseDescription: '<p>深入学习Vue.js框架掌握现代前端开发技能。</p>',
stopOnLeave: false,
videoSpeedControl: true,
showVideoText: true,
pointsEnabled: true,
earnPoints: 100,
requiredPoints: 60,
courseCover: '/images/teacher/fj.png'
}
}
//
const loadCourseData = async (id: string) => {
try {
// API
await new Promise(resolve => setTimeout(resolve, 500))
const courseData = mockCourseData[id as unknown as keyof typeof mockCourseData]
if (courseData) {
//
Object.assign(formData, courseData)
// URL
if (courseData.courseCover) {
previewUrl.value = courseData.courseCover
}
message.success('课程数据加载成功')
} else {
message.error('课程不存在')
router.back()
}
} catch (error) {
message.error('加载课程数据失败')
console.error('Load course data error:', error)
}
}
//
onMounted(() => {
if (isEditMode.value && courseId.value) {
loadCourseData(courseId.value)
}
})
//
@ -340,19 +434,37 @@ const handleSubmit = async () => {
return
}
if (!formData.courseCover || !previewUrl.value) {
if (!formData.courseCover && !previewUrl.value) {
message.error('请上传课程封面')
return
}
console.log('表单数据:', formData)
// API
await new Promise(resolve => setTimeout(resolve, 1000))
if (isEditMode.value) {
//
message.success('课程更新成功!')
console.log('更新课程ID:', courseId.value)
} else {
//
message.success('课程创建成功!')
}
//
router.push('/teacher/course-management')
} catch (error) {
console.error('提交失败:', error)
message.error('提交失败,请重试')
const errorMessage = isEditMode.value ? '更新失败,请重试' : '创建失败,请重试'
message.error(errorMessage)
}
}
const goBack = () => {
router.back()
}
</script>
<style scoped>
@ -370,6 +482,32 @@ const handleSubmit = async () => {
padding: 24px 24px 0 24px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
}
.title {
margin: 0;
font-size: 20px;
font-weight: 500;
color: #333;
}
.header-actions {
display: flex;
gap: 12px;
}
.form-row {
display: flex;
gap: 100px;

View File

@ -6,30 +6,21 @@
选择文件:
</label>
<div class="select-wrapper">
<select
v-model="selectedFolder"
class="form-select"
@change="handleFolderChange"
>
<option value="" disabled>请选择文件夹</option>
<option
v-for="folder in availableFolders"
:key="folder.id"
:value="folder.id"
>
{{ folder.name }}
</option>
</select>
<div class="select-arrow">
<img src="/images/teacher/箭头-灰.png" alt="下拉箭头" class="arrow-icon">
</div>
<n-select
v-model:value="selectedFolder"
:options="folderOptions"
placeholder="请选择文件夹"
clearable
@update:value="handleFolderChange"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, computed } from 'vue'
import { NSelect } from 'naive-ui'
// Props
const props = defineProps({
@ -51,19 +42,27 @@ const props = defineProps({
const emit = defineEmits(['confirm'])
//
const selectedFolder = ref('')
const selectedFolder = ref<number | null>(null)
// n-select
const folderOptions = computed(() => {
return props.availableFolders.map(folder => ({
label: folder.name,
value: folder.id
}))
})
//
watch(() => props.visible, (newVal) => {
if (newVal) {
selectedFolder.value = ''
selectedFolder.value = null
}
})
//
watch(selectedFolder, (newVal) => {
if (props.getValue && newVal) {
const folder = props.availableFolders.find((f: { id: number }) => f.id === newVal as unknown as number)
const folder = props.availableFolders.find((f: { id: number }) => f.id === newVal)
if (folder) {
props.getValue(folder)
}
@ -71,9 +70,9 @@ watch(selectedFolder, (newVal) => {
})
//
const handleFolderChange = () => {
if (selectedFolder.value) {
const folder = props.availableFolders.find((f: { id: number }) => f.id === selectedFolder.value as unknown as number)
const handleFolderChange = (value: number | null) => {
if (value) {
const folder = props.availableFolders.find((f: { id: number }) => f.id === value)
if (folder) {
emit('confirm', folder)
}
@ -84,7 +83,7 @@ const handleFolderChange = () => {
defineExpose({
getSelectedFolder: () => selectedFolder.value,
clearSelection: () => {
selectedFolder.value = ''
selectedFolder.value = null
}
})
</script>
@ -121,96 +120,37 @@ defineExpose({
min-width: 0;
}
.form-select {
width: 100%;
/* Naive UI Select 组件样式定制 */
:deep(.select-wrapper .n-base-selection) {
height: 36px;
padding: 0 12px;
padding-right: 32px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
color: #333;
background: white;
transition: all 0.3s ease;
box-sizing: border-box;
appearance: none;
cursor: pointer;
background-image: none;
}
.form-select:focus {
outline: none;
:deep(.select-wrapper .n-base-selection:hover) {
border-color: #40a9ff;
}
:deep(.select-wrapper .n-base-selection.n-base-selection--focus) {
border-color: #0288D1;
box-shadow: 0 0 0 2px rgba(2, 136, 209, 0.2);
}
.form-select:disabled {
background-color: #f5f5f5;
color: #bfbfbf;
cursor: not-allowed;
}
/* 下拉框选项样式 */
.form-select option {
padding: 8px 12px;
font-size: 14px;
:deep(.select-wrapper .n-base-selection-input) {
color: #333;
background: white;
border: none;
font-size: 14px;
}
.form-select option:hover {
background-color: #f5f5f5;
}
.form-select option:checked {
background-color: #e6f7ff;
color: #0288D1;
}
.form-select option:disabled {
:deep(.select-wrapper .n-base-selection-placeholder) {
color: #bfbfbf;
background-color: #f5f5f5;
font-style: italic;
font-size: 14px;
}
/* 默认提示选项样式 */
.form-select option[value=""] {
color: #bfbfbf;
background-color: #fafafa;
font-style: italic;
}
/* 下拉框悬停效果 */
.form-select:hover:not(:disabled) {
border-color: #40a9ff;
}
/* 确保下拉框在不同浏览器中显示一致 */
.form-select::-ms-expand {
display: none;
}
.select-arrow {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
transition: opacity 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.arrow-icon {
width: 12px;
height: 12px;
object-fit: contain;
transition: opacity 0.3s ease;
}
.form-select:focus + .select-arrow .arrow-icon {
opacity: 0.8;
:deep(.select-wrapper .n-base-selection-label) {
color: #333;
font-size: 14px;
}
</style>

View File

@ -48,6 +48,8 @@ import CourseEditor from '@/views/teacher/course/CourseEditor.vue'
import CoursewareManagement from '@/views/teacher/course/CoursewareManagement.vue'
import ChapterManagement from '@/views/teacher/course/ChapterManagement.vue'
import QuestionBankManagement from '@/views/teacher/course/QuestionBankManagement.vue'
import FileViewer from '@/views/teacher/course/FileViewer.vue'
import FolderBrowser from '@/views/teacher/course/FolderBrowser.vue'
import CertificateManagement from '@/views/teacher/certificate/CertificateManagement.vue'
import DiscussionManagement from '@/views/teacher/course/DiscussionManagement.vue'
import StatisticsManagement from '@/views/teacher/course/StatisticsManagement.vue'
@ -123,7 +125,7 @@ const routes: RouteRecordRaw[] = [
]
},
{
path: 'course-create',
path: 'course-create/:id?',
name: 'CourseCreate',
component: CourseCreate,
meta: { title: '创建课程' }
@ -265,7 +267,19 @@ const routes: RouteRecordRaw[] = [
name: 'GeneralManagement',
component: GeneralManagement,
meta: { title: '综合管理' }
}
},
{
path: 'file-viewer/:fileId',
name: 'FileViewer',
component: FileViewer,
meta: { title: '文件查看' }
},
{
path: 'folder-browser/:folderId',
name: 'FolderBrowser',
component: FolderBrowser,
meta: { title: '文件夹浏览' }
},
]
},
{

View File

@ -234,13 +234,13 @@ const columns = [
h(NButton, {
size: 'small',
type: 'primary',
class: 'revoke-btn',
ghost: true,
onClick: () => handleRevoke(row)
}, { default: () => '撤回' }),
h(NButton, {
size: 'small',
type: 'error',
class: 'delete-btn',
ghost: true,
onClick: () => handleDelete(row)
}, { default: () => '删除' })
])

View File

@ -1,52 +1,53 @@
<template>
<div class="modal-container flex-col">
<span class="modal-title">添加课件</span>
<h2 class="modal-title">添加课件</h2>
<n-divider />
<div class="upload-section flex-row">
<div class="label-group flex-col justify-between">
<span class="upload-path-label">上传路径</span>
<span class="upload-file-label">上传文件</span>
</div>
<div class="button-group flex-col justify-between">
<n-popselect
:options="folderOptions"
trigger="click"
placement="bottom-start"
v-model:value="selectedFolder"
@update:value="handleFolderSelect"
>
<div class="select-path-button flex-col">
<span class="button-text file-input-label">选择路径</span>
</div>
</n-popselect>
<n-popselect
:options="uploadOptions"
trigger="click"
placement="bottom-start"
@update:value="handleUploadOptionSelect"
:render-label="renderUploadOption"
>
<div class="select-file-button flex-col">
<span class="button-text file-input-label">选择文件</span>
</div>
</n-popselect>
<!-- 隐藏的文件输入框 -->
<input
ref="localFileInput"
type="file"
id="pathUpload"
@change="handleLocalFileUpload"
style="display: none;"
accept=".doc,.docx,.pdf,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4"
/>
<label for="pathUpload" class="button-text file-input-label">选择路径</label>
</div>
<div class="select-file-button flex-col" style="position: relative;">
<div class="button-text file-input-label" @click="toggleResourceDropdown">选择文件</div>
<div v-show="showResourceDropdown" class="upload-type flex-col">
<label class="upload-type-text">
<input
ref="resourceFileInput"
type="file"
@change="handleDropdownLocalUpload"
@change="handleResourceFileUpload"
style="display: none;"
accept=".doc,.docx,.pdf,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4"
/>
本地上传
</label>
<label class="upload-type-text">
<input
type="file"
@change="handleDropdownResourceUpload"
style="display: none;"
accept=".doc,.docx,.pdf,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4"
/>
资源上传
</label>
</div>
</div>
</div>
<div class="folder-display flex-col justify-between">
<span class="folder-name">文件夹一</span>
<div class="existing-folders flex-col">
<span class="folder-item">已由文件夹名称一</span>
<span class="folder-item">已由文件夹名称二</span>
</div>
<span class="folder-name">{{ getSelectedFolderName() }}</span>
</div>
</div>
<div class="supported-formats flex-row">
@ -74,10 +75,71 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, h } from 'vue'
//
const showResourceDropdown = ref(false)
//
const selectedFolder = ref<string>('')
//
const uploadOptions = ref([
{ label: '本地上传', value: 'local' },
{ label: '资源上传', value: 'resource' }
])
//
const localFileInput = ref<HTMLInputElement>()
const resourceFileInput = ref<HTMLInputElement>()
//
const folderOptions = ref([
{ label: '文件夹一', value: 'folder1' },
{ label: '文件夹二', value: 'folder2' },
{ label: '文件夹三', value: 'folder3' },
{ label: '文档资料', value: 'documents' },
{ label: '视频课件', value: 'videos' },
{ label: '音频文件', value: 'audios' },
{ label: '演示文稿', value: 'presentations' },
{ label: '其他资源', value: 'others' }
])
//
const renderUploadOption = (option: any) => {
return h('span', {
style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '5px 0'
}
}, option.label)
}
//
const handleUploadOptionSelect = (value: string) => {
console.log('选中的上传方式:', value)
if (value === 'local') {
//
localFileInput.value?.click()
} else if (value === 'resource') {
//
resourceFileInput.value?.click()
}
}
//
const getSelectedFolderName = () => {
if (!selectedFolder.value) {
return '请选择文件夹'
}
const folder = folderOptions.value.find(option => option.value === selectedFolder.value)
return folder ? folder.label : '未知文件夹'
}
//
const handleFolderSelect = (value: string) => {
console.log('选中的文件夹:', value)
//
}
//
const handleLocalFileUpload = (event: Event) => {
@ -99,23 +161,6 @@ const handleResourceFileUpload = (event: Event) => {
}
}
//
const toggleResourceDropdown = () => {
showResourceDropdown.value = !showResourceDropdown.value
}
//
const handleDropdownLocalUpload = (event: Event) => {
handleLocalFileUpload(event)
showResourceDropdown.value = false
}
//
const handleDropdownResourceUpload = (event: Event) => {
handleResourceFileUpload(event)
showResourceDropdown.value = false
}
//
const emit = defineEmits(['close'])
@ -163,7 +208,8 @@ const handleConfirm = () => {
height: 160px;
background: #FCFCFC ;
background-size: 100% 100%;
margin: 46px 0 0 22px;
border: 1px solid rgb(233, 233, 233);
margin: 0 0 0 22px;
}
.label-group {
@ -453,41 +499,7 @@ const handleConfirm = () => {
background: #0277BD;
}
.upload-type {
position: absolute;
left: 0;
top: 100%;
width: 123px;
height: 76px;
background: #FFFFFF;
background-size: 241px 194px;
border: 1px solid #e0e0e0;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.upload-type-text {
width: 100%;
height: 30px;
overflow-wrap: break-word;
color: rgba(51, 51, 51, 1);
font-size: 14px;
font-family: Helvetica, 'Microsoft YaHei', Arial, sans-serif;
font-weight: normal;
text-align: center;
white-space: nowrap;
line-height: 30px;
margin: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 5px 0;
}
.upload-type-text:hover {
background-color: #f5f5f5;
}
/* 文件输入框标签样式 */
.file-input-label {

View File

@ -4,6 +4,13 @@
<!-- 表单内容 -->
<div class="form-content">
<!-- 上半部分两列布局 -->
<n-button quaternary circle size="large" @click="handleCancel" class="back-button">
<template #icon>
<n-icon>
<ArrowBackOutline />
</n-icon>
</template>
</n-button>
<div class="form-row">
<!-- 左列 -->
<div class="form-column">
@ -187,6 +194,7 @@ import {
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()
@ -307,8 +315,6 @@ const creatorOptions = [
//
const handleCancel = () => {
router.back()

View File

@ -5,6 +5,7 @@
'tablet-layout': isTablet,
'sidebar-collapsed': sidebarCollapsed
}">
<!-- 移动端菜单按钮 -->
<n-button v-if="isMobile" class="mobile-menu-toggle" quaternary @click="toggleSidebar">
<span class="menu-icon"></span>
@ -19,6 +20,14 @@
<span class="close-icon">×</span>
</n-button>
<n-button quaternary circle size="large" @click="goBack" class="back-button">
<template #icon>
<n-icon>
<ArrowBackOutline />
</n-icon>
</template>
</n-button>
<n-button class="header-section flex-row justify-between" type="primary">
<template #icon>
<img class="chapter-icon" referrerpolicy="no-referrer" src="/images/teacher/加号_4.png" />
@ -216,6 +225,10 @@ import CustomDropdown from '@/components/CustomDropdown.vue';
import HomeworkDropdown from '@/components/HomeworkDropdown.vue';
import ExamPaperLibraryModal from '@/components/ExamPaperLibraryModal.vue';
import HomeworkLibraryModal from '@/components/HomeworkLibraryModal.vue';
import { ArrowBackOutline } from '@vicons/ionicons5';
import { useRouter } from 'vue-router';
const router = useRouter();
//
const isMenuVisible = ref(false);
@ -544,6 +557,10 @@ const removeLessonSection = (sectionId: number) => {
}
};
const goBack = () => {
router.back();
}
// 使
</script>
@ -565,6 +582,10 @@ const removeLessonSection = (sectionId: number) => {
height: auto !important;
}
.back-button{
margin: 10px 0 0 10px;
}
:deep(.n-button.mobile-close) {
position: absolute;
top: 15px;
@ -579,8 +600,6 @@ const removeLessonSection = (sectionId: number) => {
}
:deep(.n-button.header-section) {
width: 194px !important;
height: 48px !important;
background-color: #0288D1 !important;
border-radius: 4px !important;
margin: 31px 0 0 40px !important;

View File

@ -4,24 +4,29 @@
<div class="toolbar">
<h2>全部章节</h2>
<div class="toolbar-actions">
<button class="btn btn-primary" @click="addChapter">添加章节</button>
<button class="btn btn-new" @click="importChapters">导入</button>
<button class="btn btn-new" @click="exportChapters">导出</button>
<button class="btn btn-danger" @click="deleteSelected" :disabled="selectedChapters.length === 0">删除</button>
<div class="search-box">
<input type="text" placeholder="请输入想要搜索的内容" v-model="searchKeyword" />
<button class="btn btn-search" @click="searchChapters">搜索</button>
<n-space>
<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>
<div class="search-container">
<n-input
v-model:value="searchKeyword"
placeholder="请输入想要搜索的内容"
style="width: 200px;"
>
</n-input>
<n-button type="primary" @click="searchChapters">搜索</n-button>
</div>
</n-space>
</div>
</div>
<!-- 章节列表表格 -->
<div class="table-box">
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
<n-data-table :columns="columns" :data="paginatedChapters" :row-key="rowKey"
:checked-row-keys="selectedChapters" @update:checked-row-keys="handleCheck" :bordered="false"
:single-line="false" size="medium" class="chapter-data-table" :row-class-name="rowClassName" scroll-x="true" />
</n-config-provider>
<!-- 自定义分页器 -->
<div class="custom-pagination">
@ -61,7 +66,7 @@
<script setup lang="ts">
import { ref, computed, h } from 'vue'
import { NButton, useMessage, NDataTable, NConfigProvider, zhCN, dateZhCN } from 'naive-ui'
import { NButton, useMessage, NDataTable, NInput, NSpace } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { useRouter, useRoute } from 'vue-router'
@ -506,7 +511,6 @@ const columns: DataTableColumns<Chapter> = [
.chapter-management {
width: 100%;
background: #fff;
height: 100%;
overflow: auto;
}
@ -574,9 +578,6 @@ const columns: DataTableColumns<Chapter> = [
border: 1px solid #FF4D4F;
}
.btn-danger:hover {
background: #ff7875;
}
.search-box {
display: flex;
@ -779,7 +780,7 @@ const columns: DataTableColumns<Chapter> = [
}
.page-number:hover:not(.disabled) {
color: #0088D1;
/* color: #0088D1; */
border-color: #0088D1;
}

View File

@ -43,7 +43,7 @@
<div class="menu-group">
<div class="menu-header" @click="togglePractice">
<img src="/images/teacher/练考通.png" alt="练考通" />
<span>练考通</span>
<span>考试管理</span>
<i class="n-base-icon" :class="{ expanded: practiceExpanded }">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
@ -55,7 +55,7 @@
<div class="submenu" v-show="practiceExpanded">
<router-link :to="`/teacher/course-editor/${courseId}/practice/exam`" class="submenu-item"
:class="{ active: $route.path.includes('/practice/exam') }">
<span>试卷</span>
<span>试卷管理</span>
</router-link>
<router-link :to="`/teacher/course-editor/${courseId}/practice/review`" class="submenu-item"
:class="{ active: $route.path.includes('/practice/review') }">
@ -63,12 +63,12 @@
</router-link>
</div>
</div>
<router-link :to="`/teacher/course-editor/${courseId}/question-bank`" class="menu-item"
<!-- <router-link :to="`/teacher/course-editor/${courseId}/question-bank`" class="menu-item"
:class="{ active: $route.path.includes('question-bank') }">
<img :src="$route.path.includes('question-bank') ? '/images/teacher/题库-选中.png' : '/images/teacher/题库.png'"
alt="题库" />
<span>题库</span>
</router-link>
</router-link> -->
<router-link :to="`/teacher/course-editor/${courseId}/certificate`" class="menu-item"
:class="{ active: $route.path.includes('certificate') }">
<img :src="$route.path.includes('certificate') ? '/images/teacher/证书-选中.png' : '/images/teacher/证书.png'"
@ -87,12 +87,12 @@
alt="统计" />
<span>统计</span>
</router-link>
<router-link :to="`/teacher/course-editor/${courseId}/notification`" class="menu-item"
<!-- <router-link :to="`/teacher/course-editor/${courseId}/notification`" class="menu-item"
:class="{ active: $route.path.includes('notification') }">
<img :src="$route.path.includes('notification') ? '/images/teacher/通知-选中.png' : '/images/teacher/通知.png'"
alt="通知" />
<span>通知</span>
</router-link>
</router-link> -->
<router-link :to="`/teacher/course-editor/${courseId}/management`" class="menu-item"
:class="{ active: $route.path.includes('management') }">
<img :src="$route.path.includes('management') ? '/images/teacher/管理-选中.png' : '/images/teacher/管理.png'"
@ -285,9 +285,6 @@ const hideSidebar = computed(() => {
transform: rotate(90deg);
}
.submenu {
}
.submenu-item {
display: block;
padding: 12px 15px;

View File

@ -19,11 +19,11 @@
<!-- 文件列表表格 -->
<div class="table-box">
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
<div style="flex: 1;">
<n-data-table :columns="columns" :data="paginatedFiles" :row-key="rowKey" :checked-row-keys="selectedFiles"
@update:checked-row-keys="handleCheck" :bordered="false" :single-line="false" size="medium"
class="file-data-table" :row-class-name="rowClassName" style="width: 100%" />
</n-config-provider>
</div>
<!-- 自定义分页器 -->
<div class="custom-pagination">
@ -60,7 +60,7 @@
</div>
<!-- 添加课件模态框 -->
<div v-if="showAddCoursewareModal" class="modal-overlay" @click="closeAddCoursewareModal">
<div v-if="showAddCoursewareModal" class="modal-overlay">
<AddCoursewareModal @close="closeAddCoursewareModal" />
</div>
@ -88,12 +88,28 @@
:get-value="getValue" />
</template>
</CommonModal>
<!-- 重命名模态框 -->
<CommonModal :visible="showRenameModal" title="重命名" @close="closeRenameModal" @confirm="handleRename">
<template #content>
<div class="rename-content">
<n-input
v-model:value="renameInputValue"
placeholder="请输入新的文件名"
style="width: 100%"
ref="renameInputRef"
@keyup.enter="handleRename"
/>
</div>
</template>
</CommonModal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
import { NButton, NDropdown, NTag, useMessage, NDataTable, NConfigProvider, zhCN, dateZhCN } from 'naive-ui'
import { ref, computed, h, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NDropdown, NTag, NInput, useMessage, NDataTable } from 'naive-ui'
import type { DataTableColumns, DropdownOption } from 'naive-ui'
import AddCoursewareModal from './AddCoursewareModal.vue'
import UploadFileModal from './UploadFileModal.vue'
@ -103,6 +119,8 @@ import CreateFolderContent from '@/components/common/CreateFolderContent.vue'
import MoveFileContent from '@/components/common/MoveFileContent.vue'
const message = useMessage()
const route = useRoute()
const router = useRouter()
//
interface FileItem {
@ -129,10 +147,19 @@ const showUploadFileModal = ref(false)
const showDeleteConfirmModal = ref(false)
const showCreateFolderModal = ref(false)
const showMoveFileModal = ref(false)
const showRenameModal = ref(false)
//
const itemsToDelete = ref<{ type: 'single' | 'multiple', data: any }>({ type: 'single', data: null })
//
const currentRenameFile = ref<FileItem | null>(null)
//
const renameInputValue = ref('')
//
const renameInputRef = ref<InstanceType<typeof NInput> | null>(null)
//
const currentUploadTarget = ref<FileItem | null>(null)
@ -700,7 +727,25 @@ const closeUploadFileModal = () => {
}
const viewFile = (file: FileItem) => {
message.info('查看文件: ' + file.name)
if (file.type === 'folder') {
//
router.push({
name: 'FolderBrowser',
params: {
id: route.params.id, // ID
folderId: file.id.toString()
}
})
} else {
//
router.push({
name: 'FileViewer',
params: {
id: route.params.id, // ID
fileId: file.id.toString()
}
})
}
}
const deleteFile = (file: FileItem) => {
@ -745,11 +790,66 @@ const cancelDelete = () => {
}
const renameFile = (file: FileItem) => {
const newName = prompt('请输入新的文件名:', file.name)
if (newName && newName !== file.name) {
file.name = newName
message.success('重命名成功')
//
currentRenameFile.value = file
//
renameInputValue.value = file.name
showRenameModal.value = true
// 使 nextTick
nextTick(() => {
renameInputRef.value?.focus()
})
}
//
const closeRenameModal = () => {
showRenameModal.value = false
currentRenameFile.value = null
renameInputValue.value = ''
}
//
const handleRename = () => {
if (!currentRenameFile.value || !renameInputValue.value.trim()) {
message.error('文件名不能为空')
return
}
const newName = renameInputValue.value.trim()
const oldName = currentRenameFile.value.name
//
if (newName === oldName) {
message.info('文件名未发生变化')
closeRenameModal()
return
}
//
const originalFile = findFileById(fileList.value, currentRenameFile.value.id)
if (originalFile) {
originalFile.name = newName
message.success(`文件重命名成功:"${oldName}" → "${newName}"`)
} else {
message.error('未找到要重命名的文件')
}
closeRenameModal()
}
//
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
}
}
return null
}
//
@ -829,8 +929,10 @@ const toggleFolder = (folder: FileItem) => {
.courseware-management {
width: 100%;
background: #fff;
height: 100%;
overflow: auto;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 顶部工具栏 */
@ -907,8 +1009,8 @@ const toggleFolder = (folder: FileItem) => {
.btn-default:disabled,
.btn-danger:disabled {
/* opacity: 0.5; */
/* cursor: not-allowed; */
opacity: 0.5;
cursor: not-allowed;
}
/* 模态框遮罩层样式 */
@ -925,6 +1027,11 @@ const toggleFolder = (folder: FileItem) => {
z-index: 1000;
}
/* 重命名模态框内容样式 */
.rename-content {
padding: 10px 0;
}
.search-box {
display: flex;
align-items: center;
@ -959,6 +1066,7 @@ const toggleFolder = (folder: FileItem) => {
flex-direction: column;
height: 100%;
justify-content: space-between;
flex: 1;
}
/* Naive UI 表格样式定制 */
@ -1297,7 +1405,6 @@ const toggleFolder = (folder: FileItem) => {
}
.page-number:hover:not(.disabled) {
color: #0088D1;
border-color: #0088D1;
}

View File

@ -0,0 +1,800 @@
<template>
<div class="file-viewer">
<!-- 顶部导航栏 -->
<div class="header">
<div class="nav-left">
<n-button quaternary circle size="large" @click="goBack" class="back-button">
<template #icon>
<n-icon>
<ArrowBackOutline />
</n-icon>
</template>
</n-button>
<div class="breadcrumb">
<span
v-for="(crumb, index) in breadcrumbs"
:key="crumb.id"
class="breadcrumb-item"
@click="navigateTo(crumb)"
>
{{ crumb.name }}
<span v-if="index < breadcrumbs.length - 1" class="separator">></span>
</span>
</div>
</div>
<div class="nav-right">
<button class="btn btn-primary" @click="downloadFile" v-if="currentFile && currentFile.type !== 'folder'">
下载文件
</button>
</div>
</div>
<!-- 内容区域 -->
<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 class="file-header">
<div class="file-info">
<img :src="getFileIcon(currentFile.type)" :alt="currentFile.type" class="file-type-icon" />
<div class="file-details">
<h3>{{ currentFile.name }}</h3>
<div class="file-meta">
<span>文件大小{{ currentFile.size }}</span>
<span>创建时间{{ currentFile.createTime }}</span>
<span>创建人{{ currentFile.creator }}</span>
</div>
</div>
</div>
</div>
<!-- 文件预览内容 -->
<div class="preview-content">
<!-- 图片预览 -->
<div v-if="isImage(currentFile.type)" class="image-preview">
<img :src="getPreviewUrl(currentFile)" :alt="currentFile.name" />
</div>
<!-- 视频预览 -->
<div v-else-if="isVideo(currentFile.type)" class="video-preview">
<video controls :src="getPreviewUrl(currentFile)">
您的浏览器不支持视频播放
</video>
</div>
<!-- PDF预览 -->
<div v-else-if="isPdf(currentFile.type)" class="pdf-preview">
<iframe :src="getPreviewUrl(currentFile)" frameborder="0"></iframe>
</div>
<!-- 文档预览 -->
<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" />
</div>
<div class="file-preview-info">
<h4>{{ currentFile.name }}</h4>
<p class="file-description">该文件类型暂不支持在线预览</p>
<n-button type="primary" @click="downloadFile">下载查看</n-button>
</div>
</div>
</div>
<!-- 其他文件类型 -->
<div v-else class="unsupported-preview">
<div class="unsupported-placeholder">
<div class="file-preview-icon">
<img :src="getFileIcon(currentFile.type)" :alt="currentFile.type" />
</div>
<div class="file-preview-info">
<h4>{{ currentFile.name }}</h4>
<p class="file-description">该文件类型暂不支持预览</p>
<n-button type="primary" @click="downloadFile">下载查看</n-button>
</div>
</div>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-else class="loading-state">
<div class="loading-spinner"></div>
<p>加载中...</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { ArrowBackOutline } from '@vicons/ionicons5'
const route = useRoute()
const router = useRouter()
const message = useMessage()
//
interface FileItem {
id: number
name: string
type: string
size: string
creator: string
createTime: string
isTop: boolean
children?: FileItem[]
parentId?: number
}
// /
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 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'
}
return iconMap[type] || '/images/activity/file.png'
}
//
const isImage = (type: string) => ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'image'].includes(type)
const isVideo = (type: string) => ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'video'].includes(type)
const isPdf = (type: string) => type === 'pdf'
const isDocument = (type: string) => ['word', 'excel', 'ppt', 'txt'].includes(type)
// URLURL
const getPreviewUrl = (file: FileItem) => {
// URL
return `/api/files/preview/${file.id}`
}
//
const formatTime = (timeStr: string) => {
return timeStr.replace(/\./g, '-')
}
// 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
}
}
return null
}
//
const buildBreadcrumbs = (file: FileItem) => {
const crumbs: FileItem[] = []
let current = file
while (current) {
crumbs.unshift(current)
if (current.parentId) {
current = findFileById(mockFileData, current.parentId) as FileItem
} else {
break
}
}
breadcrumbs.value = crumbs
}
//
const loadFile = (fileId: number) => {
const file = findFileById(mockFileData, fileId)
if (file) {
currentFile.value = file
buildBreadcrumbs(file)
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
})
}
} else {
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() }
})
}
}
//
const goBack = () => {
router.back()
}
//
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()
}
}
//
onMounted(() => {
const fileId = parseInt(route.params.fileId as string)
if (fileId) {
loadFile(fileId)
}
})
//
const unwatchRoute = router.afterEach(() => {
const fileId = parseInt(route.params.fileId as string)
if (fileId) {
loadFile(fileId)
}
})
//
onUnmounted(() => {
unwatchRoute()
})
</script>
<style scoped>
.file-viewer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
/* 顶部导航栏 */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #e8e8e8;
background: #fff;
z-index: 10;
}
.nav-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: #f5f5f5;
border: 1px solid #d9d9d9;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.3s;
}
.back-btn:hover {
background: #e6f7ff;
border-color: #0288d1;
color: #0288d1;
}
.back-btn img {
width: 16px;
height: 16px;
}
.breadcrumb {
display: flex;
align-items: center;
font-size: 14px;
color: #666;
}
.breadcrumb-item {
cursor: pointer;
transition: color 0.3s;
}
.breadcrumb-item:hover {
color: #0288d1;
}
.breadcrumb-item:last-child {
color: #333;
font-weight: 500;
}
.separator {
margin: 0 8px;
color: #ccc;
}
.nav-right .btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background: #0288d1;
color: white;
}
.btn-primary:hover {
background: #0277bd;
}
/* 内容区域 */
.content {
flex: 1;
overflow: auto;
padding: 24px;
}
/* 文件夹视图 */
.folder-view {
max-width: 1200px;
margin: 0 auto;
}
.folder-header {
margin-bottom: 24px;
}
.folder-header h3 {
margin: 0 0 8px 0;
font-size: 24px;
color: #333;
}
.folder-info {
display: flex;
gap: 24px;
font-size: 14px;
color: #666;
}
.folder-content {
min-height: 400px;
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.file-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
border: 1px solid #e8e8e8;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
background: #fff;
}
.file-item:hover {
border-color: #0288d1;
box-shadow: 0 2px 8px rgba(2, 136, 209, 0.1);
transform: translateY(-2px);
}
.file-icon {
position: relative;
margin-bottom: 12px;
}
.file-icon img {
width: 48px;
height: 48px;
}
.top-badge {
position: absolute;
top: -8px;
right: -8px;
background: #ff4d4f;
color: white;
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
}
.file-name {
font-size: 14px;
color: #333;
text-align: center;
word-break: break-word;
margin-bottom: 8px;
font-weight: 500;
}
.file-meta {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
font-size: 12px;
color: #999;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
color: #999;
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.6;
}
/* 文件预览视图 */
.file-preview {
max-width: 1000px;
margin: 0 auto;
}
.file-header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e8e8e8;
}
.file-info {
display: flex;
align-items: center;
gap: 16px;
}
.file-type-icon {
width: 64px;
height: 64px;
}
.file-details h3 {
margin: 0 0 8px 0;
font-size: 20px;
color: #333;
}
.file-details .file-meta {
display: flex;
gap: 24px;
font-size: 14px;
color: #666;
}
.preview-content {
background: #f5f6fa;
border-radius: 8px;
padding: 32px 24px;
min-height: 500px;
display: flex;
align-items: center;
justify-content: center;
}
/* 图片预览 */
.image-preview img {
max-width: 100%;
max-height: 600px;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 视频预览 */
.video-preview video {
width: 100%;
max-width: 800px;
border-radius: 4px;
}
/* PDF预览 */
.pdf-preview iframe {
width: 100%;
height: 600px;
border-radius: 4px;
}
/* 文档预览占位 */
.document-preview,
.unsupported-preview {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.document-placeholder,
.unsupported-placeholder {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
max-width: 400px;
padding: 40px 20px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.file-preview-icon {
margin-bottom: 24px;
padding: 20px;
background: #f8f9fa;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.file-preview-icon img {
width: 64px;
height: 64px;
opacity: 0.8;
}
.file-preview-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.file-preview-info h4 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
word-break: break-word;
max-width: 100%;
}
.file-description {
margin: 0;
font-size: 14px;
color: #666;
line-height: 1.5;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
color: #666;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid #f3f3f3;
border-top: 3px solid #0288d1;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 响应式设计 */
@media (max-width: 768px) {
.content {
padding: 16px;
}
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
}
.folder-info {
flex-direction: column;
gap: 8px;
}
.file-details .file-meta {
flex-direction: column;
gap: 8px;
}
.preview-content {
padding: 20px 16px;
}
.document-placeholder,
.unsupported-placeholder {
max-width: 100%;
padding: 32px 16px;
}
.file-preview-icon {
margin-bottom: 20px;
padding: 16px;
}
.file-preview-icon img {
width: 48px;
height: 48px;
}
.file-preview-info h4 {
font-size: 16px;
}
.file-description {
font-size: 13px;
}
}
</style>

View File

@ -0,0 +1,630 @@
<template>
<div class="folder-browser">
<!-- 顶部导航栏 -->
<div class="header">
<div class="nav-left">
<n-button quaternary circle size="large" @click="goBack" class="back-button">
<template #icon>
<n-icon>
<ArrowBackOutline />
</n-icon>
</template>
</n-button>
<div class="breadcrumb">
<span
v-for="(crumb, index) in breadcrumbs"
:key="crumb.id"
class="breadcrumb-item"
@click="navigateTo(crumb)"
>
{{ crumb.name }}
<span v-if="index < breadcrumbs.length - 1" class="separator">></span>
</span>
</div>
</div>
<div class="nav-right">
<n-button type="primary" @click="addFile">
添加文件
</n-button>
</div>
</div>
<!-- 内容区域 -->
<div class="content">
<div class="folder-view">
<div class="folder-header">
<h3>{{ currentFolder?.name || '文件夹' }}</h3>
<div class="folder-info" v-if="currentFolder">
<span>创建时间{{ currentFolder.createTime }}</span>
<span>创建人{{ currentFolder.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="selectItem(item)"
@dblclick="openItem(item)"
:class="{ 'selected': selectedItem?.id === item.id }"
>
<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 class="file-actions">
<n-button @click.stop="viewItem(item)">查看</n-button>
<n-button @click.stop="downloadItem(item)" v-if="item.type !== 'folder'">下载</n-button>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="folderItems.length === 0" class="empty-state">
<div class="empty-icon">📁</div>
<p>该文件夹暂无内容</p>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>加载中...</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { ArrowBackOutline } from '@vicons/ionicons5'
const route = useRoute()
const router = useRouter()
const message = useMessage()
//
interface FileItem {
id: number
name: string
type: string
size: string
creator: string
createTime: string
isTop: boolean
children?: FileItem[]
parentId?: number
}
//
const currentFolder = ref<FileItem | null>(null)
//
const breadcrumbs = ref<FileItem[]>([])
//
const folderItems = ref<FileItem[]>([])
//
const selectedItem = ref<FileItem | null>(null)
//
const loading = ref(false)
//
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: '子文件夹',
type: 'folder',
size: '0B',
creator: '王建国',
createTime: '2025.07.25 10:30',
isTop: false,
parentId: 1,
children: [
{
id: 5,
name: '深层文件.pdf',
type: 'pdf',
size: '3MB',
creator: '王建国',
createTime: '2025.07.25 11:00',
isTop: false,
parentId: 4
}
]
}
]
}
]
//
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'
}
return iconMap[type] || '/images/activity/file.png'
}
//
const formatTime = (timeStr: string) => {
return timeStr.replace(/\./g, '-')
}
// 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
}
}
return null
}
//
const buildBreadcrumbs = (folder: FileItem) => {
const crumbs: FileItem[] = []
let current = folder
while (current) {
crumbs.unshift(current)
if (current.parentId) {
current = findFileById(mockFileData, current.parentId) as FileItem
} else {
break
}
}
breadcrumbs.value = crumbs
}
//
const loadFolder = (folderId: number) => {
loading.value = true
setTimeout(() => {
const folder = findFileById(mockFileData, folderId)
if (folder && folder.type === 'folder') {
currentFolder.value = folder
buildBreadcrumbs(folder)
if (folder.children) {
//
folderItems.value = [...folder.children].sort((a, b) => {
if (a.isTop && !b.isTop) return -1
if (!a.isTop && b.isTop) return 1
return 0
})
} else {
folderItems.value = []
}
} else {
message.error('文件夹不存在或不是有效的文件夹')
router.back()
}
loading.value = false
}, 500)
}
//
const selectItem = (item: FileItem) => {
selectedItem.value = item
}
//
const openItem = (item: FileItem) => {
if (item.type === 'folder') {
//
router.push({
name: 'FolderBrowser',
params: {
id: route.params.id,
folderId: item.id.toString()
}
})
} else {
//
router.push({
name: 'FileViewer',
params: {
id: route.params.id,
fileId: item.id.toString()
}
})
}
}
//
const viewItem = (item: FileItem) => {
if (item.type === 'folder') {
openItem(item)
} else {
//
router.push({
name: 'FileViewer',
params: {
id: route.params.id,
fileId: item.id.toString()
}
})
}
}
//
const downloadItem = (item: FileItem) => {
message.success(`开始下载:${item.name}`)
//
}
//
const navigateTo = (crumb: FileItem) => {
if (crumb.id !== currentFolder.value?.id) {
router.push({
name: 'FolderBrowser',
params: {
id: route.params.id,
folderId: crumb.id.toString()
}
})
}
}
//
const goBack = () => {
router.back()
}
//
const addFile = () => {
message.info('添加文件功能')
//
}
//
onMounted(() => {
const folderId = parseInt(route.params.folderId as string)
if (folderId) {
loadFolder(folderId)
}
})
//
router.afterEach(() => {
const folderId = parseInt(route.params.folderId as string)
if (folderId && folderId !== currentFolder.value?.id) {
loadFolder(folderId)
}
})
</script>
<style scoped>
.folder-browser {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
/* 顶部导航栏 */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #e8e8e8;
background: #fff;
z-index: 10;
}
.nav-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: #f5f5f5;
border: 1px solid #d9d9d9;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.3s;
}
.back-btn:hover {
background: #e6f7ff;
border-color: #0288d1;
color: #0288d1;
}
.breadcrumb {
display: flex;
align-items: center;
font-size: 14px;
color: #666;
}
.breadcrumb-item {
cursor: pointer;
transition: color 0.3s;
}
.breadcrumb-item:hover {
color: #0288d1;
}
.breadcrumb-item:last-child {
color: #333;
font-weight: 500;
}
.separator {
margin: 0 8px;
color: #ccc;
}
.nav-right .btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background: #0288d1;
color: white;
}
.btn-primary:hover {
background: #0277bd;
}
/* 内容区域 */
.content {
flex: 1;
overflow: auto;
padding: 24px;
}
.folder-view {
max-width: 1200px;
margin: 0 auto;
}
.folder-header {
margin-bottom: 24px;
}
.folder-header h3 {
margin: 0 0 8px 0;
font-size: 24px;
color: #333;
}
.folder-info {
display: flex;
gap: 24px;
font-size: 14px;
color: #666;
}
.folder-content {
min-height: 400px;
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.file-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
border: 1px solid #e8e8e8;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
background: #fff;
position: relative;
}
.file-item:hover {
border-color: #0288d1;
box-shadow: 0 2px 8px rgba(2, 136, 209, 0.1);
transform: translateY(-2px);
}
.file-item.selected {
border-color: #0288d1;
box-shadow: 0 0 0 2px rgba(2, 136, 209, 0.2);
}
.file-icon {
position: relative;
margin-bottom: 12px;
}
.file-icon img {
width: 48px;
height: 48px;
}
.top-badge {
position: absolute;
top: -8px;
right: -8px;
background: #ff4d4f;
color: white;
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
}
.file-name {
font-size: 14px;
color: #333;
text-align: center;
word-break: break-word;
margin-bottom: 8px;
font-weight: 500;
min-height: 20px;
}
.file-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
font-size: 12px;
color: #999;
margin-bottom: 12px;
}
.file-actions {
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.3s;
}
.file-item:hover .file-actions {
opacity: 1;
}
.action-btn {
padding: 4px 8px;
border: 1px solid #0288d1;
background: white;
color: #0288d1;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.3s;
}
.action-btn:hover {
background: #0288d1;
color: white;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
color: #999;
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.6;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
color: #666;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid #f3f3f3;
border-top: 3px solid #0288d1;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 响应式设计 */
@media (max-width: 768px) {
.content {
padding: 16px;
}
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.folder-info {
flex-direction: column;
gap: 8px;
}
.nav-left {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
</style>

View File

@ -3,24 +3,22 @@
<div class="toolbar">
<h2>作业库</h2>
<div class="toolbar-actions">
<button class="btn btn-primary" @click="handleAddHomework">添加作业</button>
<button class="btn btn-new" @click="handleImport">导入</button>
<button class="btn btn-new">导出</button>
<button class="btn btn-danger">删除</button>
<n-button type="primary" @click="handleAddHomework">添加作业</n-button>
<n-button @click="handleImport">导入</n-button>
<n-button>导出</n-button>
<n-button type="error">删除</n-button>
<div class="search-box">
<input type="text" placeholder="请输入想要搜索的内容" />
<button class="btn btn-search">搜索</button>
<n-input v-model:value="searchValue" placeholder="请输入想要搜索的内容" clearable />
<n-button type="primary" @click="handleSearch">搜索</n-button>
</div>
</div>
</div>
<div class="content-area">
<div class="table-container">
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
<n-data-table :columns="columns" :data="sortedHomeworkList" :row-key="rowKey"
:checked-row-keys="selectedHomework" @update:checked-row-keys="handleCheck" :bordered="false"
:single-line="false" size="medium" class="homework-data-table" scroll-x="true" />
</n-config-provider>
</div>
<div class="pagination-container">
@ -64,7 +62,7 @@
<script setup lang="ts">
import { ref, h, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { NButton, NDropdown, NDataTable, NConfigProvider, zhCN, dateZhCN } from 'naive-ui'
import { NButton, NDropdown, NDataTable, NInput } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
//
@ -85,6 +83,9 @@ const route = useRoute()
//
const selectedHomework = ref<number[]>([])
//
const searchValue = ref('')
//
const currentPage = ref(1)
const pageSize = ref(10)
@ -462,6 +463,11 @@ const handleImport = () => {
//
router.push(`/teacher/course-editor/${route.params.id}/homework/template-import`)
}
const handleSearch = () => {
console.log('搜索内容:', searchValue.value)
//
}
</script>
<style scoped>
@ -507,118 +513,14 @@ const handleImport = () => {
padding-bottom: 80px;
}
.btn {
padding: 7px 16px;
border: 1px solid #d9d9d9;
border-radius: 2px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
background: #fff;
color: #666;
}
.btn:hover {
border-color: #40a9ff;
color: #40a9ff;
}
.btn-primary {
background: #0288D1;
border-color: #0288D1;
color: #fff;
}
.btn-primary:hover {
background: #40a9ff;
border-color: #40a9ff;
}
.btn-new {
background: #fff;
border-color: #0288D1;
color: #0288D1;
}
.btn-new:hover {
background: #0288D1;
color: #fff;
}
.btn-default {
background: #fff;
border-color: #0288D1;
color: #666;
}
.btn-default:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.btn-danger {
border-color: #FF4D4F;
color: #FF4D4F;
}
.btn-danger:hover {
background: #FF4D4F;
color: #fff;
}
.btn-danger:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.btn-small {
padding: 4px 8px;
font-size: 12px;
}
.btn-info {
border-color: #0288D1;
color: #0288D1;
}
.btn-more {
border-color: #0288D1;
color: #0288D1;
position: relative;
}
.search-box {
display: flex;
align-items: center;
border: 1px solid #F1F3F4;
border-radius: 2px;
overflow: hidden;
gap: 8px;
}
.search-box input {
border: none;
padding: 6px 12px;
outline: none;
.search-box :deep(.n-input) {
width: 200px;
font-size: 14px;
color: #333;
}
.search-box input::placeholder {
color: #999;
}
.btn-search {
background: #0288D1;
color: white;
border: none;
padding: 6px 16px;
cursor: pointer;
font-size: 16px;
}
.btn-search:hover {
background: #0288D1;
}
/* Naive UI 表格样式定制 */
@ -888,10 +790,17 @@ const handleImport = () => {
gap: 8px;
}
.search-box input {
.search-box :deep(.n-input) {
width: 150px;
}
/* 响应式按钮样式调整 */
:deep(.toolbar-actions .n-button) {
font-size: 12px;
height: 28px;
padding: 0 12px;
}
/* 表格在小屏幕下的优化 */
:deep(.homework-data-table) {
padding: 20px;
@ -914,14 +823,14 @@ const handleImport = () => {
font-size: 16px;
}
.btn {
padding: 6px 12px;
font-size: 12px;
:deep(.toolbar-actions .n-button) {
font-size: 11px;
height: 28px;
padding: 0 10px;
}
.search-box input {
.search-box :deep(.n-input) {
width: 120px;
font-size: 12px;
}
/* 表格在移动端的优化 */
@ -971,7 +880,7 @@ const handleImport = () => {
width: 100%;
}
.search-box input {
.search-box :deep(.n-input) {
width: 100%;
}

View File

@ -1,4 +1,4 @@
<<template>
<template>
<div class="homework-review">
<!-- 顶部筛选区域 -->
<div class="top-section">
@ -8,13 +8,15 @@
@click="setActiveTab('publishing')">发布中</span>
<span class="tab-item" :class="{ active: activeTab === 'ended' }" @click="setActiveTab('ended')">已结束</span>
</div>
<div class="class-dropdown">
<span class="dropdown-text">班级名称</span>
<svg class="dropdown-arrow" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3.14645 5.64645C3.34171 5.45118 3.65829 5.45118 3.85355 5.64645L8 9.79289L12.1464 5.64645C12.3417 5.45118 12.6583 5.45118 12.8536 5.64645C13.0488 5.84171 13.0488 6.15829 12.8536 6.35355L8.35355 10.8536C8.15829 11.0488 7.84171 11.0488 7.64645 10.8536L3.14645 6.35355C2.95118 6.15829 2.95118 5.84171 3.14645 5.64645Z"
fill="#C2C2C2"></path>
</svg>
<div class="class-select">
<n-select
v-model:value="selectedClass"
:options="classOptions"
placeholder="请选择班级"
clearable
style="width: 276px;"
@update:value="handleClassChange"
/>
</div>
</div>
@ -25,7 +27,6 @@
<div v-if="homework.status === 'ended'" class="homework-card ended">
<a class="delete-link" @click="deleteHomework(homework.id)">删除</a>
<div class="card-header">
<h3 class="homework-title">{{ homework.title }}</h3>
<span class="status-badge ended">已结束</span>
@ -51,16 +52,16 @@
<span class="label secondary">{{ homework.submittedCount }}已交</span>
<span class="label secondary">{{ homework.unsubmittedCount }}未交</span>
</div>
<button class="action-button review" @click="reviewHomework(homework.id)">批阅</button>
<n-space>
<n-button type="primary" @click="reviewHomework(homework.id)">批阅</n-button>
<n-button type="error" @click="deleteHomework(homework.id)">删除</n-button>
</n-space>
</div>
</div>
</div>
<!-- 发布中作业 -->
<div v-if="homework.status === 'publishing'" class="homework-card publishing">
<a class="delete-link" @click="deleteHomework(homework.id)">删除</a>
<div class="card-header">
<h3 class="homework-title">{{ homework.title }}</h3>
<span class="status-badge publishing">发布中</span>
@ -86,7 +87,10 @@
<span class="label secondary">{{ homework.submittedCount }}已交</span>
<span class="label secondary">{{ homework.unsubmittedCount }}未交</span>
</div>
<button class="action-button view" @click="viewHomework(homework.id)">查看</button>
<n-space>
<n-button type="primary" @click="viewHomework(homework.id)">查看</n-button>
<n-button type="error" @click="deleteHomework(homework.id)">删除</n-button>
</n-space>
</div>
</div>
</div>
@ -97,6 +101,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { NButton, NFlex, NSelect } from 'naive-ui'
import { useRouter } from 'vue-router'
interface HomeworkItem {
@ -114,6 +119,35 @@ interface HomeworkItem {
const router = useRouter()
const activeTab = ref<'all' | 'publishing' | 'ended'>('all')
//
const selectedClass = ref<string | null>(null)
const classOptions = [
{
label: '全部班级',
value: 'all'
},
{
label: '软件工程一班',
value: 'class1'
},
{
label: '软件工程二班',
value: 'class2'
},
{
label: '计算机科学一班',
value: 'class3'
},
{
label: '计算机科学二班',
value: 'class4'
},
{
label: '信息安全一班',
value: 'class5'
}
]
//
const screenWidth = ref(window.innerWidth)
@ -161,6 +195,11 @@ const setActiveTab = (tab: 'all' | 'publishing' | 'ended') => {
activeTab.value = tab
}
const handleClassChange = (value: string | null) => {
console.log('选中的班级:', value)
//
}
const filteredHomeworks = computed(() => {
if (activeTab.value === 'all') {
return homeworks.value
@ -227,27 +266,35 @@ const deleteHomework = (id: number) => {
color: #2196F3;
}
.class-dropdown {
.class-select {
display: flex;
align-items: center;
padding: 8px 16px;
margin-bottom: 20px;
}
/* Naive UI Select 组件样式定制 */
:deep(.class-select .n-base-selection) {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 2px;
margin-bottom: 20px;
cursor: pointer;
width: 276px;
justify-content: space-between;
font-size: 14px;
}
.dropdown-text {
font-size: 14px;
:deep(.class-select .n-base-selection:hover) {
border-color: #0288D1;
}
:deep(.class-select .n-base-selection.n-base-selection--focus) {
border-color: #0288D1;
box-shadow: 0 0 0 2px rgba(2, 136, 209, 0.1);
}
:deep(.class-select .n-base-selection-input) {
color: #062333;
}
.dropdown-arrow {
width: 20px;
height: 20px;
:deep(.class-select .n-base-selection-placeholder) {
color: #999;
}
/* 作业列表 */
@ -428,6 +475,7 @@ const deleteHomework = (id: number) => {
.homework-card.publishing .stats-area {
margin-top: 20px;
}
/* 响应式样式 */
@media (max-width: 1200px) {
.top-section {
@ -445,6 +493,14 @@ const deleteHomework = (id: number) => {
font-size: 16px;
}
.class-select {
width: 100%;
}
:deep(.class-select .n-base-selection) {
width: 100%;
}
/* 作业卡片在小屏幕下的优化 */
.homework-card {
margin: 15px 20px;
@ -474,8 +530,13 @@ const deleteHomework = (id: number) => {
padding-bottom: 15px;
}
.dropdown-text {
font-size: 14px;
.class-select {
width: 100%;
}
:deep(.class-select .n-base-selection) {
width: 100%;
font-size: 13px;
}
/* 作业卡片在移动端的优化 */
@ -592,4 +653,4 @@ const deleteHomework = (id: number) => {
word-break: break-word;
overflow-wrap: break-word;
}
</style>>
</style>

View File

@ -3,6 +3,16 @@
<!-- 顶部区域 -->
<div class="top-section">
<!-- 左侧标签页 - 使用 Naive UI Tabs -->
<div class="back-button">
<n-button quaternary circle size="large" @click="goBack">
<template #icon>
<n-icon>
<ArrowBackOutline />
</n-icon>
</template>
</n-button>
</div>
<div class="tabs-container">
<n-tabs v-model:value="activeTab" type="line" animated>
<n-tab-pane name="all" tab="全部" />
@ -17,12 +27,7 @@
<n-button class="action-btn remove-btn" ghost type="error" size="small">移除</n-button>
<div class="search-container">
<n-input-group>
<n-input
v-model:value="searchText"
placeholder="请输入学生姓名"
:style="{ width: '200px' }"
size="medium"
/>
<n-input v-model:value="searchText" placeholder="请输入学生姓名" :style="{ width: '200px' }" size="medium" />
<n-button type="primary" size="medium">
搜索
</n-button>
@ -33,15 +38,8 @@
<!-- 数据表格 - 使用 Naive UI Table -->
<div class="table-container">
<n-data-table
:columns="activeTab === 'unsubmitted' ? unsubmittedColumns : columns"
:data="filteredData"
:pagination="false"
:bordered="false"
:single-line="false"
size="medium"
:row-key="(row) => row.id"
/>
<n-data-table :columns="activeTab === 'unsubmitted' ? unsubmittedColumns : columns" :data="filteredData"
:pagination="false" :bordered="false" :single-line="false" size="medium" :row-key="(row) => row.id" />
</div>
</div>
</template>
@ -49,6 +47,7 @@
<script setup lang="ts">
import { ref, computed, h } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ArrowBackOutline } from '@vicons/ionicons5'
import {
NTabs,
NTabPane,
@ -363,7 +362,9 @@ const unsubmittedColumns: DataTableColumns<HomeworkSubmission> = [
}
]
const goBack = () => {
router.back()
}
</script>
<style scoped>
@ -381,6 +382,11 @@ const unsubmittedColumns: DataTableColumns<HomeworkSubmission> = [
align-items: center;
}
.back-button {
border-bottom: 1.5px solid #E6E6E6;
padding: 11px;
}
/* 左侧标签页 */
.tabs-container {
flex: 1;

View File

@ -3,8 +3,13 @@
<!-- 消息提示组件 -->
<MessageComponent ref="messageRef" />
<n-button quaternary circle size="large" @click="goBack" class="back-button">
<template #icon>
<n-icon>
<ArrowBackOutline />
</n-icon>
</template>
</n-button>
<!-- 操作按钮区域 -->
<div class="action-buttons">
<button class="btn btn-primary" @click="startImport">
@ -35,7 +40,6 @@
</div>
<!-- 隐藏的文件上传输入 -->
<input
ref="fileInput"
@ -49,7 +53,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ArrowBackOutline} from '@vicons/ionicons5'
import MessageComponent from '@/components/MessageComponent.vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const fileInput = ref<HTMLInputElement>()
const messageRef = ref<InstanceType<typeof MessageComponent>>()
@ -128,7 +136,9 @@ const startImport = async () => {
}
}
const goBack = () => {
router.back()
}
</script>
<style scoped>

View File

@ -31,7 +31,7 @@
<img :src="file.thumbnail" :alt="file.name" />
</div>
<div class="file-name">{{ file.name }}</div>
<div class="file-options">
<!-- <div class="file-options">
<n-dropdown :options="fileMenuOptions" @select="(key: string) => handleFileMenuSelect(key, file)">
<n-button quaternary size="small" class="file-menu-btn">
<template #icon>
@ -43,7 +43,7 @@
</template>
</n-button>
</n-dropdown>
</div>
</div> -->
</div>
</div>
</div>
@ -135,10 +135,10 @@ const filteredFiles = computed(() => {
const selectedFile = ref<any>(null)
//
const fileMenuOptions = [
{ label: '重命名', key: 'rename' },
{ label: '删除', key: 'delete' }
]
// const fileMenuOptions = [
// { label: '', key: 'rename' },
// { label: '', key: 'delete' }
// ]
//
const searchFiles = () => {
@ -173,17 +173,17 @@ const selectFile = (file: any, event?: Event) => {
}
//
const handleFileMenuSelect = (key: string, file: any) => {
if (key === 'delete') {
console.log('删除文件:', file)
files.value = files.value.filter(f => f.id !== file.id)
if (selectedFile.value && selectedFile.value.id === file.id) {
selectedFile.value = null
}
} else if (key === 'rename') {
console.log('重命名文件:', file)
}
}
// const handleFileMenuSelect = (key: string, file: any) => {
// if (key === 'delete') {
// console.log(':', file)
// files.value = files.value.filter(f => f.id !== file.id)
// if (selectedFile.value && selectedFile.value.id === file.id) {
// selectedFile.value = null
// }
// } else if (key === 'rename') {
// console.log(':', file)
// }
// }
//
const handleCancel = () => {

View File

@ -1,43 +1,47 @@
<template>
<div class="modal-container flex-col" @click.stop>
<span class="modal-title">上传文件</span>
<h2 class="modal-title">上传文件</h2>
<n-divider />
<div class="upload-area flex-row">
<span class="upload-label">上传文件</span>
<div class="select-file-btn flex-col" @click="toggleDropdown" style="position: relative;">
<n-popselect
:options="uploadOptions"
trigger="click"
placement="bottom-start"
@update:value="handleUploadOptionSelect"
:render-label="renderUploadOption"
>
<div class="select-file-btn flex-col">
<span class="btn-text">选择文件</span>
<!-- 下拉选项 -->
<div v-show="showDropdown" class="upload-methods flex-col">
<label class="local-upload" @click="openLocalUpload">
<!-- <input type="file" @change="handleLocalUpload" style="display: none;"
accept=".doc,.docx,.pdf,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4" /> -->
本地上传
</label>
<label class="resource-upload" @click="openResourceModal">
<!-- <input type="file" @change="handleResourceUpload" style="display: none;"
accept=".doc,.docx,.pdf,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4" /> -->
资源上传
</label>
</div>
</div>
</n-popselect>
</div>
<div class="supported-formats flex-row">
<span class="formats-label">支持格式</span>
<span class="document-formats">文本文.doc.docx.pdf表格文件.xls.xlsx<br />演示文稿.ppt.pptx</span>
<span class="media-formats">音频文件.mp3<br />视频文件.mp4<br /></span>
<div class="formats-content flex-col">
<div class="format-row flex-row">
<span class="document-formats">文本文.doc.docx.pdf</span>
<span class="media-formats">音频文件.mp3</span>
</div>
<div class="upload-limits flex-row justify-between">
<div class="format-row flex-row">
<span class="document-formats">表格文件.xls.xlsx</span>
<span class="media-formats">视频文件.mp4</span>
</div>
<div class="format-row">
<span class="document-formats">演示文稿.ppt.pptx</span>
</div>
</div>
</div>
<div class="upload-limits flex-row">
<span class="limits-label">上传限制</span>
<span class="limits-description">wordexcelppt文件需小于100MB其他文件需小于2GB.mp4格式编码<br />说明上传mp4格式文件请查看编码说明</span>
<div class="limits-content flex-col">
<span class="limits-description">wordexcelppt文件需小于100MB其他文件需小于2GB.mp4格式编码</span>
<span class="limits-description">说明上传mp4格式文件请查看编码说明</span>
</div>
</div>
<div class="action-buttons flex-row justify-between">
<div class="cancel-btn-container flex-col">
<div class="cancel-btn flex-col" @click="handleCancel">
<span class="cancel-btn-text">取消</span>
</div>
</div>
<div class="confirm-btn flex-col" @click="handleConfirm">
<span class="confirm-btn-text">确定</span>
</div>
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确定</n-button>
</div>
<!-- 资源选择模态框 -->
@ -49,21 +53,43 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, h } from 'vue'
import ResourceSelectionModal from './ResourceSelectionModal.vue'
import LocalUploadModal from './LocalUploadModal.vue'
//
const showDropdown = ref(false)
//
const uploadOptions = ref([
{ label: '本地上传', value: 'local' },
{ label: '资源上传', value: 'resource' }
])
//
const renderUploadOption = (option: any) => {
return h('span', {
style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '5px 0'
}
}, option.label)
}
//
const handleUploadOptionSelect = (value: string) => {
console.log('选中的上传方式:', value)
if (value === 'local') {
//
openLocalUpload()
} else if (value === 'resource') {
//
openResourceModal()
}
}
//
const showLocalUploadModal = ref(false)
//
const toggleDropdown = () => {
showDropdown.value = !showDropdown.value
}
//
// const handleLocalUpload = (event: Event) => {
// const target = event.target as HTMLInputElement
@ -89,7 +115,6 @@ const toggleDropdown = () => {
//
const openLocalUpload = () => {
showLocalUploadModal.value = true
showDropdown.value = false
}
//
@ -108,7 +133,6 @@ const handleUploadMore = () => {
const openResourceModal = () => {
console.log('打开资源选择模态框')
showResourceModal.value = true //
showDropdown.value = false //
}
//
@ -140,12 +164,11 @@ const handleConfirm = () => {
<style scoped>
.modal-container {
width: 1076px;
height: 623px;
/* height: 623px; */
background: #FFFFFF;
background-size: 100% 100%;
margin: 0 auto;
border-radius: 8px;
padding-top: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
@ -169,7 +192,7 @@ const handleConfirm = () => {
background: #FCFCFC;
background-size: 100% 100%;
margin: 46px 0 0 22px;
border: 1px solid rgba(204, 204, 204, 1);
border: 1px solid rgb(233, 233, 233);
display: flex;
}
@ -212,72 +235,24 @@ const handleConfirm = () => {
justify-content: center;
}
.upload-methods {
position: absolute;
top: 100%;
left: 0;
width: 123px;
height: 76px;
background: #FFFFFF;
border: 1px solid #e0e0e0;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1000;
margin: 0;
}
.local-upload {
width: 100%;
height: 30px;
overflow-wrap: break-word;
color: rgba(51, 51, 51, 1);
font-size: 14px;
font-family: Helvetica, 'Microsoft YaHei', Arial, sans-serif;
font-weight: normal;
text-align: center;
white-space: nowrap;
line-height: 30px;
margin: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 5px 0;
transition: background-color 0.3s ease;
}
.local-upload:hover {
background-color: #f5f5f5;
}
.resource-upload {
width: 100%;
height: 30px;
overflow-wrap: break-word;
color: rgba(51, 51, 51, 1);
font-size: 14px;
font-family: Helvetica, 'Microsoft YaHei', Arial, sans-serif;
font-weight: normal;
text-align: center;
white-space: nowrap;
line-height: 30px;
margin: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 5px 0;
transition: background-color 0.3s ease;
}
.resource-upload:hover {
background-color: #f5f5f5;
}
.supported-formats {
width: 532px;
height: 93px;
margin: 27px 0 0 57px;
display: flex;
align-items: flex-start;
}
.formats-content {
flex: 1;
gap: 5px;
}
.format-row {
gap: 80px;
align-items: center;
}
.formats-label {
@ -292,38 +267,42 @@ const handleConfirm = () => {
white-space: nowrap;
line-height: 22px;
margin-top: 2px;
flex-shrink: 0;
}
.document-formats {
width: 230px;
height: 90px;
min-width: 200px;
overflow-wrap: break-word;
color: rgba(102, 102, 102, 1);
font-size: 18px;
font-family: Helvetica, 'Microsoft YaHei', Arial, sans-serif;
font-weight: normal;
text-align: left;
line-height: 30px;
margin-left: 18px;
line-height: 24px;
}
.media-formats {
width: 131px;
height: 90px;
min-width: 120px;
overflow-wrap: break-word;
color: rgba(102, 102, 102, 1);
font-size: 18px;
font-family: Helvetica, 'Microsoft YaHei', Arial, sans-serif;
font-weight: normal;
text-align: left;
line-height: 30px;
margin: 3px 0 0 63px;
line-height: 24px;
}
.upload-limits {
width: 698px;
height: 60px;
margin: 26px 0 0 57px;
display: flex;
align-items: flex-start;
}
.limits-content {
flex: 1;
gap: 5px;
}
.limits-label {
@ -338,25 +317,25 @@ const handleConfirm = () => {
white-space: nowrap;
line-height: 22px;
margin-top: 3px;
flex-shrink: 0;
}
.limits-description {
width: 590px;
height: 60px;
max-width: 590px;
overflow-wrap: break-word;
color: rgba(102, 102, 102, 1);
font-size: 18px;
font-family: Helvetica, 'Microsoft YaHei', Arial, sans-serif;
font-weight: normal;
text-align: left;
line-height: 30px;
line-height: 24px;
}
.action-buttons {
width: 147px;
height: 32px;
margin: 103px 0 0 905px;
/* margin: 103px 0 0 905px; */
display: flex;
justify-content: flex-end;
margin: 20px;
gap: 20px;
}
@ -429,15 +408,4 @@ const handleConfirm = () => {
.select-file-btn:hover {
background: #0277BD;
}
/* 确保下拉菜单在按钮上方显示 */
.select-file-btn .upload-methods {
margin-top: 2px;
}
/* 隐藏文件输入框 */
.local-upload input[type="file"],
.resource-upload input[type="file"] {
display: none;
}
</style>