feat: 新增导入组件,支持数据导入及模板下载;修改考试管理对应的路由层级及页面,加入过渡动画效果

This commit is contained in:
yuk255 2025-08-23 18:27:07 +08:00
parent 8bb211fc33
commit 5e34c71b14
12 changed files with 2043 additions and 182 deletions

View File

@ -3,6 +3,59 @@ import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useUserStore } from '@/stores/user'
import AppLayout from '@/components/layout/AppLayout.vue'
import { NConfigProvider } from 'naive-ui'
import type { GlobalThemeOverrides } from 'naive-ui';
// naive-ui
const themeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#0288D1',
primaryColorHover: '#0277BD', // A slightly darker shade for hover
primaryColorPressed: '#01579B', // A darker shade for pressed
errorColor: '#FF4D4F',
errorColorHover: '#E54547',
errorColorPressed: '#C0383A',
},
Button: {
// For ghost primary buttons
textColorGhostPrimary: '#0288D1',
borderPrimary: '1px solid #0288D1',
// For ghost error buttons
textColorGhostError: '#FF4D4F',
borderError: '1px solid #FF4D4F',
},
Tag: {
colorPrimary: '#0288D1',
colorPrimaryHover: '#0277BD',
colorPrimaryPressed: '#01579B',
textColorPrimary: '#ffffff',
},
Card: {
borderColor: '#E6E6E6',
},
Input: {
borderHover: '#0288D1',
borderFocus: '#0288D1',
boxShadowFocus: '0 0 0 1px rgba(2, 136, 209, 0.5)',
},
InputNumber: {
borderHover: '#0288D1',
borderFocus: '#0288D1',
boxShadowFocus: '0 0 0 1px rgba(2, 136, 209, 0.5)',
},
Select: {
borderHover: '#0288D1',
borderFocus: '#0288D1',
boxShadowFocus: '0 0 0 1px rgba(2, 136, 209, 0.5)',
},
Modal: {
borderRadius: '8px',
},
Dialog: {
borderRadius: '8px',
}
};
const userStore = useUserStore()
@ -14,9 +67,11 @@ onMounted(() => {
<template>
<div id="app">
<AppLayout>
<RouterView />
</AppLayout>
<n-config-provider :theme-overrides="themeOverrides">
<AppLayout>
<RouterView />
</AppLayout>
</n-config-provider>
</div>
</template>

View File

@ -25,6 +25,12 @@
</div>
</div>
<!-- 考试人数 -->
<div class="setting-row">
<label class="setting-label">考试人数</label>
<n-input v-model:value="formData.examCount" placeholder="请输入考试人数" class="setting-input" />
</div>
<!-- 试卷分类 -->
<div class="setting-row">
<label class="setting-label">试卷分类</label>
@ -292,6 +298,7 @@ interface ExamSettings {
participants: 'all' | 'by_school';
selectedClasses: string[];
instructions: string;
examCount: number;
//
enforceOrder: boolean;
@ -365,6 +372,7 @@ const formData = ref<ExamSettings>({
participants: 'all',
selectedClasses: [],
instructions: '',
examCount: 0,
//
enforceOrder: false,

View File

@ -0,0 +1,475 @@
<template>
<n-modal v-model:show="showModal" :mask-closable="false" preset="dialog" title="数据导入" style="width: 600px;">
<div class="import-modal-content">
<!-- 模板下载区域 -->
<div class="template-section">
<div class="section-title">
<n-icon size="18" color="#0288d1">
<CodeDownloadOutline />
</n-icon>
<span>模板下载</span>
</div>
<div class="template-description">
请先下载导入模板按照模板格式填写数据后再上传
</div>
<n-button type="primary" ghost @click="downloadTemplate">
<template #icon>
<n-icon>
<DownloadOutline />
</n-icon>
</template>
下载 Excel 模板
</n-button>
</div>
<n-divider />
<!-- 文件上传区域 -->
<div class="upload-section">
<div class="section-title">
<n-icon size="18" color="#0288d1">
<CloudUploadOutline />
</n-icon>
<span>文件上传</span>
</div>
<n-upload
ref="uploadRef"
:file-list="fileList"
:max="1"
accept=".xlsx,.xls"
:show-file-list="false"
:custom-request="handleUpload"
@change="handleFileChange"
>
<n-upload-dragger>
<div class="upload-area">
<n-icon size="48" color="#0288d1" class="upload-icon">
<CloudUploadOutline />
</n-icon>
<div class="upload-text">
<div class="upload-title">点击或拖拽文件到此区域上传</div>
<div class="upload-hint">
支持扩展名.xlsx.xls文件大小不超过 10MB
</div>
</div>
</div>
</n-upload-dragger>
</n-upload>
<!-- 文件信息显示 -->
<div v-if="selectedFile" class="file-info">
<div class="file-item">
<n-icon size="20" color="#52c41a">
<DocumentTextOutline />
</n-icon>
<span class="file-name">{{ selectedFile.name }}</span>
<span class="file-size">({{ formatFileSize(selectedFile.file?.size || 0) }})</span>
<n-button text type="error" @click="removeFile">
<n-icon>
<CloseOutline />
</n-icon>
</n-button>
</div>
<!-- 上传进度 -->
<div v-if="uploading" class="upload-progress">
<n-progress :percentage="uploadProgress" :show-indicator="false" />
<span class="progress-text">上传中... {{ uploadProgress }}%</span>
</div>
</div>
<!-- 导入结果 -->
<div v-if="importResult" class="import-result">
<n-alert :type="importResult.success ? 'success' : 'error'" :show-icon="true">
<template #header>
{{ importResult.success ? '导入成功' : '导入失败' }}
</template>
{{ importResult.message }}
<div v-if="importResult.details" class="result-details">
<div>成功导入{{ importResult.details.success }} </div>
<div v-if="importResult.details.failed > 0">
失败{{ importResult.details.failed }}
</div>
</div>
</n-alert>
</div>
</div>
<!-- 导入说明 -->
<div class="import-notes">
<div class="section-title">
<n-icon size="18" color="#fa8c16">
<InformationCircleOutline />
</n-icon>
<span>导入说明</span>
</div>
<ul class="notes-list">
<li>请严格按照模板格式填写数据不要修改表头</li>
<li>必填字段不能为空否则导入失败</li>
<li>导入前请检查数据格式确保数据的准确性</li>
</ul>
</div>
</div>
<template #action>
<n-space>
<n-button @click="closeModal">取消</n-button>
<n-button
type="primary"
@click="startImport"
:disabled="!selectedFile || uploading"
:loading="importing"
>
{{ importing ? '导入中...' : '开始导入' }}
</n-button>
</n-space>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import {
CodeDownloadOutline,
DownloadOutline,
CloudUploadOutline,
DocumentTextOutline,
CloseOutline,
InformationCircleOutline
} from '@vicons/ionicons5';
import { useMessage } from 'naive-ui';
import type { UploadFileInfo, UploadCustomRequestOptions } from 'naive-ui';
// Props
interface Props {
show: boolean;
templateName?: string;
importType?: string;
}
// Emits
interface Emits {
(e: 'update:show', value: boolean): void;
(e: 'success', result: any): void;
(e: 'template-download', type?: string): void;
}
const props = withDefaults(defineProps<Props>(), {
templateName: 'import_template.xlsx',
importType: 'default'
});
const emit = defineEmits<Emits>();
//
const message = useMessage();
//
const uploadRef = ref();
const fileList = ref<UploadFileInfo[]>([]);
const selectedFile = ref<UploadFileInfo | null>(null);
const uploading = ref(false);
const importing = ref(false);
const uploadProgress = ref(0);
//
interface ImportResult {
success: boolean;
message: string;
details?: {
success: number;
failed: number;
};
}
const importResult = ref<ImportResult | null>(null);
//
const showModal = computed({
get: () => props.show,
set: (value: boolean) => emit('update:show', value)
});
//
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
//
const downloadTemplate = () => {
emit('template-download', props.importType);
console.log('下载模板:', props.templateName);
// TODO:
};
//
const handleFileChange = (options: { fileList: UploadFileInfo[] }) => {
if (options.fileList.length > 0) {
selectedFile.value = options.fileList[0];
importResult.value = null; //
}
};
//
const handleUpload = (options: UploadCustomRequestOptions) => {
const { file } = options;
// (10MB)
if (file.file && file.file.size > 10 * 1024 * 1024) {
message.error('文件大小不能超过 10MB');
return;
}
//
if (file.file && !file.file.name.match(/\.(xlsx|xls)$/i)) {
message.error('只支持 Excel 文件格式');
return;
}
uploading.value = true;
uploadProgress.value = 0;
//
const progressInterval = setInterval(() => {
if (uploadProgress.value < 90) {
uploadProgress.value += Math.random() * 20;
}
}, 200);
//
setTimeout(() => {
clearInterval(progressInterval);
uploadProgress.value = 100;
uploading.value = false;
options.onFinish();
console.log('文件上传完成:', file.name);
}, 2000);
};
//
const removeFile = () => {
selectedFile.value = null;
fileList.value = [];
importResult.value = null;
uploadProgress.value = 0;
};
//
const startImport = async () => {
if (!selectedFile.value) {
message.warning('请先选择要导入的文件');
return;
}
importing.value = true;
importResult.value = null;
try {
//
await new Promise(resolve => setTimeout(resolve, 3000));
//
const mockResult: ImportResult = {
success: true,
message: '数据导入完成',
details: {
success: 45,
failed: 2
}
};
importResult.value = mockResult;
if (mockResult.success) {
message.success('导入成功!');
emit('success', mockResult);
//
setTimeout(() => {
closeModal();
}, 2000);
}
console.log('导入完成:', mockResult);
// TODO: API
} catch (error) {
importResult.value = {
success: false,
message: '导入失败:' + (error as Error).message
};
message.error('导入失败');
} finally {
importing.value = false;
}
};
//
const closeModal = () => {
//
selectedFile.value = null;
fileList.value = [];
importResult.value = null;
uploading.value = false;
importing.value = false;
uploadProgress.value = 0;
showModal.value = false;
};
</script>
<style scoped>
.import-modal-content {
padding: 8px 0;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 16px;
color: #333;
margin-bottom: 12px;
}
.template-section {
margin-bottom: 16px;
}
.template-description {
color: #666;
font-size: 14px;
margin-bottom: 16px;
line-height: 1.5;
}
.upload-section {
margin-bottom: 16px;
}
.upload-area {
text-align: center;
padding: 32px 16px;
}
.upload-icon {
margin-bottom: 16px;
}
.upload-title {
font-size: 16px;
color: #333;
margin-bottom: 8px;
}
.upload-hint {
font-size: 14px;
color: #999;
}
.file-info {
margin-top: 16px;
padding: 12px;
background-color: #f6f8fa;
border-radius: 6px;
}
.file-item {
display: flex;
align-items: center;
gap: 8px;
}
.file-name {
flex: 1;
color: #333;
font-weight: 500;
}
.file-size {
color: #666;
font-size: 12px;
}
.upload-progress {
margin-top: 12px;
}
.progress-text {
font-size: 12px;
color: #666;
margin-top: 4px;
display: block;
}
.import-result {
margin-top: 16px;
}
.result-details {
margin-top: 8px;
font-size: 14px;
}
.import-notes {
margin-top: 8px;
}
.notes-list {
margin: 8px 0 0 0;
padding-left: 20px;
color: #666;
font-size: 14px;
line-height: 1.6;
}
.notes-list li {
margin-bottom: 4px;
}
/* 上传拖拽区域样式优化 */
:deep(.n-upload-dragger) {
border: 2px dashed #d9d9d9;
border-radius: 6px;
transition: border-color 0.3s ease;
}
:deep(.n-upload-dragger:hover) {
border-color: #0288d1;
}
:deep(.n-upload-dragger.n-upload-dragger--disabled) {
cursor: not-allowed;
}
/* 进度条样式 */
:deep(.n-progress .n-progress-graph .n-progress-graph-line-fill) {
background-color: #0288d1;
}
/* 响应式设计 */
@media (max-width: 768px) {
.upload-area {
padding: 24px 12px;
}
.upload-icon {
margin-bottom: 12px;
}
.section-title {
font-size: 14px;
}
.template-description {
font-size: 13px;
}
}
</style>

View File

@ -0,0 +1,136 @@
# ImportModal 通用导入组件
一个可复用的数据导入弹窗组件,支持模板下载、文件上传、拖拽上传等功能。
## 功能特性
- 📄 **模板下载**:支持下载 Excel 导入模板
- 📤 **文件上传**:支持点击上传和拖拽上传
- 📊 **进度显示**:实时显示上传进度
- ✅ **结果反馈**:详细的导入结果提示
- 🔒 **文件校验**:文件格式和大小限制
- 📱 **响应式设计**:适配不同屏幕尺寸
## 使用方法
### 1. 导入组件
```vue
<script setup lang="ts">
import ImportModal from '@/components/common/ImportModal.vue';
</script>
```
### 2. 在模板中使用
```vue
<template>
<!-- 触发按钮 -->
<n-button @click="showImportModal = true">导入数据</n-button>
<!-- 导入弹窗 -->
<ImportModal
v-model:show="showImportModal"
template-name="custom_template.xlsx"
import-type="custom"
@success="handleImportSuccess"
@template-download="handleTemplateDownload"
/>
</template>
```
### 3. 处理事件
```vue
<script setup lang="ts">
const showImportModal = ref(false);
// 导入成功处理
const handleImportSuccess = (result: any) => {
console.log('导入结果:', result);
// 刷新数据列表
loadData();
};
// 模板下载处理
const handleTemplateDownload = (type?: string) => {
// 调用下载API
downloadTemplate(type);
};
</script>
```
## Props
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| show | boolean | - | 控制弹窗显示/隐藏 |
| templateName | string | 'import_template.xlsx' | 模板文件名 |
| importType | string | 'default' | 导入类型标识 |
## Events
| 事件名 | 参数 | 说明 |
|--------|------|------|
| update:show | boolean | 弹窗显示状态变化 |
| success | result: ImportResult | 导入成功回调 |
| template-download | type?: string | 模板下载回调 |
## ImportResult 类型
```typescript
interface ImportResult {
success: boolean;
message: string;
details?: {
success: number;
failed: number;
};
}
```
## 自定义配置
### 文件限制
- 支持格式:`.xlsx`, `.xls`
- 文件大小:最大 10MB
- 同时上传:仅支持单文件
### 样式自定义
组件使用 scoped 样式,可通过 CSS 变量或深度选择器自定义样式。
## API 集成点
组件中预留了以下 API 集成点,需要根据实际项目进行实现:
1. **模板下载 API**
```javascript
// 在 downloadTemplate 函数中实现
const downloadTemplate = () => {
// TODO: 调用模板下载 API
};
```
2. **文件上传 API**
```javascript
// 在 handleUpload 函数中实现
const handleUpload = (options) => {
// TODO: 调用文件上传 API
};
```
3. **数据导入 API**
```javascript
// 在 startImport 函数中实现
const startImport = async () => {
// TODO: 调用数据导入 API
};
```
## 注意事项
1. 确保后端支持对应的文件格式
2. 模板格式要与后端解析逻辑一致
3. 合理设置文件大小限制
4. 提供清晰的错误提示信息
5. 考虑网络异常情况的处理

View File

@ -54,7 +54,10 @@ import GeneralManagement from '@/views/teacher/course/GeneralManagement.vue'
// 作业子组件
import HomeworkLibrary from '@/views/teacher/course/HomeworkLibrary.vue'
import HomeworkReview from '@/views/teacher/course/HomeworkReview.vue'
// 练考通菜单组件
// 考试管理组件
import ExamManagement from '@/views/teacher/course/ExamPages/ExamPage.vue'
import QuestionManagement from '@/views/teacher/course/ExamPages/QuestionManagement.vue'
import ExamLibrary from '@/views/teacher/course/ExamPages/ExamLibrary.vue'
import MarkingCenter from '@/views/teacher/course/ExamPages/MarkingCenter.vue'
import AddExam from '@/views/teacher/course/ExamPages/AddExam.vue'
@ -156,7 +159,7 @@ const routes: RouteRecordRaw[] = [
path: 'practice',
name: 'PracticeManagement',
component: PracticeManagement,
meta: { title: '练考通管理' },
meta: { title: '考试管理' },
redirect: (to) => `/teacher/course-editor/${to.params.id}/practice/exam-library`,
children: [
{
@ -241,7 +244,40 @@ const routes: RouteRecordRaw[] = [
name: 'ChapterEditor',
component: ChapterEditor,
meta: { title: '章节编辑' }
}
},
{
path: 'exam-management',
name: 'ExamManagement',
component: ExamManagement,
meta: { title: '考试管理' },
redirect: '/teacher/exam-management/question-management',
children: [
{
path: 'question-management',
name: 'QuestionManagement',
component: QuestionManagement,
meta: { title: '试题管理' }
},
{
path: 'exam-library',
name: 'ExamLibrary',
component: ExamLibrary,
meta: { title: '试卷管理' }
},
{
path: 'marking-center',
name: 'MarkingCenter',
component: MarkingCenter,
meta: { title: '阅卷中心' }
},
{
path: 'add',
name: 'AddExam',
component: AddExam,
meta: { title: '添加试卷' }
}
]
},
]
},

View File

@ -24,6 +24,44 @@
<img :src="activeNavItem === 0 ? '/images/teacher/课程管理(选中).png' : '/images/teacher/课程管理.png'" alt="">
<span>课程管理</span>
</router-link>
<!-- 考试管理 - 可展开菜单 -->
<div class="nav-item" :class="{ active: activeNavItem === 4 }" @click="toggleExamMenu">
<img :src="activeNavItem === 4 ? '/images/teacher/练考通-选中.png' : '/images/teacher/练考通.png'" alt="">
<span>考试管理</span>
<n-icon class="expand-icon" :class="{ expanded: examMenuExpanded }">
<ChevronDownOutline />
</n-icon>
</div>
<!-- 考试管理子菜单 -->
<div class="submenu-container" :class="{ expanded: examMenuExpanded }">
<router-link
to="/teacher/exam-management/question-management"
class="submenu-item"
:class="{ active: activeSubNavItem === 'question-management' }"
@click="setActiveSubNavItem('question-management')"
>
<span>试题管理</span>
</router-link>
<router-link
to="/teacher/exam-management/exam-library"
class="submenu-item"
:class="{ active: activeSubNavItem === 'exam-library' }"
@click="setActiveSubNavItem('exam-library')"
>
<span>试卷管理</span>
</router-link>
<router-link
to="/teacher/exam-management/marking-center"
class="submenu-item"
:class="{ active: activeSubNavItem === 'marking-center' }"
@click="setActiveSubNavItem('marking-center')"
>
<span>阅卷中心</span>
</router-link>
</div>
<router-link to="/teacher/student-management" class="nav-item" :class="{ active: activeNavItem === 1 }"
@click="setActiveNavItem(1)">
@ -50,7 +88,7 @@
<div class="breadcrumb">
<span class="breadcrumb-separator"></span>
<n-breadcrumb>
<n-breadcrumb-item v-for="(item, index) in breadcrumbItems" :key="index" :to="item.path">
<n-breadcrumb-item v-for="(item, index) in breadcrumbItems" :key="index" :href="item.path">
{{ item.title }}
</n-breadcrumb-item>
</n-breadcrumb>
@ -65,17 +103,43 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ChevronDownOutline } from '@vicons/ionicons5'
const width = window.innerWidth;
const height = window.innerHeight;
console.log(`当前屏幕宽度: ${width}px, 高度: ${height}px`);
//
const activeNavItem = ref(0); // 0: , 1: , 2: , 3:
const activeNavItem = ref(0); // 0: , 1: , 2: , 3: , 4:
const activeSubNavItem = ref(''); //
const examMenuExpanded = ref(false); //
const route = useRoute();
const setActiveNavItem = (index: number) => {
activeNavItem.value = index;
//
if (index !== 4) {
examMenuExpanded.value = false;
activeSubNavItem.value = '';
}
}
//
const toggleExamMenu = () => {
examMenuExpanded.value = !examMenuExpanded.value;
activeNavItem.value = 4;
//
if (examMenuExpanded.value && !activeSubNavItem.value) {
activeSubNavItem.value = 'question-management';
}
}
//
const setActiveSubNavItem = (subItem: string) => {
activeSubNavItem.value = subItem;
activeNavItem.value = 4;
examMenuExpanded.value = true;
}
//
@ -108,12 +172,35 @@ const updateActiveNavItem = () => {
const path = route.path;
if (path.includes('course-management')) {
activeNavItem.value = 0; //
examMenuExpanded.value = false;
activeSubNavItem.value = '';
} else if (path.includes('student-management')) {
activeNavItem.value = 1; //
examMenuExpanded.value = false;
activeSubNavItem.value = '';
} else if (path.includes('my-resources')) {
activeNavItem.value = 2; //
examMenuExpanded.value = false;
activeSubNavItem.value = '';
} else if (path.includes('personal-center')) {
activeNavItem.value = 3; //
examMenuExpanded.value = false;
activeSubNavItem.value = '';
} else if (path.includes('exam-management')) {
activeNavItem.value = 4; //
examMenuExpanded.value = true;
//
if (path.includes('question-management')) {
activeSubNavItem.value = 'question-management';
} else if (path.includes('exam-library')) {
activeSubNavItem.value = 'exam-library';
} else if (path.includes('marking-center')) {
activeSubNavItem.value = 'marking-center';
} else {
//
activeSubNavItem.value = 'question-management';
}
}
}
@ -296,6 +383,17 @@ const updateActiveNavItem = () => {
width: 200px;
margin: 0 10px 15px;
}
.submenu-container {
margin-left: 10px;
width: 200px;
}
.submenu-item {
margin-left: 20px;
padding-left: 30px;
font-size: 14px;
}
}
@media screen and (max-width: 480px) {
@ -308,6 +406,22 @@ const updateActiveNavItem = () => {
.nav-container .nav-item img {
margin-left: 20px;
}
.submenu-container {
margin-left: 5px;
width: 150px;
}
.submenu-item {
margin-left: 10px;
padding-left: 25px;
font-size: 13px;
height: 35px;
}
.expand-icon {
margin-right: 10px;
}
}
.nav-container .nav-item:hover {
@ -342,12 +456,77 @@ const updateActiveNavItem = () => {
text-transform: none;
}
/* 展开图标样式 */
.expand-icon {
margin-left: auto;
margin-right: 20px;
transition: transform 0.3s ease;
color: #666;
}
.expand-icon.expanded {
transform: rotate(180deg);
}
/* 子菜单容器 */
.submenu-container {
overflow: hidden;
max-height: 0;
transition: max-height 0.3s ease-out;
margin-left: 20px;
width: 254px;
}
.submenu-container.expanded {
max-height: 200px;
}
/* 子菜单项样式 */
.submenu-item {
display: flex;
align-items: center;
height: 40px;
margin-bottom: 8px;
margin-left: 30px;
padding-left: 40px;
border-radius: 8px;
transition: all 0.3s ease;
text-decoration: none;
color: #666;
font-size: 16px;
position: relative;
}
.submenu-item:hover {
background: rgba(102, 183, 227, 0.05);
}
.submenu-item.active {
background: rgba(102, 183, 227, 0.1);
color: #0C99DA;
}
/* 子菜单项前的小圆点 */
.submenu-item::before {
content: '';
width: 6px;
height: 6px;
background-color: #ccc;
border-radius: 50%;
position: absolute;
left: 20px;
transition: background-color 0.3s ease;
}
.submenu-item.active::before {
background-color: #0C99DA;
}
.router-view-container {
flex: 1;
padding: 20px;
background: #F5F7FA;
height: calc(100vh - var(--top-height, 130px));
overflow-y: auto;
}
@media screen and (max-width: 768px) {

View File

@ -39,12 +39,12 @@
</router-link>
</div>
</div>
<!-- 练考通父菜单 -->
<!-- 考试管理父菜单 -->
<div class="menu-group">
<div class="menu-header" @click="toggleHomework('practice')">
<img :src="$route.path.includes('practice') ? '/images/teacher/练考通-选中.png' : '/images/teacher/练考通.png'"
alt="练考通" />
<span>练考通</span>
alt="考试管理" />
<span>考试管理</span>
<i class="n-base-icon" :class="{ 'expanded': subMenuArr.practice }">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path

View File

@ -1,14 +1,31 @@
<template>
<n-config-provider :theme-overrides="themeOverrides">
<div class="exam-container">
<n-space vertical>
<n-card size="small">
<div class="group required">
组卷方式
<n-tag :checked="examType" checkable @click="changeType(1)"
style="margin-right: 20px; border: 1px solid #F1F3F4;">固定试卷组</n-tag>
<n-tag :checked="!examType" checkable @click="changeType(2)"
style="margin-right: 20px; border: 1px solid #F1F3F4;">随机抽题组卷</n-tag>
<div class="exam-container">
<n-space vertical>
<div class="header-section">
<div class="header-content">
<n-button
quaternary
circle
size="large"
@click="goBack"
class="back-button"
>
<template #icon>
<n-icon>
<ArrowBackOutline />
</n-icon>
</template>
</n-button>
<h1>添加试卷</h1>
</div>
</div>
<n-card size="small">
<div class="group required">
组卷方式
<n-tag :checked="examType" checkable @click="changeType(1)"
style="margin-right: 20px; border: 1px solid #F1F3F4;">固定试卷组</n-tag>
<n-tag :checked="!examType" checkable @click="changeType(2)"
style="margin-right: 20px; border: 1px solid #F1F3F4;">随机抽题组卷</n-tag>
</div>
<div class="group required">
<n-row>试卷名称</n-row>
@ -108,7 +125,8 @@
<!-- 题目内容输入 -->
<div class="sub-question-content">
<n-input v-model:value="subQuestion.title" type="textarea" placeholder="请输入题目内容" style="flex: 1;" />
<n-input v-model:value="subQuestion.title" type="textarea" placeholder="请输入题目内容"
style="flex: 1;" />
<n-button size="small" quaternary @click="deleteSubQuestion(index, subIndex)">
<template #icon>
<n-icon>
@ -385,10 +403,7 @@
<n-button type="primary" ghost size="large" @click="openBatchScoreModal">
批量设置分数
</n-button>
<n-button
type="primary"
:ghost="!examForm.useAIGrading"
size="large"
<n-button type="primary" :ghost="!examForm.useAIGrading" size="large"
@click="toggleAIGrading">
<!-- <template #icon>
<n-icon>
@ -416,7 +431,7 @@
<!-- 中间统计信息 -->
<div class="footer-center">
<span>题目数量{{ examForm.questions.length }} </span>
<span>总分{{ examForm.questions.reduce((total, q) => total + q.totalScore, 0) }} </span>
<span>总分{{examForm.questions.reduce((total, q) => total + q.totalScore, 0)}} </span>
</div>
<!-- 右侧按钮 -->
@ -438,93 +453,39 @@
</n-space>
<!-- 批量设置分数模态框 -->
<BatchSetScoreModal
v-model:visible="showBatchScoreModal"
:questions="examForm.questions"
@confirm="handleBatchScoreConfirm"
@cancel="handleBatchScoreCancel"
/>
<BatchSetScoreModal v-model:visible="showBatchScoreModal" :questions="examForm.questions"
@confirm="handleBatchScoreConfirm" @cancel="handleBatchScoreCancel" />
<!-- 试卷设置模态框 -->
<ExamSettingsModal
v-model:visible="showExamSettingsModal"
:exam-data="examSettingsData"
@confirm="handleExamSettingsConfirm"
@cancel="handleExamSettingsCancel"
/>
<ExamSettingsModal v-model:visible="showExamSettingsModal" :exam-data="examSettingsData"
@confirm="handleExamSettingsConfirm" @cancel="handleExamSettingsCancel" />
<!-- 题库选择模态框 -->
<QuestionBankModal
v-model:visible="showQuestionBankModal"
@confirm="handleQuestionBankConfirm"
@cancel="handleQuestionBankCancel"
/>
</div>
</n-config-provider>
<QuestionBankModal v-model:visible="showQuestionBankModal" @confirm="handleQuestionBankConfirm"
@cancel="handleQuestionBankCancel" />
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { createDiscreteApi, NConfigProvider } from 'naive-ui';
import type { GlobalThemeOverrides } from 'naive-ui';
import { AddCircle, SettingsOutline, TrashOutline, ChevronUpSharp, BookSharp } from '@vicons/ionicons5'
import { createDiscreteApi } from 'naive-ui';
import { useRouter } from 'vue-router';
import { AddCircle, SettingsOutline, TrashOutline, ChevronUpSharp, BookSharp, ArrowBackOutline } from '@vicons/ionicons5'
import BatchSetScoreModal from '@/components/admin/ExamComponents/BatchSetScoreModal.vue';
import ExamSettingsModal from '@/components/admin/ExamComponents/ExamSettingsModal.vue';
import QuestionBankModal from '@/components/admin/ExamComponents/QuestionBankModal.vue';
//
const themeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#0288D1',
primaryColorHover: '#0277BD', // A slightly darker shade for hover
primaryColorPressed: '#01579B', // A darker shade for pressed
errorColor: '#FF4D4F',
errorColorHover: '#E54547',
errorColorPressed: '#C0383A',
},
Button: {
// For ghost primary buttons
textColorGhostPrimary: '#0288D1',
borderPrimary: '1px solid #0288D1',
// For ghost error buttons
textColorGhostError: '#FF4D4F',
borderError: '1px solid #FF4D4F',
},
Tag: {
colorPrimary: '#0288D1',
colorPrimaryHover: '#0277BD',
colorPrimaryPressed: '#01579B',
textColorPrimary: '#ffffff',
},
Card: {
borderColor: '#E6E6E6',
},
Input: {
borderHover: '#0288D1',
borderFocus: '#0288D1',
boxShadowFocus: '0 0 0 2px rgba(2, 136, 209, 0.2)',
},
InputNumber: {
borderHover: '#0288D1',
borderFocus: '#0288D1',
boxShadowFocus: '0 0 0 2px rgba(2, 136, 209, 0.2)',
},
Select: {
borderHover: '#0288D1',
borderFocus: '#0288D1',
boxShadowFocus: '0 0 0 2px rgba(2, 136, 209, 0.2)',
},
Modal: {
borderRadius: '8px',
},
Dialog: {
borderRadius: '8px',
}
};
// dialog API
const { dialog } = createDiscreteApi(['dialog'])
//
const router = useRouter()
//
const goBack = () => {
router.back()
}
//
enum QuestionType {
SINGLE_CHOICE = 'single_choice', //
@ -1399,14 +1360,46 @@ const previewSubQuestion = (bigQuestionIndex: number, subQuestionIndex: number)
</script>
<style scoped>
.exam-container {
padding: 20px;
.exam-container{
background-color: #fff;
padding: 10px;
}
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 20px;
}
.header-content {
display: flex;
align-items: center;
gap: 12px;
}
.header-content h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
.back-button {
color: #666;
transition: all 0.3s ease;
}
.back-button:hover {
color: #1890ff;
background-color: rgba(24, 144, 255, 0.1);
}
.group {
border: 1px solid #F1F3F4;
padding: 12px;
margin: 15px 0;
margin-bottom: 15px 0;
}
.required::before {
@ -1572,7 +1565,7 @@ const previewSubQuestion = (bigQuestionIndex: number, subQuestionIndex: number)
margin-top: 8px;
}
.sub-question-number{
.sub-question-number {
display: flex;
gap: 6;
}

View File

@ -1,32 +1,27 @@
<template>
<n-config-provider :theme-overrides="themeOverrides">
<div class="exam-library-container">
<div class="header-section">
<h1 class="title">试卷库</h1>
<n-space class="actions-group">
<n-button type="primary" @click="handleAddExam">添加试卷</n-button>
<n-button ghost>导入</n-button>
<n-button ghost>导出</n-button>
<n-button type="error" ghost>删除</n-button>
<n-input placeholder="请输入想要搜索的内容" />
<n-button type="primary">搜索</n-button>
</n-space>
</div>
<n-data-table :columns="columns" :data="examData" :row-key="(row: Exam) => row.id"
@update:checked-row-keys="handleCheck" class="exam-table" :single-line="false" />
<div class="pagination-container">
<n-pagination v-model:page="currentPage" :page-count="totalPages" />
</div>
<div class="exam-library-container">
<div class="header-section">
<h1 class="title">试卷库</h1>
<n-space class="actions-group">
<n-button type="primary" @click="handleAddExam">添加试卷</n-button>
<n-button ghost>导入</n-button>
<n-button ghost>导出</n-button>
<n-button type="error" ghost>删除</n-button>
<n-input placeholder="请输入想要搜索的内容" />
<n-button type="primary">搜索</n-button>
</n-space>
</div>
</n-config-provider>
<n-data-table :columns="columns" :data="examData" :row-key="(row: Exam) => row.id"
@update:checked-row-keys="handleCheck" class="exam-table" :single-line="false"
:pagination="paginationConfig" />
</div>
</template>
<script setup lang="ts">
import { h, ref, VNode } from 'vue';
import { NButton, NSpace, useMessage, NDataTable, NPagination, NInput, NConfigProvider } from 'naive-ui';
import type { DataTableColumns, GlobalThemeOverrides } from 'naive-ui';
import { h, ref, VNode, computed } from 'vue';
import { NButton, NSpace, useMessage, NDataTable, NInput } from 'naive-ui';
import type { DataTableColumns } from 'naive-ui';
import { useRouter } from 'vue-router';
const router = useRouter();
@ -46,35 +41,6 @@ type Exam = {
creationTime: string;
};
//
const themeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#0288D1',
primaryColorHover: '#0277BD', // A slightly darker shade for hover
primaryColorPressed: '#01579B', // A darker shade for pressed
errorColor: '#FF4D4F',
errorColorHover: '#E54547',
errorColorPressed: '#C0383A',
},
Button: {
// For ghost primary buttons
textColorGhostPrimary: '#0288D1',
borderPrimary: '1px solid #0288D1',
// For ghost error buttons
textColorGhostError: '#FF4D4F',
borderError: '1px solid #FF4D4F',
},
Pagination: {
itemColorActive: '#0288D1',
itemTextColorActive: '#fff',
itemBorderActive: '1px solid #0288D1',
itemColorActiveHover: '#0277BD', // 使
itemTextColorActiveHover: '#fff', //
itemBorderActiveHover: '1px solid #0277BD' //
}
};
const message = useMessage();
//
@ -172,7 +138,10 @@ const examData = ref<Exam[]>([
{ id: 5, name: '试卷名称试卷名称', category: '练习', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '发布中', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
{ id: 6, name: '试卷名称试卷名称', category: '考试', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '未发布', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
{ id: 7, name: '试卷名称试卷名称', category: '练习', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '发布中', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
{ id: 8, name: '试卷名称试卷名称', category: '考试', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '未发布', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
{ id: 8, name: '试卷名称试卷名称', category: '考试', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '未发布', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },{ id: 7, name: '试卷名称试卷名称', category: '练习', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '发布中', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
{ id: 9, name: '试卷名称试卷名称', category: '考试', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '未发布', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
{ id: 10, name: '试卷名称试卷名称', category: '练习', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '发布中', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
{ id: 11, name: '试卷名称试卷名称', category: '考试', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '未发布', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
]);
const columns = createColumns({
@ -188,7 +157,31 @@ const handleCheck = (rowKeys: Array<string | number>) => {
//
const currentPage = ref(1);
const totalPages = ref(29); //
const pageSize = ref(10);
const totalItems = ref(examData.value.length);
//
const paginationConfig = computed(() => ({
page: currentPage.value,
pageSize: pageSize.value,
itemCount: totalItems.value,
pageSizes: [10, 20, 50, 100],
showSizePicker: true,
showQuickJumper: true,
prefix: (info: { startIndex: number; endIndex: number; page: number; pageSize: number; pageCount: number; itemCount?: number }) => {
const itemCount = info.itemCount || 0;
const start = (currentPage.value - 1) * pageSize.value + 1;
const end = Math.min(currentPage.value * pageSize.value, itemCount);
return `显示 ${start}-${end} 条,共 ${itemCount}`;
},
onUpdatePage: (page: number) => {
currentPage.value = page;
},
onUpdatePageSize: (newPageSize: number) => {
pageSize.value = newPageSize;
currentPage.value = 1;
}
}));
const handleAddExam = () => {
//
@ -227,10 +220,4 @@ const handleAddExam = () => {
.exam-table {
margin-top: 20px;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>

View File

@ -0,0 +1,45 @@
<template>
<div>
<router-view v-slot="{ Component, route }">
<transition
name="fade-slide"
mode="out-in"
appear
>
<component :is="Component" :key="route.path" />
</transition>
</router-view>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
/* 页面过渡动画 */
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease-in-out;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(20px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(-20px);
}
.fade-slide-enter-to,
.fade-slide-leave-from {
opacity: 1;
transform: translateX(0);
}
/* 可选:添加更丰富的过渡效果 */
.fade-slide-enter-active {
transition-delay: 0.1s;
}
</style>

View File

@ -1,11 +1,507 @@
<template>
<div>
<h1>阅卷中心</h1>
<div class="marking-center">
<!-- Tab切换 -->
<div class="tab-container">
<n-tabs
v-model:value="activeTab"
type="line"
animated
@update:value="handleTabChange"
>
<n-tab-pane name="all" tab="全部">
</n-tab-pane>
<n-tab-pane name="not-started" tab="未开始">
</n-tab-pane>
<n-tab-pane name="in-progress" tab="进行中">
</n-tab-pane>
<n-tab-pane name="completed" tab="已结束">
</n-tab-pane>
</n-tabs>
</div>
<!-- 筛选栏 -->
<div class="filter-container">
<div class="filter-right">
<n-select
v-model:value="examFilter"
:options="examFilterOptions"
placeholder="考试"
style="width: 120px; margin-right: 16px;"
/>
<n-select
v-model:value="gradeFilter"
:options="gradeFilterOptions"
placeholder="班级名称"
style="width: 120px;"
/>
</div>
</div>
<!-- 试卷列表 -->
<div class="exam-list">
<div
v-for="exam in filteredExams"
:key="exam.id"
class="exam-item"
:class="{ 'completed': exam.status === 'completed' }"
>
<div class="exam-content">
<div class="exam-header">
<n-tag
:type="getStatusType(exam.status)"
:bordered="false"
size="small"
class="status-tag"
>
{{ getStatusText(exam.status) }}
</n-tag>
<span class="exam-title">{{ exam.title }}</span>
</div>
<div class="exam-description">
{{ exam.description }}
</div>
<div class="exam-meta">
<div class="meta-item">
<n-icon :component="PersonOutline" />
<span>{{ exam.creator }}</span>
</div>
<div class="meta-item">
<n-icon :component="CalendarOutline" />
<span>{{ exam.duration }}</span>
</div>
</div>
<div class="exam-actions">
<n-button
text
type="primary"
@click="handleViewDetails(exam)"
>
试卷设置
</n-button>
<n-button
text
type="primary"
@click="handleDelete(exam)"
>
删除
</n-button>
</div>
</div>
<div class="exam-stats">
<div class="stats-item">
<div class="stats-number">{{ exam.totalQuestions }}</div>
<div class="stats-label">试题</div>
</div>
<div class="stats-item">
<div class="stats-number">{{ exam.submittedCount }}</div>
<div class="stats-label">已交</div>
</div>
<div class="stats-item">
<div class="stats-number">{{ exam.gradedCount }}</div>
<div class="stats-label">{{ exam.status === 'in-progress' ? '0未交' : '0未交' }}</div>
</div>
</div>
<div class="exam-action-button">
<n-button
:type="exam.status === 'completed' ? 'default' : 'primary'"
@click="handleAction(exam)"
>
{{ exam.status === 'completed' ? '查看' : (exam.status === 'in-progress' ? '批阅' : '查看') }}
</n-button>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-container">
<n-pagination
v-model:page="currentPage"
:page-size="pageSize"
show-size-picker
:page-sizes="[10, 20, 50]"
show-quick-jumper
:item-count="totalItems"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { PersonOutline, CalendarOutline } from '@vicons/ionicons5'
//
interface ExamItem {
id: string
title: string
description: string
creator: string
duration: string
status: 'not-started' | 'in-progress' | 'completed'
totalQuestions: number
submittedCount: number
gradedCount: number
}
//
const activeTab = ref('all')
const examFilter = ref('')
const gradeFilter = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
//
const examFilterOptions = [
{ label: '全部考试', value: '' },
{ label: '期中考试', value: 'midterm' },
{ label: '期末考试', value: 'final' },
{ label: '月考', value: 'monthly' }
]
const gradeFilterOptions = [
{ label: '全部班级', value: '' },
{ label: '一年级1班', value: 'grade1-1' },
{ label: '一年级2班', value: 'grade1-2' },
{ label: '二年级1班', value: 'grade2-1' }
]
//
const examList = ref<ExamItem[]>([
{
id: '1',
title: '试卷名称试卷名称试卷名称试卷名称试卷名称',
description: '试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明...',
creator: '发布人',
duration: '考试时间2025.6.18-2025.9.18',
status: 'not-started',
totalQuestions: 10,
submittedCount: 0,
gradedCount: 0
},
{
id: '2',
title: '试卷名称试卷名称试卷名称试卷名称试卷名称',
description: '试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明...',
creator: '发布人',
duration: '考试时间2025.6.18-2025.9.18',
status: 'in-progress',
totalQuestions: 0,
submittedCount: 0,
gradedCount: 0
},
{
id: '3',
title: '试卷名称试卷名称试卷名称试卷名称试卷名称',
description: '试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明试卷说明...',
creator: '发布人',
duration: '考试时间2025.6.18-2025.9.18',
status: 'completed',
totalQuestions: 10,
submittedCount: 0,
gradedCount: 0
}
])
//
const filteredExams = computed(() => {
let filtered = examList.value
// tab
if (activeTab.value !== 'all') {
filtered = filtered.filter(exam => exam.status === activeTab.value)
}
//
if (examFilter.value) {
//
}
//
if (gradeFilter.value) {
//
}
return filtered
})
const totalItems = computed(() => filteredExams.value.length)
//
const handleTabChange = (value: string) => {
activeTab.value = value
currentPage.value = 1
}
const getStatusType = (status: string) => {
switch (status) {
case 'not-started':
return 'default'
case 'in-progress':
return 'warning'
case 'completed':
return 'success'
default:
return 'default'
}
}
const getStatusText = (status: string) => {
switch (status) {
case 'not-started':
return '未开始'
case 'in-progress':
return '进行中'
case 'completed':
return '已结束'
default:
return '未知'
}
}
const handleViewDetails = (exam: ExamItem) => {
console.log('查看试卷详情:', exam)
}
const handleDelete = (exam: ExamItem) => {
console.log('阅读预览:', exam)
}
const handleAction = (exam: ExamItem) => {
console.log('执行操作:', exam)
}
const handlePageChange = (page: number) => {
currentPage.value = page
}
const handlePageSizeChange = (size: number) => {
pageSize.value = size
currentPage.value = 1
}
onMounted(() => {
//
})
</script>
<style scoped>
/* Tab容器样式 */
.tab-container {
background-color: #fff;
padding: 16px 20px 0;
border-radius: 8px 8px 0 0;
}
:deep(.n-tabs-nav-scroll-content) {
--n-tab-font-size: 16px !important;
}
:deep(.n-tabs-tab) {
padding: 12px 24px;
margin-right: 8px;
}
:deep(.n-tabs-tab--active) {
color: #1890ff;
font-weight: 500;
}
/* 筛选栏样式 */
.filter-container {
display: flex;
justify-content: flex-end;
align-items: center;
background-color: #fff;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.filter-right {
display: flex;
align-items: center;
}
/* 试卷列表样式 */
.exam-list {
background-color: #fff;
padding: 0 20px 20px;
}
.exam-item {
display: flex;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid #f0f0f0;
transition: all 0.3s ease;
}
.exam-item:hover {
background-color: #fafafa;
}
.exam-item.completed {
opacity: 0.8;
}
.exam-content {
flex: 1;
padding-right: 20px;
}
.exam-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.status-tag {
font-size: 12px;
padding: 2px 6px;
}
.exam-title {
font-size: 16px;
font-weight: 500;
color: #333;
line-height: 1.4;
}
.exam-description {
color: #666;
font-size: 14px;
line-height: 1.5;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.exam-meta {
display: flex;
gap: 20px;
margin-bottom: 12px;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
color: #666;
font-size: 14px;
}
.meta-item .n-icon {
font-size: 16px;
}
.exam-actions {
display: flex;
gap: 16px;
}
/* 统计数据样式 */
.exam-stats {
display: flex;
gap: 30px;
margin-right: 20px;
}
.stats-item {
text-align: center;
}
.stats-number {
font-size: 20px;
font-weight: 600;
color: #333;
line-height: 1;
}
.stats-label {
font-size: 12px;
color: #999;
margin-top: 4px;
}
/* 操作按钮样式 */
.exam-action-button {
min-width: 80px;
}
.exam-action-button .n-button {
width: 100%;
}
/* 分页样式 */
.pagination-container {
background-color: #fff;
padding: 20px;
border-radius: 0 0 8px 8px;
display: flex;
justify-content: center;
}
/* 响应式设计 */
@media (max-width: 768px) {
.filter-container {
justify-content: center;
}
.filter-right {
width: 100%;
justify-content: space-between;
}
.exam-item {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.exam-stats {
width: 100%;
justify-content: space-around;
}
.exam-action-button {
width: 100%;
min-width: auto;
}
}
@media (max-width: 480px) {
.tab-container,
.filter-container,
.exam-list,
.pagination-container {
padding-left: 16px;
padding-right: 16px;
}
.exam-stats {
gap: 20px;
}
.meta-item {
font-size: 13px;
}
.exam-title {
font-size: 15px;
}
.exam-description {
font-size: 13px;
}
}
</style>

View File

@ -0,0 +1,451 @@
<template>
<div class="question-management-container">
<div class="header-section">
<h1 class="title">全部试题</h1>
<n-space class="actions-group">
<n-button type="primary" @click="addQuestion">添加试题</n-button>
<n-button ghost @click="importQuestions">导入</n-button>
<n-button ghost @click="exportQuestions">导出</n-button>
<n-button type="error" ghost @click="deleteSelected" :disabled="selectedRowKeys.length === 0">删除</n-button>
<n-select
v-model:value="filters.category"
placeholder="分类"
:options="categoryOptions"
style="width: 120px"
@update:value="handleFilterChange"
/>
<n-input
v-model:value="filters.keyword"
placeholder="请输入想要搜索的内容"
style="width: 200px"
clearable
/>
<n-button type="primary" @click="searchQuestions">搜索</n-button>
</n-space>
</div>
<n-data-table
ref="tableRef"
:columns="columns"
:data="questionList"
:loading="loading"
:pagination="paginationConfig"
:row-key="(row: Question) => row.id"
:checked-row-keys="selectedRowKeys"
@update:checked-row-keys="handleCheck"
class="question-table"
:single-line="false"
/>
<!-- 导入弹窗 -->
<ImportModal
v-model:show="showImportModal"
template-name="question_template.xlsx"
import-type="question"
@success="handleImportSuccess"
@template-download="handleTemplateDownload"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, h, VNode } from 'vue';
import { NButton, NTag, NSpace } from 'naive-ui';
import ImportModal from '@/components/common/ImportModal.vue';
//
interface Question {
id: string;
sequence: number;
title: string;
type: string;
category: string;
difficulty: string;
score: number;
creator: string;
createTime: string;
}
//
const filters = reactive({
category: '',
keyword: ''
});
//
const categoryOptions = ref([
{ label: '全部', value: '' },
{ label: '分类试题', value: 'category' },
{ label: '考试试题', value: 'exam' }
]);
//
const loading = ref(false);
const selectedRowKeys = ref<string[]>([]);
const questionList = ref<Question[]>([]);
//
const showImportModal = ref(false);
//
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
});
//
const paginationConfig = computed(() => ({
page: pagination.page,
pageSize: pagination.pageSize,
itemCount: pagination.total,
pageSizes: [10, 20, 50, 100],
showSizePicker: true,
showQuickJumper: true,
prefix: (info: { startIndex: number; endIndex: number; page: number; pageSize: number; pageCount: number; itemCount?: number }) => {
const itemCount = info.itemCount || 0;
const start = (pagination.page - 1) * pagination.pageSize + 1;
const end = Math.min(pagination.page * pagination.pageSize, itemCount);
return `显示 ${start}-${end} 条,共 ${itemCount}`;
},
onUpdatePage: (page: number) => {
pagination.page = page;
loadQuestions();
},
onUpdatePageSize: (pageSize: number) => {
pagination.pageSize = pageSize;
pagination.page = 1;
loadQuestions();
}
}));
//
const createColumns = ({
handleAction,
}: {
handleAction: (action: string, rowData: Question) => void;
}) => {
return [
{
type: 'selection',
},
{
title: '序号',
key: 'sequence',
width: 80,
align: 'center' as const
},
{
title: '试题内容',
key: 'title',
width: 300,
ellipsis: {
tooltip: true
}
},
{
title: '题型',
key: 'type',
width: 100,
align: 'center' as const,
render(row: Question) {
const typeMap: { [key: string]: { text: string; type: any } } = {
'single_choice': { text: '单选题', type: 'info' },
'multiple_choice': { text: '多选题', type: 'warning' },
'true_false': { text: '判断题', type: 'success' },
'fill_blank': { text: '填空题', type: 'error' },
'short_answer': { text: '简答题', type: 'default' }
};
const typeInfo = typeMap[row.type] || { text: row.type, type: 'default' };
return h(NTag, { type: typeInfo.type, size: 'small' }, { default: () => typeInfo.text });
}
},
{
title: '分类',
key: 'category',
width: 100,
align: 'center' as const
},
{
title: '难度',
key: 'difficulty',
width: 80,
align: 'center' as const,
render(row: Question) {
const difficultyMap: { [key: string]: { text: string; type: any } } = {
'easy': { text: '易', type: 'success' },
'medium': { text: '中', type: 'warning' },
'hard': { text: '难', type: 'error' }
};
const diffInfo = difficultyMap[row.difficulty] || { text: row.difficulty, type: 'default' };
return h(NTag, { type: diffInfo.type, size: 'small' }, { default: () => diffInfo.text });
}
},
{
title: '分值',
key: 'score',
width: 80,
align: 'center' as const
},
{
title: '创建人',
key: 'creator',
width: 100,
align: 'center' as const
},
{
title: '创建时间',
key: 'createTime',
width: 160,
align: 'center' as const
},
{
title: '操作',
key: 'actions',
width: 120,
align: 'center' as const,
render(row: Question) {
const buttons: VNode[] = [];
buttons.push(
h(NButton, {
size: 'small',
type: 'primary',
ghost: true,
style: 'margin: 0 3px;',
onClick: () => handleAction('编辑', row)
}, { default: () => '编辑' })
);
buttons.push(
h(NButton, {
size: 'small',
type: 'error',
ghost: true,
style: 'margin: 0 3px;',
onClick: () => handleAction('删除', row)
}, { default: () => '删除' })
);
return h(NSpace, {}, { default: () => buttons });
}
}
];
};
//
const columns = createColumns({
handleAction: (action, row) => {
if (action === '编辑') {
editQuestion(row.id);
} else if (action === '删除') {
deleteQuestion(row.id);
}
},
});
//
const generateMockData = (): Question[] => {
const mockData: Question[] = [];
const types = ['single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'short_answer'];
const difficulties = ['easy', 'medium', 'hard'];
const categories = ['试题分类', '考试分类'];
const creators = ['王建国', '李明', '张三', '刘老师'];
for (let i = 1; i <= 50; i++) {
mockData.push({
id: `question_${i}`,
sequence: i,
title: `在教育中的优势条件下各部门,内容关于...`,
type: types[Math.floor(Math.random() * types.length)],
category: categories[Math.floor(Math.random() * categories.length)],
difficulty: difficulties[Math.floor(Math.random() * difficulties.length)],
score: 10,
creator: creators[Math.floor(Math.random() * creators.length)],
createTime: '2025.08.20 09:20'
});
}
return mockData;
};
//
const handleCheck = (rowKeys: string[]) => {
selectedRowKeys.value = rowKeys;
};
//
const handleFilterChange = () => {
pagination.page = 1;
loadQuestions();
};
//
const searchQuestions = () => {
pagination.page = 1;
loadQuestions();
};
//
const loadQuestions = async () => {
loading.value = true;
try {
// API
await new Promise(resolve => setTimeout(resolve, 500));
const allData = generateMockData();
//
let filteredData = allData;
if (filters.category) {
filteredData = filteredData.filter(item => item.category.includes(filters.category));
}
if (filters.keyword) {
filteredData = filteredData.filter(item =>
item.title.includes(filters.keyword) ||
item.creator.includes(filters.keyword)
);
}
//
pagination.total = filteredData.length;
const start = (pagination.page - 1) * pagination.pageSize;
const end = start + pagination.pageSize;
questionList.value = filteredData.slice(start, end);
} catch (error) {
console.error('加载题目失败:', error);
} finally {
loading.value = false;
}
};
//
const addQuestion = () => {
console.log('添加试题');
};
const importQuestions = () => {
showImportModal.value = true;
};
const exportQuestions = () => {
console.log('导出题目');
};
//
const handleImportSuccess = (result: any) => {
console.log('导入成功:', result);
//
loadQuestions();
};
//
const handleTemplateDownload = (type?: string) => {
console.log('下载模板:', type);
// TODO: API
};
const deleteSelected = () => {
console.log('批量删除:', selectedRowKeys.value);
};
const editQuestion = (id: string) => {
console.log('编辑题目:', id);
};
const deleteQuestion = (id: string) => {
console.log('删除题目:', id);
};
//
onMounted(() => {
loadQuestions();
});
</script>
<style scoped>
.question-management-container {
background-color: #fff;
padding: 20px;
border-radius: 8px;
}
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 20px;
border-bottom: 1px solid #E6E6E6;
}
.title {
margin: 0;
font-size: 20px;
font-weight: 500;
}
.actions-group {
display: flex;
align-items: center;
gap: 10px;
}
.question-table {
margin-top: 20px;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.header-section {
flex-direction: column;
align-items: stretch;
gap: 16px;
}
.actions-group {
justify-content: center;
flex-wrap: wrap;
}
}
@media (max-width: 768px) {
.question-management-container {
padding: 12px;
}
.actions-group {
gap: 8px;
}
.actions-group .n-input {
width: 150px !important;
}
.actions-group .n-select {
width: 100px !important;
}
}
@media (max-width: 480px) {
.title {
font-size: 18px;
}
.header-section {
padding: 12px 0;
}
.actions-group {
justify-content: space-around;
}
.actions-group .n-input {
width: 120px !important;
}
.actions-group .n-select {
width: 80px !important;
}
}
</style>