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>
<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>
<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">
<n-icon size="20">
<EllipsisVerticalSharp />
</n-icon>
</span>
</div>
</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)
message.success('课程创建成功!')
// 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">
<div class="select-path-button flex-col">
<input
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
type="file"
@change="handleDropdownLocalUpload"
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>
<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>
</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"
@change="handleLocalFileUpload"
style="display: none;"
accept=".doc,.docx,.pdf,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4"
/>
<input
ref="resourceFileInput"
type="file"
@change="handleResourceFileUpload"
style="display: none;"
accept=".doc,.docx,.pdf,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4"
/>
</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>
</div>
<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">
@ -51,7 +56,7 @@
<span class="page-number nav-button" :class="{ disabled: currentPage === totalPages }"
@click="goToPage('last')">
尾页
</span>
</span>
</div>
</div>
</div>
@ -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'
@ -503,10 +508,9 @@ const columns: DataTableColumns<Chapter> = [
</script>
<style scoped>
.chapter-management {
width: 100%;
background: #fff;
height: 100%;
.chapter-management {
width: 100%;
background: #fff;
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">
<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 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%" />
</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%;
}

File diff suppressed because it is too large Load Diff

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="全部" />
@ -13,35 +23,23 @@
<!-- 右侧按钮和搜索 -->
<div class="actions-container">
<n-button class="action-btn export-btn" ghost type="primary" size="small">导出</n-button>
<n-button class="action-btn remove-btn" ghost type="error" size="small">移除</n-button>
<div class="search-container">
<n-input-group>
<n-input
v-model:value="searchText"
placeholder="请输入学生姓名"
:style="{ width: '200px' }"
size="medium"
/>
<n-button type="primary" size="medium">
搜索
</n-button>
</n-input-group>
</div>
<n-button class="action-btn export-btn" ghost type="primary" size="small">导出</n-button>
<n-button class="action-btn remove-btn" ghost type="error" size="small">移除</n-button>
<div class="search-container">
<n-input-group>
<n-input v-model:value="searchText" placeholder="请输入学生姓名" :style="{ width: '200px' }" size="medium" />
<n-button type="primary" size="medium">
搜索
</n-button>
</n-input-group>
</div>
</div>
</div>
<!-- 数据表格 - 使用 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,14 +47,15 @@
<script setup lang="ts">
import { ref, computed, h } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
NTabs,
NTabPane,
NDataTable,
NButton,
NInput,
import { ArrowBackOutline } from '@vicons/ionicons5'
import {
NTabs,
NTabPane,
NDataTable,
NButton,
NInput,
NInputGroup,
type DataTableColumns
type DataTableColumns
} from 'naive-ui'
interface HomeworkSubmission {
@ -178,7 +177,7 @@ const filteredData = computed(() => {
//
if (searchText.value.trim()) {
filtered = filtered.filter((item: HomeworkSubmission) =>
filtered = filtered.filter((item: HomeworkSubmission) =>
item.name.includes(searchText.value.trim())
)
}
@ -361,9 +360,11 @@ 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;
@ -531,7 +537,7 @@ const unsubmittedColumns: DataTableColumns<HomeworkSubmission> = [
gap: 20px;
align-items: flex-start;
}
.actions-container {
width: 100%;
justify-content: flex-end;
@ -539,26 +545,26 @@ const unsubmittedColumns: DataTableColumns<HomeworkSubmission> = [
}
@media (max-width: 768px) {
.actions-container {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.search-container {
width: 100%;
}
.search-input {
width: 100%;
}
.operations-container {
flex-direction: column;
gap: 4px;
}
.operation-btn {
width: 100%;
text-align: center;

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,44 +1,48 @@
<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;">
<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>
<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>
</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>
<div class="upload-limits flex-row justify-between">
<span class="limits-label">上传限制</span>
<span class="limits-description">wordexcelppt文件需小于100MB其他文件需小于2GB.mp4格式编码<br />说明上传mp4格式文件请查看编码说明</span>
</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 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="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 class="confirm-btn flex-col" @click="handleConfirm">
<span class="confirm-btn-text">确定</span>
</div>
<div class="upload-limits flex-row">
<span class="limits-label">上传限制</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">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确定</n-button>
</div>
<!-- 资源选择模态框 -->
<ResourceSelectionModal v-model:show="showResourceModal" @select="handleResourceSelection" />
@ -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>