feat: 新增导入组件,支持数据导入及模板下载;修改考试管理对应的路由层级及页面,加入过渡动画效果
This commit is contained in:
parent
8bb211fc33
commit
5e34c71b14
61
src/App.vue
61
src/App.vue
@ -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>
|
||||
|
||||
|
@ -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,
|
||||
|
475
src/components/common/ImportModal.vue
Normal file
475
src/components/common/ImportModal.vue
Normal 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>
|
136
src/components/common/README.md
Normal file
136
src/components/common/README.md
Normal 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. 考虑网络异常情况的处理
|
@ -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: '添加试卷' }
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
45
src/views/teacher/course/ExamPages/ExamPage.vue
Normal file
45
src/views/teacher/course/ExamPages/ExamPage.vue
Normal 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>
|
@ -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>
|
||||
|
451
src/views/teacher/course/ExamPages/QuestionManagement.vue
Normal file
451
src/views/teacher/course/ExamPages/QuestionManagement.vue
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user