feat: 试卷管理:删除接口,添加试卷部分接口(获取题库,获取题目,导入,导出功能),修复题库管理获取课程列表,分页器显示和切换问题,对接导入题库接口,添加试题接入查询分类,难度接口,修复试题渲染问题,修复编辑题目时选项数据无法正确显示的问题,添加试题编辑接口

This commit is contained in:
QDKF 2025-09-13 19:50:45 +08:00
parent 3461499661
commit 1721ab50fc
15 changed files with 3703 additions and 618 deletions

523
docs/ExamInfo-API-Usage.md Normal file
View File

@ -0,0 +1,523 @@
# 考试信息API使用文档
## 接口概述
`/aiol/aiolExam/getExamInfo` 接口用于获取教师名下的考试信息列表。
## 接口定义
### 请求方式
- **方法**: GET
- **路径**: `/aiol/aiolExam/getExamInfo`
- **参数**: `userId` (查询参数)
### 请求参数
| 参数名 | 类型 | 必选 | 说明 |
|--------|------|------|------|
| userId | string | 是 | 教师用户ID |
### 响应格式
```typescript
{
"success": boolean,
"message": string,
"code": number,
"result": ExamInfo[],
"timestamp": number
}
```
### ExamInfo 数据结构
```typescript
interface ExamInfo {
id: string // 考试ID
name: string // 考试名称
paperId: string // 试卷ID
startTime: string // 开始时间
endTime: string // 结束时间
totalTime: number // 考试时长(分钟)
type: number // 考试类型0=练习1=考试
status: number // 状态0=未发布1=发布中2=已结束
createBy: string // 创建人
createTime: string // 创建时间
updateBy: string // 更新人
updateTime: string // 更新时间
}
```
## 使用示例
### 1. 在API模块中调用
```typescript
import { ExamApi } from '@/api/modules/exam'
// 获取教师考试信息
const response = await ExamApi.getExamInfo('teacher_user_id')
console.log('考试信息:', response.data)
```
### 2. 在Vue组件中使用
```typescript
import { ExamApi } from '@/api/modules/exam'
import { useUserStore } from '@/stores/user'
export default {
setup() {
const userStore = useUserStore()
const loadExamInfo = async () => {
if (!userStore.user?.id) {
console.error('请先登录')
return
}
try {
const response = await ExamApi.getExamInfo(userStore.user.id.toString())
return response.data || []
} catch (error) {
console.error('加载考试信息失败:', error)
return []
}
}
return {
loadExamInfo
}
}
}
```
### 3. 在试卷管理页面中使用
```typescript
// 在 ExamLibrary.vue 中
import { ExamApi } from '@/api/modules/exam'
import { useUserStore } from '@/stores/user'
import type { ExamInfo } from '@/api/types'
const userStore = useUserStore()
const loadExamInfo = async () => {
loading.value = true
try {
const currentUser = userStore.user
if (!currentUser?.id) {
message.error('请先登录')
return
}
const response = await ExamApi.getExamInfo(currentUser.id.toString())
if (response.data && Array.isArray(response.data)) {
// 数据映射和显示逻辑
const mappedList = response.data.map((item: ExamInfo) => {
// 映射逻辑...
return {
id: item.id,
name: item.name,
// ... 其他字段映射
}
})
examData.value = mappedList
}
} catch (error) {
console.error('加载考试信息失败:', error)
message.error('加载考试信息失败')
} finally {
loading.value = false
}
}
```
## 数据映射说明
### 状态映射
```typescript
const statusMap: { [key: number]: string } = {
0: '未发布',
1: '发布中',
2: '已结束'
}
```
### 类型映射
```typescript
const categoryMap: { [key: number]: string } = {
0: '练习',
1: '考试'
}
```
### 难度映射
```typescript
const difficultyMap: { [key: number]: string } = {
0: '易',
1: '中',
2: '难'
}
```
## 错误处理
接口可能返回以下错误:
1. **401 Unauthorized**: 用户未登录或token过期
2. **403 Forbidden**: 没有权限访问
3. **500 Internal Server Error**: 服务器内部错误
建议在调用时添加适当的错误处理:
```typescript
try {
const response = await ExamApi.getExamInfo(userId)
// 处理成功响应
} catch (error) {
if (error.response?.status === 401) {
// 处理认证错误
console.error('登录已过期,请重新登录')
} else {
// 处理其他错误
console.error('获取考试信息失败:', error.message)
}
}
```
## 注意事项
1. 调用此接口前需要确保用户已登录
2. 只有教师用户才能调用此接口
3. 返回的考试信息按创建时间倒序排列
4. 建议在组件挂载时调用此接口加载数据
5. 可以根据需要添加分页、搜索等参数(需要后端支持)
## 相关文件
- API实现: `src/api/modules/exam.ts`
- 类型定义: `src/api/types.ts`
- 使用示例: `src/api/examples/getExamInfo-example.ts`
- 页面实现: `src/views/teacher/ExamPages/ExamLibrary.vue`
---
# 创建试卷API使用文档
## 接口概述
`POST /aiol/aiolPaper/add` 接口用于创建新的试卷。
## 接口定义
### 请求方式
- **方法**: POST
- **路径**: `/aiol/aiolPaper/add`
- **Content-Type**: `application/json`
### 请求参数
| 参数名 | 类型 | 必选 | 说明 |
|--------|------|------|------|
| title | string | 是 | 试卷标题 |
| generateMode | number | 否 | 组卷模式0=固定试卷组1=随机抽题组卷 |
| rules | string | 否 | 组卷规则(随机抽题时使用) |
| repoId | string | 否 | 题库ID随机抽题时使用 |
| totalScore | number | 是 | 试卷总分 |
| passScore | number | 否 | 及格分数默认总分的60% |
| requireReview | number | 否 | 是否需要批阅0=不需要1=需要 |
### 响应格式
```typescript
{
"success": boolean,
"message": string,
"code": number,
"result": string, // 试卷ID
"timestamp": number
}
```
## 使用示例
### 1. 在API模块中调用
```typescript
import { ExamApi } from '@/api/modules/exam'
// 创建固定试卷组
const response = await ExamApi.createExamPaper({
title: '数学期末考试试卷',
generateMode: 0,
totalScore: 100,
passScore: 60,
requireReview: 0
})
console.log('试卷ID:', response.data)
```
### 2. 在Vue组件中使用
```typescript
// 在 AddExam.vue 中
const saveExam = async () => {
try {
const apiData = {
title: examForm.title,
generateMode: examForm.type === 1 ? 0 : 1,
rules: '',
repoId: '',
totalScore: examForm.totalScore,
passScore: examForm.passScore || Math.floor(examForm.totalScore * 0.6),
requireReview: examForm.useAIGrading ? 1 : 0
}
const response = await ExamApi.createExamPaper(apiData)
console.log('创建试卷成功:', response.data)
} catch (error) {
console.error('创建试卷失败:', error)
}
}
```
### 3. 不同组卷模式示例
```typescript
// 固定试卷组
const fixedPaper = {
title: '固定试卷组示例',
generateMode: 0,
rules: '',
repoId: '',
totalScore: 100,
passScore: 60,
requireReview: 0
}
// 随机抽题组卷
const randomPaper = {
title: '随机抽题组卷示例',
generateMode: 1,
rules: '{"difficulty": [1, 2, 3], "types": [0, 1, 2], "count": 20}',
repoId: 'repo_123',
totalScore: 100,
passScore: 60,
requireReview: 1
}
```
## 相关文件
- API实现: `src/api/modules/exam.ts`
- 页面实现: `src/views/teacher/ExamPages/AddExam.vue`
- 使用示例: `src/api/examples/createPaper-example.ts`
---
# 删除试卷API使用文档
## 接口概述
删除试卷相关的API接口包括单个删除和批量删除功能。
## 接口定义
### 1. 删除单个试卷
- **方法**: DELETE
- **路径**: `/aiol/aiolPaper/delete?id={paperId}`
- **参数**: `id` (查询参数) - 试卷ID
### 2. 批量删除试卷
- **实现方式**: 循环调用单个删除接口(因为后端可能不支持批量删除接口)
- **方法**: 内部调用多个 `DELETE /aiol/aiolPaper/delete?id={paperId}`
- **参数**: `ids: string[]` - 试卷ID数组
### 响应格式
**单个删除响应**:
```typescript
{
"success": boolean,
"message": string,
"code": number,
"result": string,
"timestamp": number
}
```
**批量删除响应**:
```typescript
{
"success": boolean,
"message": string,
"code": number,
"result": {
"success": number, // 成功删除的数量
"failed": number, // 失败删除的数量
"total": number, // 总数量
"errors": string[] // 错误信息数组
},
"timestamp": number
}
```
## 使用示例
### 1. 删除单个试卷
```typescript
import { ExamApi } from '@/api/modules/exam'
// 删除单个试卷
const response = await ExamApi.deleteExamPaper('1962379646322384897')
console.log('删除结果:', response.data)
```
### 2. 批量删除试卷
```typescript
// 批量删除试卷
const paperIds = ['1962379646322384897', '1966450638717292545']
const response = await ExamApi.batchDeleteExamPapers(paperIds)
console.log('批量删除结果:', response.data)
```
### 3. 在Vue组件中使用使用 Naive UI 对话框组件)
```typescript
// 在 ExamLibrary.vue 中
import { useDialog, useMessage } from 'naive-ui'
const dialog = useDialog()
const message = useMessage()
const handleDeletePaper = async (row: any) => {
try {
// 使用 Naive UI 对话框组件
dialog.warning({
title: '确认删除',
content: `确定要删除试卷"${row.name}"吗?此操作不可撤销。`,
positiveText: '确定删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
// 调用删除API
const response = await ExamApi.deleteExamPaper(row.id)
// 显示成功消息
message.success('试卷删除成功!')
// 重新加载试卷列表
await loadExamPaperList()
} catch (error) {
console.error('删除试卷失败:', error)
message.error('删除试卷失败,请重试')
}
}
})
} catch (error) {
console.error('删除试卷失败:', error)
message.error('删除试卷失败,请重试')
}
}
// 批量删除示例
const handleBatchDelete = async () => {
if (checkedRowKeys.value.length === 0) {
message.warning('请先选择要删除的试卷')
return
}
try {
// 使用 Naive UI 对话框组件
dialog.error({
title: '确认批量删除',
content: `确定要删除选中的 ${checkedRowKeys.value.length} 个试卷吗?此操作不可撤销。`,
positiveText: '确定删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
// 调用批量删除API
const response = await ExamApi.batchDeleteExamPapers(checkedRowKeys.value as string[])
// 显示成功消息
message.success(`成功删除 ${checkedRowKeys.value.length} 个试卷!`)
// 清空选中状态
checkedRowKeys.value = []
// 重新加载试卷列表
await loadExamPaperList()
} catch (error) {
console.error('批量删除试卷失败:', error)
message.error('批量删除试卷失败,请重试')
}
}
})
} catch (error) {
console.error('批量删除试卷失败:', error)
message.error('批量删除试卷失败,请重试')
}
}
```
### 4. 错误处理
```typescript
const deletePaperWithErrorHandling = async (paperId: string) => {
try {
const response = await ExamApi.deleteExamPaper(paperId)
if (response.data === 'success') {
return { success: true, message: '删除成功' }
} else {
return { success: false, message: '删除失败,请重试' }
}
} catch (error: any) {
if (error.response?.status === 404) {
return { success: false, message: '试卷不存在' }
} else if (error.response?.status === 403) {
return { success: false, message: '没有权限删除此试卷' }
}
return { success: false, message: '删除失败,请检查网络连接' }
}
}
```
## 功能特性
### 单个删除
- 支持删除单个试卷
- 使用 Naive UI 警告对话框组件
- 删除成功后自动刷新列表
- 完整的错误处理
### 批量删除
- 支持同时删除多个试卷
- 循环调用单个删除接口(避免后端接口不存在的问题)
- 逐个删除,避免对服务器造成过大压力
- 详细的删除结果反馈(成功/失败数量)
- 使用 Naive UI 错误对话框组件(更醒目的警告)
- 删除后清空选中状态
### 用户体验
- **美观的确认对话框**: 使用 Naive UI 组件,样式统一美观
- **不同类型的对话框**: 单个删除使用 warning批量删除使用 error
- **成功/失败消息提示**: 使用 Naive UI 的 message 组件
- **按钮状态管理**: 批量删除按钮在未选中时禁用
- **实时更新选中数量显示**: 动态显示选中的试卷数量
- **异步操作处理**: 在对话框确认后才执行删除操作
## 相关文件
- API实现: `src/api/modules/exam.ts`
- 页面实现: `src/views/teacher/ExamPages/ExamLibrary.vue`
- 使用示例: `src/api/examples/deletePaper-example.ts`

View File

@ -0,0 +1,112 @@
// 创建试卷API使用示例
import { ExamApi } from '../modules/exam'
/**
* 使
*/
export async function createPaperExample() {
try {
// 示例数据 - 匹配 /aiol/aiolPaper/add 接口
const paperData = {
title: '数学期末考试试卷',
generateMode: 0, // 0: 固定试卷组, 1: 随机抽题组卷
rules: '', // 组卷规则(随机抽题时使用)
repoId: '', // 题库ID随机抽题时使用
totalScore: 100, // 试卷总分
passScore: 60, // 及格分数
requireReview: 0 // 是否需要批阅0=不需要1=需要
}
console.log('🚀 准备创建试卷:', paperData)
// 调用API创建试卷
const response = await ExamApi.createExamPaper(paperData)
console.log('✅ 创建试卷成功:', response)
if (response.data) {
console.log('试卷ID:', response.data)
return response.data
} else {
console.warn('API返回的数据格式不正确')
return null
}
} catch (error) {
console.error('创建试卷失败:', error)
throw error
}
}
/**
* Vue组件中使用示例
*/
export function useCreatePaperInComponent() {
const createPaper = async (formData: {
title: string
type: number // 1: 固定试卷组, 2: 随机抽题组卷
totalScore: number
passScore?: number
useAIGrading?: boolean
}) => {
try {
const apiData = {
title: formData.title,
generateMode: formData.type === 1 ? 0 : 1,
rules: '', // 组卷规则
repoId: '', // 题库ID
totalScore: formData.totalScore,
passScore: formData.passScore || Math.floor(formData.totalScore * 0.6),
requireReview: formData.useAIGrading ? 1 : 0
}
const response = await ExamApi.createExamPaper(apiData)
return response.data
} catch (error) {
console.error('创建试卷失败:', error)
throw error
}
}
return {
createPaper
}
}
/**
*
*/
export const paperCreationExamples = {
// 固定试卷组示例
fixedPaper: {
title: '固定试卷组示例',
generateMode: 0,
rules: '',
repoId: '',
totalScore: 100,
passScore: 60,
requireReview: 0
},
// 随机抽题组卷示例
randomPaper: {
title: '随机抽题组卷示例',
generateMode: 1,
rules: '{"difficulty": [1, 2, 3], "types": [0, 1, 2], "count": 20}',
repoId: 'repo_123',
totalScore: 100,
passScore: 60,
requireReview: 1
},
// 需要AI批阅的试卷示例
aiGradingPaper: {
title: 'AI批阅试卷示例',
generateMode: 0,
rules: '',
repoId: '',
totalScore: 100,
passScore: 60,
requireReview: 1
}
}

View File

@ -0,0 +1,180 @@
// 删除试卷API使用示例
import { ExamApi } from '../modules/exam'
/**
* 使
*/
export async function deletePaperExample() {
try {
const paperId = '1962379646322384897' // 试卷ID
console.log('🚀 准备删除试卷:', paperId)
// 调用删除API
const response = await ExamApi.deleteExamPaper(paperId)
console.log('✅ 删除试卷成功:', response)
if (response.data) {
console.log('删除结果:', response.data)
return true
} else {
console.warn('删除操作可能失败')
return false
}
} catch (error) {
console.error('删除试卷失败:', error)
throw error
}
}
/**
* 使
*/
export async function batchDeletePapersExample() {
try {
const paperIds = [
'1962379646322384897',
'1966450638717292545',
'1966458655621877761'
] // 试卷ID数组
console.log('🚀 准备批量删除试卷:', paperIds)
// 调用批量删除API
const response = await ExamApi.batchDeleteExamPapers(paperIds)
console.log('✅ 批量删除试卷完成:', response)
if (response.data) {
const { success, failed, total, errors } = response.data
console.log('批量删除结果:', {
总数: total,
成功: success,
失败: failed,
错误: errors
})
if (failed === 0) {
console.log('✅ 所有试卷删除成功')
return { success: true, message: `成功删除 ${success} 个试卷` }
} else if (success > 0) {
console.warn('⚠️ 部分试卷删除成功')
return { success: false, message: `删除完成:成功 ${success} 个,失败 ${failed}` }
} else {
console.error('❌ 所有试卷删除失败')
return { success: false, message: `删除失败:${failed} 个试卷删除失败` }
}
} else {
console.warn('批量删除操作可能失败')
return { success: false, message: '批量删除操作失败' }
}
} catch (error) {
console.error('批量删除试卷失败:', error)
throw error
}
}
/**
* Vue组件中使用示例使 Naive UI
*/
export function useDeletePaperInComponent() {
// 注意:在实际使用中需要从 'naive-ui' 导入 useDialog 和 useMessage
// import { useDialog, useMessage } from 'naive-ui'
const deletePaper = async (paperId: string, paperName: string, dialog: any, message: any) => {
try {
// 使用 Naive UI 对话框组件
dialog.warning({
title: '确认删除',
content: `确定要删除试卷"${paperName}"吗?此操作不可撤销。`,
positiveText: '确定删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
const response = await ExamApi.deleteExamPaper(paperId)
console.log('删除试卷成功:', response)
message.success('试卷删除成功!')
return true
} catch (error) {
console.error('删除试卷失败:', error)
message.error('删除试卷失败,请重试')
throw error
}
}
})
} catch (error) {
console.error('删除试卷失败:', error)
throw error
}
}
const batchDeletePapers = async (paperIds: string[], dialog: any, message: any) => {
try {
// 使用 Naive UI 对话框组件
dialog.error({
title: '确认批量删除',
content: `确定要删除选中的 ${paperIds.length} 个试卷吗?此操作不可撤销。`,
positiveText: '确定删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
const response = await ExamApi.batchDeleteExamPapers(paperIds)
console.log('批量删除试卷成功:', response)
message.success(`成功删除 ${paperIds.length} 个试卷!`)
return true
} catch (error) {
console.error('批量删除试卷失败:', error)
message.error('批量删除试卷失败,请重试')
throw error
}
}
})
} catch (error) {
console.error('批量删除试卷失败:', error)
throw error
}
}
return {
deletePaper,
batchDeletePapers
}
}
/**
*
*/
export async function deletePaperWithErrorHandling(paperId: string) {
try {
const response = await ExamApi.deleteExamPaper(paperId)
// 检查响应状态
if (response.data && response.data === 'success') {
console.log('试卷删除成功')
return { success: true, message: '删除成功' }
} else {
console.warn('删除操作可能失败:', response)
return { success: false, message: '删除失败,请重试' }
}
} catch (error: any) {
console.error('删除试卷时发生错误:', error)
// 根据错误类型返回不同的错误信息
if (error.response) {
const status = error.response.status
if (status === 404) {
return { success: false, message: '试卷不存在' }
} else if (status === 403) {
return { success: false, message: '没有权限删除此试卷' }
} else if (status === 500) {
return { success: false, message: '服务器错误,请稍后重试' }
}
}
return { success: false, message: '删除失败,请检查网络连接' }
}
}

View File

@ -0,0 +1,73 @@
// getExamInfo 接口使用示例
import { ExamApi } from '../modules/exam'
import { useUserStore } from '@/stores/user'
/**
* 使
*/
export async function getExamInfoExample() {
try {
// 获取用户存储
const userStore = useUserStore()
// 确保用户已登录
if (!userStore.user || !userStore.user.id) {
console.error('用户未登录')
return
}
// 调用API获取考试信息
const response = await ExamApi.getExamInfo(userStore.user.id.toString())
console.log('API响应:', response)
if (response.data && Array.isArray(response.data)) {
console.log('考试信息列表:', response.data)
// 处理每个考试信息
response.data.forEach((exam, index) => {
console.log(`考试 ${index + 1}:`, {
id: exam.id,
name: exam.name,
type: exam.type,
status: exam.status,
startTime: exam.startTime,
endTime: exam.endTime,
createBy: exam.createBy,
createTime: exam.createTime
})
})
} else {
console.warn('API返回的数据格式不正确')
}
} catch (error) {
console.error('获取考试信息失败:', error)
}
}
/**
* Vue组件中使用示例
*/
export function useExamInfoInComponent() {
const userStore = useUserStore()
const loadExamInfo = async () => {
if (!userStore.user?.id) {
console.error('请先登录')
return []
}
try {
const response = await ExamApi.getExamInfo(userStore.user.id.toString())
return response.data || []
} catch (error) {
console.error('加载考试信息失败:', error)
return []
}
}
return {
loadExamInfo
}
}

View File

@ -1,5 +1,6 @@
// 考试题库相关API接口
import { ApiRequest } from '../request'
import axios from 'axios'
import type {
ApiResponse,
ApiResponseWithResult,
@ -15,6 +16,7 @@ import type {
UpdateQuestionAnswerRequest,
CreateQuestionRepoRequest,
UpdateQuestionRepoRequest,
ExamInfo,
} from '../types'
/**
@ -48,8 +50,8 @@ export class ExamApi {
*/
static async getCourseList(): Promise<ApiResponse<{ id: string; name: string }[]>> {
try {
// 调用现有的课程列表API但只返回id和name字段
const response = await ApiRequest.get<any>('/biz/course/list')
// 调用教师端课程列表API
const response = await ApiRequest.get<any>('/aiol/aiolCourse/teacher_list')
console.log('✅ 获取课程列表成功:', response)
// 处理响应数据只提取id和name
@ -164,9 +166,9 @@ export class ExamApi {
/**
*
*/
static async getQuestionsByRepo(repoId: string): Promise<ApiResponse<Question[]>> {
static async getQuestionsByRepo(repoId: string): Promise<ApiResponseWithResult<Question[]>> {
console.log('🚀 查询题库下题目:', { repoId })
const response = await ApiRequest.get<Question[]>(`/aiol/aiolRepo/questionList/${repoId}`)
const response = await ApiRequest.get<{ result: Question[] }>(`/aiol/aiolRepo/questionList/${repoId}`)
console.log('✅ 查询题库下题目成功:', response)
return response
}
@ -176,7 +178,7 @@ export class ExamApi {
*/
static async getQuestionDetail(questionId: string): Promise<ApiResponse<Question>> {
console.log('🚀 查询题目详情:', { questionId })
const response = await ApiRequest.get<Question>(`/aiol/aiolRepo/repoList/${questionId}`)
const response = await ApiRequest.get<Question>(`/aiol/aiolQuestion/queryById?id=${questionId}`)
console.log('✅ 查询题目详情成功:', response)
return response
}
@ -253,13 +255,23 @@ export class ExamApi {
*/
static async deleteQuestionOption(id: string): Promise<ApiResponse<string>> {
console.log('🚀 删除题目选项:', { id })
const response = await ApiRequest.delete<string>('/gen/questionoption/questionOption/delete', {
const response = await ApiRequest.delete<string>('/aiol/aiolQuestionOption/delete', {
params: { id }
})
console.log('✅ 删除题目选项成功:', response)
return response
}
/**
*
*/
static async getQuestionOptions(questionId: string): Promise<ApiResponse<any[]>> {
console.log('🚀 获取题目选项列表:', { questionId })
const response = await ApiRequest.get<any[]>(`/aiol/aiolQuestionOption/list?questionId=${questionId}`)
console.log('✅ 获取题目选项列表成功:', response)
return response
}
// ========== 题目答案管理 ==========
/**
@ -287,7 +299,7 @@ export class ExamApi {
*/
static async deleteQuestionAnswer(id: string): Promise<ApiResponse<string>> {
console.log('🚀 删除题目答案:', { id })
const response = await ApiRequest.delete<string>('/gen/questionanswer/questionAnswer/delete', {
const response = await ApiRequest.delete<string>('/aiol/aiolQuestionAnswer/delete', {
params: { id }
})
console.log('✅ 删除题目答案成功:', response)
@ -301,7 +313,7 @@ export class ExamApi {
*/
static async createQuestionRepo(data: CreateQuestionRepoRequest): Promise<ApiResponse<string>> {
console.log('🚀 添加题库题目关联:', data)
const response = await ApiRequest.post<string>('/gen/questionrepo/questionRepo/add', data)
const response = await ApiRequest.post<string>('/aiol/aiolQuestionRepo/add', data)
console.log('✅ 添加题库题目关联成功:', response)
return response
}
@ -311,7 +323,7 @@ export class ExamApi {
*/
static async updateQuestionRepo(data: UpdateQuestionRepoRequest): Promise<ApiResponse<string>> {
console.log('🚀 编辑题库题目关联:', data)
const response = await ApiRequest.put<string>('/gen/questionrepo/questionRepo/edit', data)
const response = await ApiRequest.put<string>('/aiol/aiolQuestionRepo/edit', data)
console.log('✅ 编辑题库题目关联成功:', response)
return response
}
@ -321,13 +333,370 @@ export class ExamApi {
*/
static async deleteQuestionRepo(id: string): Promise<ApiResponse<string>> {
console.log('🚀 删除题库题目关联:', { id })
const response = await ApiRequest.delete<string>('/gen/questionrepo/questionRepo/delete', {
const response = await ApiRequest.delete<string>('/aiol/aiolQuestionRepo/delete', {
params: { id }
})
console.log('✅ 删除题库题目关联成功:', response)
return response
}
// ========== 题目完整创建流程 ==========
/**
*
*/
static async createCompleteQuestion(data: {
repoId: string
parentId?: string
type: number
content: string
analysis?: string
difficulty: number
score: number
degree?: number
ability?: number
options?: Array<{
content: string
isCorrect: boolean
orderNo: number
}>
answers?: Array<{
answerText: string
orderNo: number
}>
}): Promise<ApiResponse<string>> {
try {
console.log('🚀 开始创建完整题目:', data)
// 1. 创建题目
const questionData = {
repoId: data.repoId,
parentId: data.parentId,
type: data.type,
content: data.content,
analysis: data.analysis || '',
difficulty: data.difficulty,
score: data.score,
degree: data.degree || 1,
ability: data.ability || 1
}
console.log('📝 步骤1: 创建题目基础信息')
const questionResponse = await this.createQuestion(questionData)
if (!questionResponse.data) {
throw new Error('创建题目失败未获取到题目ID')
}
const questionId = questionResponse.data
console.log('✅ 题目创建成功ID:', questionId)
// 确保questionId是字符串
let questionIdStr = '';
if (typeof questionId === 'string') {
questionIdStr = questionId;
} else if (questionId && typeof questionId === 'object' && (questionId as any).result) {
questionIdStr = String((questionId as any).result);
} else {
questionIdStr = String(questionId);
}
console.log('🔍 处理后的题目ID:', questionIdStr);
// 2. 创建题目选项(选择题需要)
if (data.options && data.options.length > 0) {
console.log('📝 步骤2: 创建题目选项')
for (const option of data.options) {
await this.createQuestionOption({
questionId: questionIdStr, // 使用处理后的题目ID
content: option.content,
izCorrent: option.isCorrect ? 1 : 0,
orderNo: option.orderNo
})
}
console.log('✅ 题目选项创建成功')
}
// 3. 创建题目答案(填空题、简答题需要)
if (data.answers && data.answers.length > 0) {
console.log('📝 步骤3: 创建题目答案')
for (const answer of data.answers) {
await this.createQuestionAnswer({
questionId: questionIdStr, // 使用处理后的题目ID
answerText: answer.answerText,
orderNo: answer.orderNo
})
}
console.log('✅ 题目答案创建成功')
}
// 4. 创建题库题目关联
console.log('📝 步骤4: 创建题库题目关联')
await this.createQuestionRepo({
repoId: data.repoId,
questionId: questionIdStr // 使用处理后的题目ID
})
console.log('✅ 题库题目关联创建成功')
console.log('🎉 完整题目创建成功!')
return {
code: 200,
message: '题目创建成功',
data: questionId
}
} catch (error: any) {
console.error('❌ 创建完整题目失败:', error)
throw error
}
}
// ========== 题目完整编辑流程 ==========
/**
*
*/
static async updateCompleteQuestion(data: {
questionId: string
repoId: string
parentId?: string
type: number
content: string
analysis?: string
difficulty: number
score: number
degree?: number
ability?: number
options?: Array<{
id?: string
content: string
isCorrect: boolean
orderNo: number
}>
answers?: Array<{
id?: string
answerText: string
orderNo: number
}>
}): Promise<ApiResponse<string>> {
try {
console.log('🚀 开始编辑完整题目:', data)
// 1. 更新题目基础信息
const questionData = {
id: data.questionId,
parentId: data.parentId,
type: data.type,
content: data.content,
analysis: data.analysis || '',
difficulty: data.difficulty,
score: data.score
}
console.log('📝 步骤1: 更新题目基础信息')
await this.updateQuestion(questionData)
console.log('✅ 题目基础信息更新成功')
// 2. 处理题目选项(选择题需要)
if (data.options && data.options.length > 0) {
console.log('📝 步骤2: 处理题目选项')
// 先获取现有选项
const existingOptions = await this.getQuestionOptions(data.questionId)
let existingOptionsList = []
// 处理不同格式的API响应
if (existingOptions.data) {
const data = existingOptions.data as any
if (Array.isArray(data)) {
existingOptionsList = data
} else if (data.result && Array.isArray(data.result)) {
existingOptionsList = data.result
} else if (data.result && data.result.records && Array.isArray(data.result.records)) {
existingOptionsList = data.result.records
}
}
console.log('🔍 现有选项:', existingOptionsList)
// 删除所有现有选项
const deletePromises = existingOptionsList.map(async (existingOption: any) => {
try {
await this.deleteQuestionOption(existingOption.id)
console.log(`🗑️ 删除现有选项成功: ${existingOption.id}`)
return true
} catch (error) {
console.warn(`删除选项失败: ${existingOption.id}`, error)
return false
}
})
// 等待所有删除操作完成
const deleteResults = await Promise.allSettled(deletePromises)
const successCount = deleteResults.filter(result => result.status === 'fulfilled').length
console.log(`🗑️ 删除选项结果: ${successCount}/${existingOptionsList.length} 成功`)
// 等待一小段时间确保删除操作完成
await new Promise(resolve => setTimeout(resolve, 200))
// 创建新选项
for (let i = 0; i < data.options.length; i++) {
const option = data.options[i]
await this.createQuestionOption({
questionId: data.questionId,
content: option.content,
izCorrent: option.isCorrect ? 1 : 0,
orderNo: i // 确保orderNo从0开始连续
})
}
console.log('✅ 题目选项更新成功')
}
// 3. 处理题目答案(填空题、简答题需要)
if (data.answers && data.answers.length > 0) {
console.log('📝 步骤3: 处理题目答案')
// 先获取现有答案
const existingAnswers = await this.getQuestionAnswers(data.questionId)
let existingAnswersList = []
// 处理不同格式的API响应
if (existingAnswers.data) {
const data = existingAnswers.data as any
if (Array.isArray(data)) {
existingAnswersList = data
} else if (data.result && Array.isArray(data.result)) {
existingAnswersList = data.result
} else if (data.result && data.result.records && Array.isArray(data.result.records)) {
existingAnswersList = data.result.records
}
}
console.log('🔍 现有答案:', existingAnswersList)
// 删除所有现有答案
const deleteAnswerPromises = existingAnswersList.map(async (existingAnswer: any) => {
try {
await this.deleteQuestionAnswer(existingAnswer.id)
console.log(`🗑️ 删除现有答案成功: ${existingAnswer.id}`)
return true
} catch (error) {
console.warn(`删除答案失败: ${existingAnswer.id}`, error)
return false
}
})
// 等待所有删除操作完成
const deleteAnswerResults = await Promise.allSettled(deleteAnswerPromises)
const successAnswerCount = deleteAnswerResults.filter(result => result.status === 'fulfilled').length
console.log(`🗑️ 删除答案结果: ${successAnswerCount}/${existingAnswersList.length} 成功`)
// 等待一小段时间确保删除操作完成
await new Promise(resolve => setTimeout(resolve, 200))
// 创建新答案
for (let i = 0; i < data.answers.length; i++) {
const answer = data.answers[i]
await this.createQuestionAnswer({
questionId: data.questionId,
answerText: answer.answerText,
orderNo: i // 确保orderNo从0开始连续
})
}
console.log('✅ 题目答案更新成功')
}
console.log('🎉 完整题目编辑成功!')
return {
code: 200,
message: '题目编辑成功',
data: data.questionId
}
} catch (error: any) {
console.error('❌ 编辑完整题目失败:', error)
throw error
}
}
/**
*
*/
static async getQuestionAnswers(questionId: string): Promise<ApiResponse<any[]>> {
console.log('🚀 获取题目答案列表:', { questionId })
const response = await ApiRequest.get<any[]>(`/aiol/aiolQuestionAnswer/list?questionId=${questionId}`)
console.log('✅ 获取题目答案列表成功:', response)
return response
}
// ========== 分类和难度管理 ==========
/**
*
*/
static async getQuestionCategories(): Promise<ApiResponse<Array<{id: string, name: string}>>> {
try {
console.log('🚀 获取题目分类列表')
const response = await ApiRequest.get<any>('/aiol/aiolCourse/category/list')
console.log('✅ 获取题目分类列表成功:', response)
if (response.data && response.data.success && response.data.result) {
const categories = response.data.result.map((item: any) => ({
id: String(item.id),
name: item.name || '未命名分类'
}))
return {
code: 200,
message: '获取成功',
data: categories
}
}
return {
code: 200,
message: '获取成功',
data: []
}
} catch (error) {
console.error('❌ 获取题目分类失败:', error)
throw error
}
}
/**
*
*/
static async getQuestionDifficulties(): Promise<ApiResponse<Array<{id: string, name: string}>>> {
try {
console.log('🚀 获取题目难度列表')
const response = await ApiRequest.get<any>('/aiol/aiolCourse/difficulty/list')
console.log('✅ 获取题目难度列表成功:', response)
if (response.data && response.data.success && response.data.result) {
const difficulties = response.data.result.map((item: any) => ({
id: String(item.value || '0'),
name: item.label || '未知难度'
}))
return {
code: 200,
message: '获取成功',
data: difficulties
}
}
return {
code: 200,
message: '获取成功',
data: []
}
} catch (error) {
console.error('❌ 获取题目难度失败:', error)
throw error
}
}
// ========== 常用工具方法 ==========
/**
@ -357,6 +726,166 @@ export class ExamApi {
return difficultyMap[difficulty] || '未知难度'
}
/**
* Excel文件
*/
static async importQuestions(repoId: string, formData: FormData): Promise<ApiResponse<string>> {
console.log('🚀 导入题目Excel文件:', { repoId })
try {
const response = await ApiRequest.post<string>(`/aiol/aiolRepo/importXls?repoId=${repoId}`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
console.log('✅ 导入题目Excel文件成功:', response)
return response
} catch (error: any) {
console.error('❌ 导入题目Excel文件失败:', error)
// 处理服务器返回的错误信息
if (error.response && error.response.data) {
const errorData = error.response.data
if (errorData.message) {
throw new Error(errorData.message)
}
}
// 处理网络错误
if (error.message) {
throw new Error(error.message)
}
throw new Error('导入失败,请检查网络连接')
}
}
/**
*
*/
static async downloadTemplate(): Promise<Blob> {
try {
console.log('🚀 下载题库导入模板')
// 直接使用axios请求避免响应拦截器的干扰
const baseURL = import.meta.env.VITE_API_BASE_URL || '/jeecgboot'
const token = localStorage.getItem('X-Access-Token') || ''
const response = await axios.get(`${baseURL}/aiol/aiolRepo/exportXls?repoId=template`, {
responseType: 'blob',
headers: {
'X-Access-Token': token,
'X-Request-Time': Date.now().toString()
}
})
console.log('✅ 下载题库导入模板成功:', response)
// 检查响应数据
if (response.data instanceof Blob) {
return response.data
}
// 如果数据是ArrayBuffer转换为Blob
if (response.data instanceof ArrayBuffer) {
return new Blob([response.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
}
// 如果数据是字符串尝试base64解码
if (typeof response.data === 'string') {
try {
const binaryString = atob(response.data)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
} catch (error) {
console.error('base64解码失败:', error)
throw new Error('文件数据格式不正确')
}
}
console.error('无法解析的数据类型:', {
type: typeof response.data,
constructor: response.data?.constructor?.name,
data: response.data
})
throw new Error('无法解析服务器返回的文件数据')
} catch (error: any) {
console.error('下载题库导入模板失败:', error)
throw error
}
}
/**
* Excel文件
*/
static async exportQuestions(repoId: string): Promise<Blob> {
console.log('🚀 导出题目Excel文件:', { repoId })
// 直接使用axios请求避免响应拦截器的干扰
const baseURL = import.meta.env.VITE_API_BASE_URL || '/jeecgboot'
const token = localStorage.getItem('X-Access-Token') || ''
const response = await axios.get(`${baseURL}/aiol/aiolRepo/exportXls?repoId=${repoId}`, {
responseType: 'blob',
headers: {
'X-Access-Token': token,
'X-Request-Time': Date.now().toString()
}
})
console.log('✅ 导出题目Excel文件成功:', response)
// 检查响应数据
if (response.data instanceof Blob) {
return response.data
}
// 如果数据是ArrayBuffer转换为Blob
if (response.data instanceof ArrayBuffer) {
return new Blob([response.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
}
// 如果数据是字符串尝试base64解码
if (typeof response.data === 'string') {
try {
const binaryString = atob(response.data)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
} catch (error) {
console.error('base64解码失败:', error)
throw new Error('文件数据格式不正确')
}
}
console.error('无法解析的数据类型:', {
type: typeof response.data,
constructor: response.data?.constructor?.name,
data: response.data
})
throw new Error('无法解析服务器返回的文件数据')
} catch (error: any) {
console.error('导出题目Excel文件失败:', error)
if (error.response) {
// 服务器返回了错误响应
console.error('服务器错误:', error.response.status, error.response.data)
throw new Error(`服务器错误: ${error.response.status} - ${error.response.data?.message || '未知错误'}`)
} else if (error.request) {
// 请求已发出但没有收到响应
console.error('网络错误:', error.request)
throw new Error('网络连接失败,请检查网络连接')
} else {
// 其他错误
console.error('请求配置错误:', error.message)
throw new Error(`请求失败: ${error.message}`)
}
}
/**
*
*/
@ -395,6 +924,16 @@ export class ExamApi {
// ========== 试卷管理相关接口 ==========
/**
*
*/
static async getExamInfo(userId: string): Promise<ApiResponse<ExamInfo[]>> {
console.log('🚀 获取教师名下的考试信息:', { userId })
const response = await ApiRequest.get<ExamInfo[]>(`/aiol/aiolExam/getExamInfo?userId=${userId}`)
console.log('✅ 获取教师名下的考试信息成功:', response)
return response
}
/**
*
*/
@ -430,27 +969,132 @@ export class ExamApi {
/**
*
*/
static async getExamPaperDetail(id: string): Promise<ApiResponse<any>> {
static async getExamPaperDetail(id: string): Promise<ApiResponse<{
success: boolean
message: string
result: {
id: string
title: string
generateMode: number
rules: string
repoId: string
totalScore: number
passScore: number
requireReview: number
createBy: string
createTime: string
updateBy: string
updateTime: string
}
}>> {
console.log('🚀 获取试卷详情:', id)
const response = await ApiRequest.get<any>(`/aiol/aiolExam/paperDetail/${id}`)
const response = await ApiRequest.get<{
success: boolean
message: string
result: {
id: string
title: string
generateMode: number
rules: string
repoId: string
totalScore: number
passScore: number
requireReview: number
createBy: string
createTime: string
updateBy: string
updateTime: string
}
}>(`/aiol/aiolPaper/queryById`, { id })
console.log('✅ 获取试卷详情成功:', response)
return response
}
/**
*
*/
static async getExamPaperQuestions(paperId: string): Promise<ApiResponse<{
success: boolean
message: string
result: any[]
}>> {
console.log('🚀 获取试卷题目列表:', paperId)
const response = await ApiRequest.get<{
success: boolean
message: string
result: any[]
}>(`/aiol/aiolPaperQuestion/list`, { paperId })
console.log('✅ 获取试卷题目列表成功:', response)
return response
}
/**
*
*/
static async addExamPaperQuestion(data: {
paperId: string
questionId: string
orderNo: number
score: number
}): Promise<ApiResponse<string>> {
console.log('🚀 添加试卷题目:', data)
const response = await ApiRequest.post<string>(`/aiol/aiolPaperQuestion/add`, data)
console.log('✅ 添加试卷题目成功:', response)
return response
}
/**
*
*/
static async updateExamPaperQuestion(data: {
id: string
paperId: string
questionId: string
orderNo: number
score: number
}): Promise<ApiResponse<string>> {
console.log('🚀 编辑试卷题目:', data)
const response = await ApiRequest.put<string>(`/aiol/aiolPaperQuestion/edit`, data)
console.log('✅ 编辑试卷题目成功:', response)
return response
}
/**
*
*/
static async deleteExamPaperQuestion(id: string): Promise<ApiResponse<string>> {
console.log('🚀 删除试卷题目:', id)
const response = await ApiRequest.delete<string>(`/aiol/aiolPaperQuestion/delete`, { id })
console.log('✅ 删除试卷题目成功:', response)
return response
}
/**
*
*/
static async createExamPaper(data: {
name: string
category: string
description?: string
title: string
generateMode?: number
rules?: string
repoId?: string
totalScore: number
difficulty: string
duration: number
questions: any[]
passScore?: number
requireReview?: number
}): Promise<ApiResponse<string>> {
console.log('🚀 创建试卷:', data)
const response = await ApiRequest.post<string>('/aiol/aiolPaper/add', data)
// 构建API请求数据确保字段名匹配后端接口
const apiData = {
title: data.title,
generateMode: data.generateMode || 0, // 默认固定试卷组
rules: data.rules || '', // 组卷规则
repoId: data.repoId || '', // 题库ID
totalScore: data.totalScore,
passScore: data.passScore || Math.floor(data.totalScore * 0.6), // 默认及格分为总分的60%
requireReview: data.requireReview || 0 // 默认不需要批阅
}
const response = await ApiRequest.post<string>('/aiol/aiolPaper/add', apiData)
console.log('✅ 创建试卷成功:', response)
return response
}
@ -459,16 +1103,33 @@ export class ExamApi {
*
*/
static async updateExamPaper(id: string, data: {
name?: string
category?: string
title: string
generateMode?: number
rules?: string
repoId?: string
totalScore: number
passScore?: number
requireReview?: number
description?: string
totalScore?: number
difficulty?: string
duration?: number
questions?: any[]
instructions?: string
useAIGrading?: boolean
}): Promise<ApiResponse<string>> {
console.log('🚀 更新试卷:', { id, data })
const response = await ApiRequest.put<string>(`/aiol/aiolExam/paperUpdate/${id}`, data)
// 准备API数据 - 匹配后端接口格式
const apiData = {
id: id,
title: data.title,
generateMode: data.generateMode || 0, // 0: 固定试卷组, 1: 随机抽题组卷
rules: data.rules || '', // 组卷规则
repoId: data.repoId || '', // 题库ID
totalScore: data.totalScore,
passScore: data.passScore || Math.floor(data.totalScore * 0.6), // 及格分
requireReview: data.requireReview || (data.useAIGrading ? 1 : 0) // 是否需要批阅
}
const response = await ApiRequest.put<string>(`/aiol/aiolPaper/edit`, apiData)
console.log('✅ 更新试卷成功:', response)
return response
}
@ -478,19 +1139,64 @@ export class ExamApi {
*/
static async deleteExamPaper(id: string): Promise<ApiResponse<string>> {
console.log('🚀 删除试卷:', id)
const response = await ApiRequest.delete<string>(`/aiol/aiolExam/paperDelete/${id}`)
const response = await ApiRequest.delete<string>(`/aiol/aiolPaper/delete?id=${id}`)
console.log('✅ 删除试卷成功:', response)
return response
}
/**
*
*
*/
static async batchDeleteExamPapers(ids: string[]): Promise<ApiResponse<string>> {
static async batchDeleteExamPapers(ids: string[]): Promise<ApiResponse<{
success: number
failed: number
total: number
errors: string[]
}>> {
console.log('🚀 批量删除试卷:', ids)
const response = await ApiRequest.post<string>('/aiol/aiolExam/paperBatchDelete', { ids })
console.log('✅ 批量删除试卷成功:', response)
return response
const results = {
success: 0,
failed: 0,
total: ids.length,
errors: [] as string[]
}
try {
// 逐个删除,而不是并行删除,避免对服务器造成过大压力
for (let i = 0; i < ids.length; i++) {
const id = ids[i]
try {
console.log(`🗑️ 正在删除第 ${i + 1}/${ids.length} 个试卷:`, id)
await this.deleteExamPaper(id)
results.success++
console.log(`✅ 第 ${i + 1} 个试卷删除成功`)
} catch (error) {
results.failed++
const errorMsg = `试卷 ${id} 删除失败: ${error}`
results.errors.push(errorMsg)
console.error(`❌ 第 ${i + 1} 个试卷删除失败:`, error)
}
}
console.log('✅ 批量删除完成:', results)
// 返回结果
return {
data: results,
success: results.failed === 0,
message: results.failed === 0
? `成功删除 ${results.success} 个试卷`
: `删除完成,成功 ${results.success} 个,失败 ${results.failed}`,
code: results.failed === 0 ? 200 : 207, // 207 表示部分成功
timestamp: Date.now().toString()
} as ApiResponse<typeof results>
} catch (error) {
console.error('❌ 批量删除试卷失败:', error)
throw error
}
}
/**

View File

@ -94,6 +94,11 @@ request.interceptors.response.use(
})
}
// 如果是blob响应直接返回
if (response.config.responseType === 'blob') {
return response
}
// 处理不同的响应格式
let normalizedData: ApiResponse

View File

@ -850,3 +850,43 @@ export interface UpdateQuestionRepoRequest {
repoId: string
questionId: string
}
// 考试信息类型
export interface ExamInfo {
id: string
name: string
paperId: string
startTime: string
endTime: string
totalTime: number
type: number
status: number
createBy: string
createTime: string
updateBy: string
updateTime: string
// 扩展字段,用于试卷管理页面显示
category?: string
questionCount?: number
chapter?: string
totalScore?: number
difficulty?: string
creator?: string
creationTime?: string
}
// 试卷信息类型
export interface PaperInfo {
id: string
title: string
generateMode: number
rules: string
repoId: string
totalScore: number
passScore: number
requireReview: number
createBy: string
createTime: string
updateBy: string
updateTime: string
}

View File

@ -24,7 +24,8 @@
<!-- 考试人数限制 -->
<div class="setting-row">
<label class="setting-label">考试人数</label>
<n-input v-model:value="formData.maxParticipants" type="number" :min="1" placeholder="请输入考试人数上限" class="setting-input" />
<n-input v-model:value="formData.maxParticipants" :min="1" placeholder="请输入考试人数上限"
class="setting-input" />
</div>
<!-- 试卷分类 -->
<div class="setting-row">
@ -206,9 +207,12 @@
<div class="advanced-setting">
<label class="advanced-label">详分设置</label>
<n-radio-group v-model:value="formData.detailScoreMode" class="detail-score-group">
<n-radio value="question">填空题简答题题目设为为主观题 <span class="tip">设为主观题后需教师手动批阅</span> </n-radio>
<n-radio value="automatic">填空题简答题不区分大小写 <span class="tip">勾选后英文大写和小写都可以得分</span> </n-radio>
<n-radio value="show_current">填空题简答题忽略符号 <span class="tip">勾选后答案内符号与标准答案不同也给分</span> </n-radio>
<n-radio value="question">填空题简答题题目设为为主观题 <span class="tip">设为主观题后需教师手动批阅</span>
</n-radio>
<n-radio value="automatic">填空题简答题不区分大小写 <span class="tip">勾选后英文大写和小写都可以得分</span>
</n-radio>
<n-radio value="show_current">填空题简答题忽略符号 <span class="tip">勾选后答案内符号与标准答案不同也给分</span>
</n-radio>
<n-radio value="show_all">多选题未全选对时得一半分 <span class="tip">不勾选时全选对才给分</span> </n-radio>
</n-radio-group>
</div>
@ -274,7 +278,19 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { createDiscreteApi } from 'naive-ui';
import {
createDiscreteApi,
NModal,
NInput,
NDatePicker,
NSelect,
NRadioGroup,
NRadio,
NCheckbox,
NButton,
NDivider,
NSwitch
} from 'naive-ui';
// message API
const { message } = createDiscreteApi(['message']);
@ -286,12 +302,12 @@ interface ExamSettings {
endTime: number | null;
category: 'exam' | 'practice';
timeLimit: 'unlimited' | 'limited' | 'no_limit';
timeLimitValue: number;
timeLimitValue: string;
examTimes: 'unlimited' | 'limited' | 'each_day';
examTimesValue: number;
examTimesValue: string;
dailyLimit: number;
chapter: string;
passScore: number;
passScore: string;
participants: 'all' | 'by_school';
selectedClasses: string[];
instructions: string;
@ -299,11 +315,11 @@ interface ExamSettings {
//
enforceOrder: boolean;
enforceInstructions: boolean;
readingTime: number;
readingTime: string;
submitSettings: {
allowEarlySubmit: boolean;
};
gradingDelay: number;
gradingDelay: string;
scoreDisplay: 'show_all' | 'show_score' | 'hide_all';
detailedSettings: {
showQuestions: boolean;
@ -311,15 +327,15 @@ interface ExamSettings {
showSubmissionTime: boolean;
};
timerEnabled: boolean;
timerDuration: number;
timerDuration: string;
answerType: 'auto_save' | 'manual_save' | 'multiple_submit';
detailScoreMode: 'question' | 'automatic' | 'show_current' | 'show_all';
showRanking: boolean; //
courseProgress: number; //
courseProgress: string; //
//
correctnessMode: 'no_limit' | 'limit_wrong';
wrongLimit: number;
wrongLimit: string;
practiceSettings: {
showCorrectAnswer: boolean;
showWrongAnswer: boolean;
@ -329,7 +345,7 @@ interface ExamSettings {
};
paperMode: 'show_all' | 'show_current' | 'hide_all';
//
maxParticipants: number | null;
maxParticipants: string | null;
}
// Props
@ -361,12 +377,12 @@ const formData = ref<ExamSettings>({
endTime: null,
category: 'exam',
timeLimit: 'limited',
timeLimitValue: 0,
timeLimitValue: '0',
examTimes: 'unlimited',
examTimesValue: 1,
examTimesValue: '1',
dailyLimit: 1,
chapter: '',
passScore: 60,
passScore: '60',
participants: 'all',
selectedClasses: [],
instructions: '',
@ -374,11 +390,11 @@ const formData = ref<ExamSettings>({
//
enforceOrder: false,
enforceInstructions: false,
readingTime: 10,
readingTime: '10',
submitSettings: {
allowEarlySubmit: true,
},
gradingDelay: 60,
gradingDelay: '60',
scoreDisplay: 'show_all',
detailedSettings: {
showQuestions: false,
@ -386,15 +402,15 @@ const formData = ref<ExamSettings>({
showSubmissionTime: false,
},
timerEnabled: false,
timerDuration: 10,
timerDuration: '10',
answerType: 'auto_save',
detailScoreMode: 'question',
showRanking: false,
courseProgress: 0,
courseProgress: '0',
//
correctnessMode: 'no_limit',
wrongLimit: 10,
wrongLimit: '10',
practiceSettings: {
showCorrectAnswer: false,
showWrongAnswer: false,

View File

@ -1,10 +1,5 @@
<template>
<n-modal
v-model:show="showModal"
class="question-bank-modal"
preset="card"
:mask-closable="false"
:closable="false"
<n-modal v-model:show="showModal" class="question-bank-modal" preset="card" :mask-closable="false" :closable="false"
:style="{ width: '1200px' }">
<div class="header">
@ -16,35 +11,52 @@
<!-- 筛选条件 -->
<div class="filter-section">
<div class="filter-row">
<div class="filter-item">
<label>选择题库</label>
<n-select v-model:value="selectedRepo" placeholder="全部题库" :options="repoOptions"
style="width: 200px" @update:value="handleRepoChange" />
</div>
<div class="filter-item">
<label>试题分类</label>
<n-select
v-model:value="filters.category"
placeholder="全部"
:options="categoryOptions"
style="width: 150px"
/>
<n-select v-model:value="filters.category" placeholder="全部" :options="categoryOptions"
style="width: 150px" @update:value="handleFilterChange" />
</div>
<div class="filter-item">
<label>试题难度</label>
<n-select
v-model:value="filters.difficulty"
placeholder="全部"
:options="difficultyOptions"
style="width: 150px"
/>
<n-select v-model:value="filters.difficulty" placeholder="全部" :options="difficultyOptions"
style="width: 150px" @update:value="handleFilterChange" />
</div>
<div class="filter-item">
<label>试题题型</label>
<n-select
v-model:value="filters.type"
placeholder="全部"
:options="typeOptions"
style="width: 150px"
/>
<n-select v-model:value="filters.type" placeholder="全部" :options="typeOptions"
style="width: 150px" @update:value="handleFilterChange" />
</div>
<div class="filter-actions">
<span class="tip">已全部加载{{ pagination.itemCount }}试题</span>
<span class="tip">
{{ selectedRepo ? `已加载题库题目,共${pagination.itemCount}试题` : '请先选择题库' }}
</span>
<n-button type="default" @click="resetFilters" style="margin-right: 8px;">
<template #icon>
<n-icon>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" />
</svg>
</n-icon>
</template>
重置筛选
</n-button>
<n-button type="default" @click="exportQuestions" style="margin-right: 8px;">
<template #icon>
<n-icon>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z" />
</svg>
</n-icon>
</template>
导出模板
</n-button>
<n-button type="primary" @click="addNewQuestion">
<template #icon>
<n-icon>
@ -53,23 +65,60 @@
</template>
导入试题
</n-button>
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-button type="info" quaternary circle size="small" style="margin-left: 8px;">
<template #icon>
<n-icon>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z" />
</svg>
</n-icon>
</template>
</n-button>
</template>
<div style="max-width: 300px; line-height: 1.5;">
<div style="font-weight: bold; margin-bottom: 8px;">Excel导入格式说明</div>
<div> 必填字段题目内容正确答案</div>
<div> 正确答案格式ABC或A,B,C</div>
<div> 支持题型单选题多选题判断题</div>
<div> 文件格式.xlsx或.xls</div>
<div> 文件大小不超过10MB</div>
</div>
</n-tooltip>
</div>
</div>
</div>
<!-- 题目列表 -->
<div class="question-list-section">
<n-data-table
ref="tableRef"
:columns="columns"
:data="questionList"
:pagination="pagination"
:loading="loading"
:row-key="(row: any) => row.id"
:checked-row-keys="selectedRowKeys"
@update:checked-row-keys="handleCheck"
striped
/>
<n-data-table ref="tableRef" :columns="columns" :data="questionList" :pagination="pagination"
:loading="loading" :row-key="(row: any) => row.id" :checked-row-keys="selectedRowKeys"
@update:checked-row-keys="handleCheck" striped>
<template #empty>
<div class="empty-state">
<div v-if="!selectedRepo" class="empty-tip">
<n-icon size="48" color="#ccc">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
</n-icon>
<p>请先选择题库查看题目</p>
</div>
<div v-else class="empty-tip">
<n-icon size="48" color="#ccc">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
</n-icon>
<p>该题库暂无题目</p>
</div>
</div>
</template>
</n-data-table>
</div>
<!-- 已选择题目统计 -->
@ -92,11 +141,14 @@
import { ref, computed, watch, onMounted } from 'vue';
import { createDiscreteApi } from 'naive-ui';
import { Add } from '@vicons/ionicons5';
import { ExamApi } from '@/api/modules/exam';
import { CourseApi } from '@/api/modules/course';
import type { Repo, Question, CourseCategory, CourseDifficulty } from '@/api/types';
// message API
const { message } = createDiscreteApi(['message']);
//
//
interface QuestionItem {
id: string;
number: number;
@ -109,6 +161,8 @@ interface QuestionItem {
createTime: string;
//
content?: any;
//
originalData?: Question;
}
// Props
@ -130,7 +184,7 @@ const emit = defineEmits<Emits>();
//
const showModal = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
set: (value: boolean) => emit('update:visible', value)
});
//
@ -138,62 +192,125 @@ const filters = ref({
category: '',
difficulty: '',
type: props.questionType || '',
keyword: ''
keyword: '',
repoId: '' //
});
//
const repoList = ref<Repo[]>([]);
const selectedRepo = ref<string>('');
//
const categoryOptions = ref([
{ label: '全部', value: '' },
{ label: '计算机基础', value: 'computer' },
{ label: '数学', value: 'math' },
{ label: '英语', value: 'english' }
{ label: '全部', value: '' }
]);
//
const categoryList = ref<CourseCategory[]>([]);
//
const repoOptions = computed(() => [
{ label: '全部题库', value: '' },
...repoList.value.map((repo: Repo) => ({
label: `${repo.title} (${repo.questionCount || 0}题)`,
value: repo.id
}))
]);
const difficultyOptions = ref([
{ label: '全部', value: '' },
{ label: '易', value: 'easy' },
{ label: '中', value: 'medium' },
{ label: '难', value: 'hard' }
{ label: '简单', value: '1' },
{ label: '中等', value: '2' },
{ label: '难', value: '3' }
]);
const typeOptions = ref([
{ label: '全部', value: '' },
{ label: '单选题', value: 'single_choice' },
{ label: '多选题', value: 'multiple_choice' },
{ label: '判断题', value: 'true_false' },
{ label: '填空题', value: 'fill_blank' },
{ label: '简答题', value: 'short_answer' }
{ label: '单选题', value: '0' },
{ label: '多选题', value: '1' },
{ label: '判断题', value: '2' },
{ label: '填空题', value: '3' },
{ label: '简答题', value: '4' },
{ label: '复合题', value: '5' }
]);
//
const loading = ref(false);
const selectedRowKeys = ref<string[]>([]);
// ()
//
const questionList = ref<QuestionItem[]>([]);
const allQuestions = ref<QuestionItem[]>([]); //
//
const filteredQuestions = computed(() => {
let filtered = allQuestions.value;
//
if (filters.value.category) {
filtered = filtered.filter(q => q.category === filters.value.category);
}
//
if (filters.value.difficulty) {
const difficultyMap: { [key: string]: string } = {
'1': '简单',
'2': '中等',
'3': '困难'
};
const targetDifficulty = difficultyMap[filters.value.difficulty];
filtered = filtered.filter(q => q.difficulty === targetDifficulty);
}
//
if (filters.value.type) {
const typeMap: { [key: string]: string } = {
'0': '单选题',
'1': '多选题',
'2': '判断题',
'3': '填空题',
'4': '简答题',
'5': '复合题'
};
const targetType = typeMap[filters.value.type];
filtered = filtered.filter(q => q.type === targetType);
}
//
if (filters.value.keyword) {
const keyword = filters.value.keyword.toLowerCase();
filtered = filtered.filter(q =>
q.title.toLowerCase().includes(keyword) ||
q.creator.toLowerCase().includes(keyword)
);
}
return filtered;
});
//
const generateMockData = () => {
const mockData: QuestionItem[] = [];
const types = ['单选题', '多选题', '判断题', '填空题', '简答题'];
const difficulties = ['易', '中', '难'];
const categories = ['计算机基础', '数学', '英语'];
for (let i = 1; i <= 15; i++) {
mockData.push({
id: `question_${i}`,
number: i,
title: `在数据库的三级模式结构中,内模式有...`,
type: types[Math.floor(Math.random() * types.length)],
difficulty: difficulties[Math.floor(Math.random() * difficulties.length)],
category: categories[Math.floor(Math.random() * categories.length)],
score: 10,
creator: '王建国',
createTime: '2025.08.20 09:20'
});
}
return mockData;
};
// 使
// const generateMockData = () => {
// const mockData: QuestionItem[] = [];
// const types = ['', '', '', '', ''];
// const difficulties = ['', '', ''];
// const categories = ['', '', ''];
//
// for (let i = 1; i <= 15; i++) {
// mockData.push({
// id: `question_${i}`,
// number: i,
// title: `...`,
// type: types[Math.floor(Math.random() * types.length)],
// difficulty: difficulties[Math.floor(Math.random() * difficulties.length)],
// category: categories[Math.floor(Math.random() * categories.length)],
// score: 10,
// creator: '',
// createTime: '2025.08.20 09:20'
// });
// }
// return mockData;
// };
//
const columns = [
@ -259,16 +376,18 @@ const pagination = ref({
showSizePicker: true,
pageSizes: [10, 20, 50],
itemCount: 0,
showQuickJumper: true,
displayOrder: ['size-picker', 'pages', 'quick-jumper'],
onChange: (page: number) => {
pagination.value.page = page;
loadQuestions();
updateCurrentPageQuestions();
},
onUpdatePageSize: (pageSize: number) => {
pagination.value.pageSize = pageSize;
pagination.value.page = 1;
loadQuestions();
updateCurrentPageQuestions();
},
prefix: ({ itemCount }: { itemCount: number }) => `${itemCount}`
prefix: ({ itemCount }: { itemCount: number }) => `${itemCount}`
});
//
@ -276,24 +395,317 @@ const handleCheck = (rowKeys: string[]) => {
selectedRowKeys.value = rowKeys;
};
//
const updateCurrentPageQuestions = () => {
const filtered = filteredQuestions.value;
const startIndex = (pagination.value.page - 1) * pagination.value.pageSize;
const endIndex = startIndex + pagination.value.pageSize;
questionList.value = filtered.slice(startIndex, endIndex);
console.log('📄 更新当前页题目 - 页码:', pagination.value.page, '每页:', pagination.value.pageSize, '显示题目数:', questionList.value.length);
};
//
const handleRepoChange = (repoId: string) => {
selectedRepo.value = repoId;
filters.value.repoId = repoId;
loadQuestions();
};
//
const handleFilterChange = () => {
const filtered = filteredQuestions.value;
pagination.value.itemCount = filtered.length;
pagination.value.page = 1; //
//
const startIndex = (pagination.value.page - 1) * pagination.value.pageSize;
const endIndex = startIndex + pagination.value.pageSize;
questionList.value = filtered.slice(startIndex, endIndex);
console.log('📊 筛选后分页器更新 - 题目总数:', pagination.value.itemCount);
};
//
const resetFilters = () => {
filters.value = {
category: '',
difficulty: '',
type: props.questionType || '',
keyword: '',
repoId: selectedRepo.value
};
handleFilterChange();
};
//
const loadCategories = async () => {
try {
const response = await CourseApi.getCategories();
if (response.data && response.data.length > 0) {
categoryList.value = response.data;
categoryOptions.value = [
{ label: '全部', value: '' },
...response.data.map((category: CourseCategory) => ({
label: category.name,
value: category.name
}))
];
console.log('✅ 加载分类选项成功:', categoryOptions.value);
}
} catch (error) {
console.error('加载分类选项失败:', error);
// 使
categoryOptions.value = [
{ label: '全部', value: '' },
{ label: '计算机基础', value: '计算机基础' },
{ label: '数学', value: '数学' },
{ label: '英语', value: '英语' }
];
}
};
//
const loadDifficulties = async () => {
try {
const response = await CourseApi.getDifficulties();
if (response.data && response.data.length > 0) {
difficultyOptions.value = [
{ label: '全部', value: '' },
...response.data.map((difficulty: CourseDifficulty) => ({
label: difficulty.name,
value: difficulty.id
}))
];
console.log('✅ 加载难度选项成功:', difficultyOptions.value);
}
} catch (error) {
console.error('加载难度选项失败:', error);
// 使
difficultyOptions.value = [
{ label: '全部', value: '' },
{ label: '简单', value: '1' },
{ label: '中等', value: '2' },
{ label: '困难', value: '3' }
];
}
};
//
const loadRepos = async () => {
try {
const response = await ExamApi.getCourseRepoList();
if (response.data && response.data.result) {
repoList.value = response.data.result;
console.log('✅ 加载题库列表成功:', repoList.value);
//
if (!selectedRepo.value && repoList.value.length > 0) {
selectedRepo.value = repoList.value[0].id;
console.log('🔄 自动选择第一个题库:', selectedRepo.value);
//
await loadQuestions();
}
}
} catch (error) {
console.error('加载题库列表失败:', error);
message.error('加载题库列表失败');
}
};
//
const loadQuestions = async () => {
loading.value = true;
try {
// API
await new Promise(resolve => setTimeout(resolve, 500));
questionList.value = generateMockData();
pagination.value.itemCount = questionList.value.length;
if (selectedRepo.value) {
//
console.log('🔍 正在加载题库题目:', selectedRepo.value);
const response = await ExamApi.getQuestionsByRepo(selectedRepo.value);
console.log('✅ 题库题目响应:', response);
if (response.data && response.data.result && response.data.result.length > 0) {
const questions = response.data.result.map((q: Question, index: number) => ({
id: q.id,
number: index + 1,
title: q.content || '无标题',
type: ExamApi.getQuestionTypeText(q.type || 0),
difficulty: ExamApi.getDifficultyText(q.difficulty || 1),
category: '计算机基础', //
score: q.score || 5,
creator: q.createBy || '未知',
createTime: q.createTime ? new Date(q.createTime).toLocaleString() : '未知时间',
originalData: q
}));
allQuestions.value = questions;
//
const filtered = filteredQuestions.value;
pagination.value.itemCount = filtered.length;
//
const startIndex = (pagination.value.page - 1) * pagination.value.pageSize;
const endIndex = startIndex + pagination.value.pageSize;
questionList.value = filtered.slice(startIndex, endIndex);
console.log('✅ 题目列表加载成功:', questionList.value);
console.log('📊 筛选后题目总数:', filtered.length);
} else {
//
allQuestions.value = [];
questionList.value = [];
console.log('⚠️ 该题库暂无题目');
}
} else {
//
questionList.value = [];
console.log(' 请先选择题库');
}
console.log('📊 分页器更新 - 题目总数:', pagination.value.itemCount);
} catch (error) {
console.error('加载题目失败:', error);
message.error('加载题目失败');
questionList.value = [];
} finally {
loading.value = false;
}
};
//
//
const addNewQuestion = () => {
message.info('导入试题功能待开发');
//
const input = document.createElement('input');
input.type = 'file';
input.accept = '.xlsx,.xls';
input.style.display = 'none';
input.onchange = async (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
if (!selectedRepo.value) {
message.error('请先选择题库');
return;
}
//
const allowedTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/vnd.ms-excel' // .xls
];
if (!allowedTypes.includes(file.type)) {
message.error('请选择Excel文件(.xlsx或.xls格式)');
return;
}
// (10MB)
if (file.size > 10 * 1024 * 1024) {
message.error('文件大小不能超过10MB');
return;
}
try {
loading.value = true;
message.loading('正在导入试题,请稍候...', { duration: 0 });
const formData = new FormData();
formData.append('file', file);
const response = await ExamApi.importQuestions(selectedRepo.value, formData);
if (response.data && typeof response.data === 'object' && 'success' in response.data && (response.data as any).success) {
message.destroyAll();
message.success('试题导入成功!');
//
await loadQuestions();
} else {
message.destroyAll();
message.error('导入失败');
}
} catch (error: any) {
message.destroyAll();
console.error('导入试题失败:', error);
//
let errorMessage = '导入失败,请检查文件格式';
if (error.message) {
if (error.message.includes('getCorrectAnswers()') && error.message.includes('null')) {
errorMessage = '导入失败Excel文件中的"正确答案"列不能为空,请检查文件内容';
} else if (error.message.includes('split')) {
errorMessage = '导入失败Excel文件格式不正确请检查数据格式';
} else if (error.message.includes('Cannot invoke')) {
errorMessage = '导入失败Excel文件数据格式不正确请检查必填字段';
} else if (error.message.includes('null')) {
errorMessage = '导入失败Excel文件中存在空值请检查必填字段';
} else {
errorMessage = `导入失败:${error.message}`;
}
}
message.error(errorMessage);
} finally {
loading.value = false;
}
};
//
document.body.appendChild(input);
input.click();
document.body.removeChild(input);
};
//
const exportQuestions = async () => {
if (!selectedRepo.value) {
message.error('请先选择题库');
return;
}
try {
loading.value = true;
message.loading('正在导出模板,请稍候...', { duration: 0 });
const response = await ExamApi.exportQuestions(selectedRepo.value);
// Blob
if (!response || !(response instanceof Blob)) {
throw new Error('服务器返回的数据格式不正确');
}
// blob
if (response.size === 0) {
throw new Error('导出的文件为空');
}
//
const url = window.URL.createObjectURL(response);
const link = document.createElement('a');
link.href = url;
//
const repo = repoList.value.find(r => r.id === selectedRepo.value);
const fileName = repo ? `${repo.title}_题目模板.xlsx` : '题目模板.xlsx';
link.download = fileName;
//
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// URL
window.URL.revokeObjectURL(url);
message.destroyAll();
message.success('模板导出成功!');
} catch (error: any) {
message.destroyAll();
console.error('导出模板失败:', error);
message.error(error.message || '导出失败,请稍后重试');
} finally {
loading.value = false;
}
};
//
@ -304,27 +716,36 @@ const cancelSelection = () => {
//
const confirmSelection = () => {
const selectedQuestions = questionList.value.filter(q => selectedRowKeys.value.includes(q.id));
const selectedQuestions = questionList.value.filter((q: QuestionItem) => selectedRowKeys.value.includes(q.id));
emit('confirm', selectedQuestions);
selectedRowKeys.value = [];
};
// visible
watch(() => props.visible, (visible) => {
watch(() => props.visible, async (visible: boolean) => {
if (visible) {
loadQuestions();
selectedRowKeys.value = [];
//
if (props.questionType) {
filters.value.type = props.questionType;
}
//
await Promise.all([
loadCategories(),
loadDifficulties(),
loadRepos()
]);
}
});
//
onMounted(() => {
if (props.visible) {
loadQuestions();
Promise.all([
loadCategories(),
loadDifficulties(),
loadRepos()
]);
}
});
</script>
@ -334,7 +755,7 @@ onMounted(() => {
position: relative;
}
.header-title{
.header-title {
color: #000;
font-weight: 400;
font-size: 20px;
@ -379,7 +800,7 @@ onMounted(() => {
margin-left: auto;
}
.tip{
.tip {
font-size: 12px;
color: #999;
}
@ -439,6 +860,24 @@ onMounted(() => {
margin-bottom: 16px;
}
/* 空状态样式 */
.empty-state {
padding: 40px 20px;
text-align: center;
}
.empty-tip {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: #999;
}
.empty-tip p {
margin: 0;
font-size: 14px;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.question-bank-content {

View File

@ -86,6 +86,7 @@ import StudentList from '@/views/teacher/ExamPages/StudentList.vue'
import GradingPage from '@/views/teacher/ExamPages/GradingPage.vue'
import ExamTaking from '@/views/teacher/ExamPages/ExamTaking.vue'
import ExamNoticeBeforeStart from '@/views/teacher/ExamPages/ExamNoticeBeforeStart.vue'
import ExamAnalysis from '@/views/teacher/ExamPages/ExamAnalysis.vue'
import ChapterEditor from '@/views/teacher/course/ChapterEditor.vue'
import TeacherCourseDetail from '@/views/teacher/course/CourseDetail.vue'
@ -414,7 +415,14 @@ const routes: RouteRecordRaw[] = [
name: 'ExamManagement',
component: ExamManagement,
meta: { title: '考试管理' },
redirect: '/teacher/exam-management/question-bank',
redirect: (to) => {
// 如果访问的是根路径,重定向到题库管理
if (to.path === '/teacher/exam-management') {
return '/teacher/exam-management/question-bank'
}
// 否则不重定向,让子路由处理
return '/teacher/exam-management/question-bank'
},
children: [
{
path: 'question-bank',
@ -467,6 +475,12 @@ const routes: RouteRecordRaw[] = [
component: AddExam,
meta: { title: '添加试卷' }
},
{
path: 'edit/:id',
name: 'EditExam',
component: AddExam,
meta: { title: '编辑试卷' }
},
{
path: 'preview',
name: 'ExamPreview',
@ -478,6 +492,12 @@ const routes: RouteRecordRaw[] = [
name: 'AddQuestionPage',
component: AddQuestion,
meta: { title: '添加试题' }
},
{
path: 'analysis',
name: 'ExamAnalysis',
component: ExamAnalysis,
meta: { title: '试卷分析' }
}
]
},

View File

@ -10,7 +10,7 @@
</n-icon>
</template>
</n-button>
<h1>添加试卷</h1>
<h1>{{ isEditMode ? '编辑试卷' : '添加试卷' }}</h1>
<span v-if="isAutoSaved" class="auto-save-indicator">
<n-icon size="14" color="#52c41a">
<svg viewBox="0 0 24 24" fill="currentColor">
@ -182,7 +182,7 @@
</div>
<div class="sub-footer-item">
题目必填<n-select v-model:value="subQuestion.required" size="small" style="width: 80px"
:options="[{ label: '是', value: true }, { label: '否', value: false }]" />
:options="[{ label: '是', value: 'true' }, { label: '否', value: 'false' }]" />
</div>
</div>
</div>
@ -249,6 +249,17 @@
<!-- 右侧按钮 -->
<div class="footer-right">
<n-space>
<n-button strong type="primary" secondary size="large" @click="clearExamForm">
<template #icon>
<n-icon>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</n-icon>
</template>
清除内容
</n-button>
<n-button strong type="primary" secondary size="large">
取消
</n-button>
@ -277,7 +288,19 @@
<script setup lang="ts">
import { computed, reactive, ref, onMounted, onUnmounted, watch } from 'vue';
import { createDiscreteApi } from 'naive-ui';
import {
createDiscreteApi,
NSpace,
NButton,
NIcon,
NCard,
NTag,
NRow,
NInput,
NSelect,
NInputNumber,
NPopselect
} from 'naive-ui';
import { useRouter, useRoute } from 'vue-router';
import { AddCircle, SettingsOutline, TrashOutline, BookSharp, ArrowBackOutline } from '@vicons/ionicons5'
import BatchSetScoreModal from '@/components/admin/ExamComponents/BatchSetScoreModal.vue';
@ -291,8 +314,8 @@ import ShortAnswerQuestion from '@/components/teacher/ShortAnswerQuestion.vue';
import CompositeQuestion from '@/components/teacher/CompositeQuestion.vue';
import { ExamApi } from '@/api/modules/exam';
// dialog API
const { dialog } = createDiscreteApi(['dialog'])
// dialog message API
const { dialog, message } = createDiscreteApi(['dialog', 'message'])
//
const router = useRouter()
@ -357,7 +380,7 @@ interface SubQuestion {
title: string; //
score: number; //
difficulty: 'easy' | 'medium' | 'hard'; //
required: boolean; //
required: string; //
//
options?: ChoiceOption[]; //
@ -429,6 +452,10 @@ const difficultyOptions = ref([
{ label: '困难', value: 'hard' }
])
//
const isEditMode = ref(false);
const examId = ref<string>('');
const examForm = reactive({
title: '',
type: 1, // 1: , 2:
@ -461,6 +488,119 @@ const changeType = (e: number) => {
examForm.type = e;
}
//
const loadExamDetail = async (id: string) => {
try {
console.log('🚀 加载试卷详情:', id);
//
const [examResponse, questionsResponse] = await Promise.all([
ExamApi.getExamPaperDetail(id),
ExamApi.getExamPaperQuestions(id).catch(() => ({ data: { success: true, result: [] } })) // API使
]);
console.log('📊 试卷详情API响应:', examResponse);
console.log('📊 试卷题目API响应:', questionsResponse);
if (examResponse.data && examResponse.data.success) {
const examData = examResponse.data.result;
//
examForm.title = examData.title || '';
examForm.type = examData.generateMode === 1 ? 2 : 1; // 1: , 2:
examForm.description = (examData as any).description || '';
examForm.totalScore = examData.totalScore || 0;
examForm.duration = (examData as any).duration || 60;
examForm.passScore = examData.passScore || 60;
examForm.instructions = (examData as any).instructions || '';
examForm.useAIGrading = examData.requireReview === 1;
//
if (questionsResponse.data && questionsResponse.data.success && (questionsResponse.data.result as any).records && (questionsResponse.data.result as any).records.length > 0) {
console.log('📊 发现题目关联数据,开始处理');
console.log('📊 题目关联数据详情:', (questionsResponse.data.result as any).records);
//
const questionRelations = (questionsResponse.data.result as any).records;
// questionId
const questionDetails = await Promise.all(
questionRelations.map(async (relation: any) => {
try {
console.log('📊 获取题目详情:', relation.questionId);
const questionDetail = await ExamApi.getQuestionDetail(relation.questionId);
if (questionDetail.data && (questionDetail.data as any).success) {
return {
...(questionDetail.data as any).result,
paperQuestionId: relation.id,
orderNo: relation.orderNo,
score: relation.score
};
}
return null;
} catch (error) {
console.error('❌ 获取题目详情失败:', relation.questionId, error);
return null;
}
})
);
//
const validQuestions = questionDetails.filter((q: any) => q !== null);
console.log('📊 有效题目数据:', validQuestions);
//
examForm.questions = validQuestions.map((question: any, index: number) => {
console.log('📊 处理题目:', question);
//
const bigQuestion = {
id: question.paperQuestionId || `big_question_${Date.now()}_${index}`,
title: question.title || `大题 ${index + 1}`,
type: 'composite',
description: question.description || '',
sort: question.orderNo || index + 1,
totalScore: question.score || 0,
subQuestions: [{
id: question.id || `sub_question_${Date.now()}_${index}`,
title: question.content || question.title || `题目 ${index + 1}`,
type: convertNumberToQuestionType(question.type || 0),
score: question.score || 0,
difficulty: question.difficulty || 1,
explanation: question.analysis || '',
createTime: question.createTime || new Date().toISOString(),
required: 'true',
options: question.options || [],
correctAnswer: question.correctAnswer || '',
analysis: question.analysis || ''
}],
createTime: question.createTime || new Date().toISOString()
};
console.log('📊 构建的大题结构:', bigQuestion);
return bigQuestion;
});
} else {
//
console.log('📝 试卷暂无题目数据,使用默认结构');
}
console.log('✅ 试卷详情加载成功:', examData);
} else {
message.error((examResponse.data as any)?.message || '加载试卷详情失败');
}
} catch (error: any) {
console.error('❌ 加载试卷详情失败:', error);
if (error.response?.status === 404) {
message.error('试卷不存在或已删除');
router.back();
} else {
message.error('加载试卷详情失败,请重试');
}
}
};
const addQuestion = (index: number) => {
const questionType = questionTypeValue.value as QuestionType;
const newSubQuestion: SubQuestion = {
@ -469,7 +609,7 @@ const addQuestion = (index: number) => {
title: '',
score: 5, //
difficulty: 'medium',
required: true, //
required: 'true', //
explanation: '', //
textAnswer: '', //
//
@ -847,25 +987,25 @@ const examSettingsData = computed(() => ({
endTime: null,
category: (examForm.type === 1 ? 'exam' : 'practice') as 'exam' | 'practice',
timeLimit: 'limited' as 'unlimited' | 'limited' | 'no_limit',
timeLimitValue: 0,
timeLimitValue: '0',
examTimes: 'unlimited' as 'unlimited' | 'limited' | 'each_day',
examTimesValue: 1,
examTimesValue: '1',
dailyLimit: 1,
chapter: '',
passScore: examForm.passScore,
passScore: examForm.passScore.toString(),
participants: 'all' as 'all' | 'by_school',
selectedClasses: [],
maxParticipants: 0,
maxParticipants: '0',
instructions: examForm.instructions,
//
enforceOrder: false,
enforceInstructions: false,
readingTime: 10,
readingTime: '10',
submitSettings: {
allowEarlySubmit: true,
},
gradingDelay: 60,
gradingDelay: '60',
scoreDisplay: 'show_all' as 'show_all' | 'show_score' | 'hide_all',
detailedSettings: {
showQuestions: false,
@ -873,15 +1013,15 @@ const examSettingsData = computed(() => ({
showSubmissionTime: false,
},
timerEnabled: false,
timerDuration: examForm.duration,
timerDuration: examForm.duration.toString(),
answerType: 'auto_save' as 'auto_save' | 'manual_save' | 'multiple_submit',
detailScoreMode: 'question' as 'question' | 'automatic' | 'show_current' | 'show_all',
showRanking: false,
courseProgress: 0,
courseProgress: '0',
//
correctnessMode: 'no_limit' as 'no_limit' | 'limit_wrong',
wrongLimit: 10,
wrongLimit: '10',
practiceSettings: {
showCorrectAnswer: false,
showWrongAnswer: false,
@ -934,7 +1074,7 @@ const handleQuestionBankConfirm = (selectedQuestions: any[]) => {
title: question.title,
score: question.score,
difficulty: question.difficulty,
required: true,
required: 'true',
explanation: '', //
textAnswer: '', //
createTime: new Date().toISOString()
@ -998,6 +1138,58 @@ const getQuestionTypeFromString = (typeString: string) => {
return typeMap[typeString] || 'single_choice';
};
//
const clearExamForm = () => {
//
const hasContent = examForm.title.trim() ||
examForm.description.trim() ||
examForm.instructions.trim() ||
examForm.questions.some((q: any) => q.title.trim() || (q.description && q.description.trim()) || q.subQuestions.length > 0);
if (!hasContent) {
message.info('表单内容已为空,无需清除');
return;
}
//
dialog.warning({
title: '确认清除',
content: '确定要清除所有试卷内容吗?此操作不可撤销!',
positiveText: '确定清除',
negativeText: '取消',
onPositiveClick: () => {
//
examForm.title = '';
examForm.type = 1;
examForm.description = '';
examForm.totalScore = 0;
examForm.duration = 60;
examForm.passScore = 60;
examForm.instructions = '';
examForm.useAIGrading = false;
//
examForm.questions = [
{
id: '1',
title: '',
description: '',
sort: 1,
totalScore: 0,
subQuestions: [],
createTime: new Date().toISOString()
}
];
// sessionStorage
sessionStorage.removeItem('examPreviewData');
message.success('试卷内容已清除');
console.log('✅ 试卷表单内容已清除');
}
});
};
//
const saveExam = async () => {
//
@ -1037,30 +1229,88 @@ const saveExam = async () => {
}
try {
// API
// API - /aiol/aiolPaper/add
const apiData = {
name: examForm.title,
category: examForm.type === 1 ? '考试' : '练习',
description: examForm.description || '',
title: examForm.title,
generateMode: examForm.type === 1 ? 0 : 1, // 0: , 1:
rules: '', //
repoId: '', // ID
totalScore: examForm.totalScore,
difficulty: getDifficultyLevel(examForm.totalScore),
duration: examForm.duration,
questions: formatQuestionsForAPI(examForm.questions)
passScore: examForm.passScore || Math.floor(examForm.totalScore * 0.6), //
requireReview: examForm.useAIGrading ? 1 : 0 //
};
console.log('🚀 准备保存试卷数据:', apiData);
// API
const response = await ExamApi.createExamPaper(apiData);
console.log('✅ 创建试卷成功:', response);
// API
let response;
let paperId: string;
if (isEditMode.value) {
//
paperId = examId.value;
console.log('🚀 编辑模式更新试卷ID:', paperId);
response = await ExamApi.updateExamPaper(examId.value, {
title: examForm.title,
generateMode: examForm.type === 1 ? 0 : 1, // 0: , 1:
rules: '', //
repoId: '', // ID
totalScore: examForm.totalScore,
passScore: examForm.passScore || Math.floor(examForm.totalScore * 0.6),
requireReview: examForm.useAIGrading ? 1 : 0,
description: examForm.description,
duration: examForm.duration,
instructions: examForm.instructions,
useAIGrading: examForm.useAIGrading
});
console.log('✅ 更新试卷成功:', response);
} else {
//
response = await ExamApi.createExamPaper(apiData);
console.log('✅ 创建试卷成功:', response);
// ID
if (response.data) {
paperId = response.data;
} else {
throw new Error('创建试卷失败无法获取试卷ID');
}
}
//
if (paperId) {
console.log('🚀 开始保存题目数据试卷ID:', paperId);
console.log('📊 当前题目数据:', examForm.questions);
//
const hasQuestions = examForm.questions.some(bigQuestion =>
bigQuestion.subQuestions && bigQuestion.subQuestions.length > 0
);
if (hasQuestions) {
console.log('📊 发现题目数据,开始保存');
await saveExamQuestions(paperId);
} else {
console.log('⚠️ 没有题目数据,跳过题目保存');
}
} else {
console.error('❌ 无法获取试卷ID跳过题目保存');
}
dialog.success({
title: '保存成功',
content: '试卷保存成功!',
content: isEditMode.value ? '试卷更新成功!' : '试卷保存成功!',
positiveText: '确定',
onPositiveClick: () => {
//
router.back();
if (isEditMode.value) {
//
loadExamDetail(examId.value);
} else {
//
clearExamForm();
//
router.back();
}
}
});
} catch (error) {
@ -1074,31 +1324,182 @@ const saveExam = async () => {
}
//
const getDifficultyLevel = (totalScore: number): string => {
if (totalScore <= 60) return 'easy';
if (totalScore <= 100) return 'medium';
return 'hard';
}
// 使
// const getDifficultyLevel = (totalScore: number): string => {
// if (totalScore <= 60) return 'easy';
// if (totalScore <= 100) return 'medium';
// return 'hard';
// }
// API
const convertQuestionTypeToNumber = (type: QuestionType): number => {
const typeMap: Record<QuestionType, number> = {
[QuestionType.SINGLE_CHOICE]: 0, //
[QuestionType.MULTIPLE_CHOICE]: 1, //
[QuestionType.TRUE_FALSE]: 2, //
[QuestionType.FILL_BLANK]: 3, //
[QuestionType.SHORT_ANSWER]: 4, //
[QuestionType.COMPOSITE]: 5 //
};
return typeMap[type] || 0;
};
// API
const convertNumberToQuestionType = (type: number): QuestionType => {
const typeMap: Record<number, QuestionType> = {
0: QuestionType.SINGLE_CHOICE, //
1: QuestionType.MULTIPLE_CHOICE, //
2: QuestionType.TRUE_FALSE, //
3: QuestionType.FILL_BLANK, //
4: QuestionType.SHORT_ANSWER, //
5: QuestionType.COMPOSITE //
};
return typeMap[type] || QuestionType.SINGLE_CHOICE;
};
//
const saveExamQuestions = async (paperId: string) => {
try {
console.log('🚀 开始保存试卷题目:', paperId);
//
if (isEditMode.value) {
console.log('🗑️ 编辑模式:删除现有题目关联');
//
// API
}
let questionOrder = 1;
//
if (!examForm.questions || examForm.questions.length === 0) {
console.log('⚠️ 没有题目数据,跳过题目保存');
return;
}
//
console.log('📊 开始遍历大题,总数:', examForm.questions.length);
for (const bigQuestion of examForm.questions) {
console.log('📊 处理大题:', bigQuestion.title, '小题数量:', bigQuestion.subQuestions?.length || 0);
if (bigQuestion.subQuestions && bigQuestion.subQuestions.length > 0) {
//
for (const subQuestion of bigQuestion.subQuestions) {
console.log('📊 处理小题:', subQuestion.title, '类型:', subQuestion.type);
try {
//
const questionData = {
repoId: '1958492351656955905', // 使ID
parentId: '', // ID
type: convertQuestionTypeToNumber(subQuestion.type), //
content: subQuestion.title || '', //
analysis: '', //
difficulty: 1, // 1
score: subQuestion.score || 0, //
degree: 1, // 1
ability: 1 // 1
};
console.log('📝 创建题目:', questionData);
const questionResponse = await ExamApi.createQuestion(questionData);
if (questionResponse.data) {
// ID
let questionId;
if (typeof questionResponse.data === 'string') {
questionId = questionResponse.data;
} else if (questionResponse.data && typeof questionResponse.data === 'object') {
questionId = (questionResponse.data as any).result || questionResponse.data;
} else {
throw new Error('无法获取题目ID');
}
console.log('✅ 题目创建成功ID:', questionId);
console.log('📊 题目响应详情:', questionResponse);
//
if (subQuestion.options && subQuestion.options.length > 0) {
console.log('📊 题目选项数据:', subQuestion.options);
for (let i = 0; i < subQuestion.options.length; i++) {
const option = subQuestion.options[i];
console.log('📊 处理选项:', option);
const optionData = {
questionId: questionId,
content: (option as any).text || option.content || '',
izCorrent: (option as any).isCorrect ? 1 : 0,
orderNo: i + 1
};
console.log('📝 创建题目选项:', optionData);
await ExamApi.createQuestionOption(optionData);
}
}
//
if (subQuestion.correctAnswer !== null && subQuestion.correctAnswer !== undefined) {
console.log('📊 题目答案数据:', subQuestion.correctAnswer);
const answerData = {
questionId: questionId,
answerText: String(subQuestion.correctAnswer),
orderNo: 1
};
console.log('📝 创建题目答案:', answerData);
await ExamApi.createQuestionAnswer(answerData);
} else {
console.log('📝 题目无答案,跳过答案创建');
}
//
const paperQuestionData = {
paperId: paperId,
questionId: questionId,
orderNo: questionOrder,
score: subQuestion.score || 0
};
console.log('📝 关联题目到试卷:', paperQuestionData);
const paperQuestionResponse = await ExamApi.addExamPaperQuestion(paperQuestionData);
console.log('✅ 题目关联成功:', paperQuestionResponse);
questionOrder++;
} else {
console.error('❌ 题目创建失败:', questionResponse);
}
} catch (error) {
console.error('❌ 保存小题失败:', subQuestion, error);
}
}
}
}
console.log('✅ 试卷题目保存完成,共处理', questionOrder - 1, '道题目');
} catch (error) {
console.error('❌ 保存试卷题目失败:', error);
throw error;
}
};
// API
const formatQuestionsForAPI = (questions: any[]): any[] => {
return questions.map((bigQuestion, index) => ({
id: bigQuestion.id,
title: bigQuestion.title,
description: bigQuestion.description,
sort: index + 1,
totalScore: bigQuestion.totalScore,
subQuestions: bigQuestion.subQuestions.map((subQuestion: any, subIndex: number) => ({
id: subQuestion.id,
title: subQuestion.title,
type: subQuestion.type,
options: subQuestion.options || [],
correctAnswer: subQuestion.correctAnswer,
score: subQuestion.score,
sort: subIndex + 1
}))
}));
}
// 使
// const formatQuestionsForAPI = (questions: any[]): any[] => {
// return questions.map((bigQuestion, index) => ({
// id: bigQuestion.id,
// title: bigQuestion.title,
// description: bigQuestion.description,
// sort: index + 1,
// totalScore: bigQuestion.totalScore,
// subQuestions: bigQuestion.subQuestions.map((subQuestion: any, subIndex: number) => ({
// id: subQuestion.id,
// title: subQuestion.title,
// type: subQuestion.type,
// options: subQuestion.options || [],
// correctAnswer: subQuestion.correctAnswer,
// score: subQuestion.score,
// sort: subIndex + 1
// }))
// }));
// }
//
const previewExam = () => {
@ -1179,7 +1580,15 @@ const restoreExamData = () => {
//
onMounted(() => {
restoreExamData();
//
if (route.params.id) {
isEditMode.value = true;
examId.value = route.params.id as string;
loadExamDetail(examId.value);
} else {
//
restoreExamData();
}
});
//

View File

@ -46,8 +46,7 @@
v-model:trueFalseAnswer="questionForm.trueFalseAnswer"
v-model:fillBlankAnswers="questionForm.fillBlankAnswers"
v-model:shortAnswer="questionForm.shortAnswer"
v-model:compositeData="questionForm.compositeData"
v-model:title="questionForm.title"
v-model:compositeData="questionForm.compositeData" v-model:title="questionForm.title"
v-model:explanation="questionForm.explanation" />
<!-- 基本信息 -->
@ -149,29 +148,31 @@
</div>
<div v-else class="composite-sub-questions">
<div v-for="(subQuestion, index) in questionForm.compositeData.subQuestions"
:key="subQuestion.id"
class="composite-sub-question">
:key="subQuestion.id" class="composite-sub-question">
<div class="sub-question-header">
<span class="sub-question-number">{{ index + 1 }}.</span>
<span class="sub-question-title">{{ subQuestion.title || '请输入小题内容...' }}</span>
<span class="sub-question-type">[{{ getQuestionTypeLabel(subQuestion.type) }}]</span>
<span class="sub-question-type">[{{ getQuestionTypeLabel(subQuestion.type)
}}]</span>
<span class="sub-question-score">({{ subQuestion.score }})</span>
</div>
<!-- 小题选项预览 -->
<div v-if="subQuestion.type === 'single' && subQuestion.data" class="sub-question-options">
<div v-if="subQuestion.type === 'single' && subQuestion.data"
class="sub-question-options">
<div v-for="(option, optIndex) in subQuestion.data" :key="optIndex"
class="preview-option-item"
:class="{ 'correct-answer': subQuestion.correctAnswer === optIndex }">
class="preview-option-item"
:class="{ 'correct-answer': subQuestion.correctAnswer === optIndex }">
<span class="option-letter">{{ String.fromCharCode(65 + optIndex) }}</span>
<span class="option-content">{{ option.option || '请输入内容' }}</span>
</div>
</div>
<div v-else-if="subQuestion.type === 'multiple' && subQuestion.data" class="sub-question-options">
<div v-else-if="subQuestion.type === 'multiple' && subQuestion.data"
class="sub-question-options">
<div v-for="(option, optIndex) in subQuestion.data" :key="optIndex"
class="preview-option-item"
:class="{ 'correct-answer': subQuestion.correctAnswers && subQuestion.correctAnswers.includes(optIndex) }">
class="preview-option-item"
:class="{ 'correct-answer': subQuestion.correctAnswers && subQuestion.correctAnswers.includes(optIndex) }">
<span class="option-letter">{{ String.fromCharCode(65 + optIndex) }}</span>
<span class="option-content">{{ option.option || '请输入内容' }}</span>
</div>
@ -179,20 +180,21 @@
<div v-else-if="subQuestion.type === 'truefalse'" class="sub-question-options">
<div class="preview-option-item"
:class="{ 'correct-answer': subQuestion.answer === true }">
:class="{ 'correct-answer': subQuestion.answer === true }">
<span class="option-letter">A</span>
<span class="option-content"></span>
</div>
<div class="preview-option-item"
:class="{ 'correct-answer': subQuestion.answer === false }">
:class="{ 'correct-answer': subQuestion.answer === false }">
<span class="option-letter">B</span>
<span class="option-content"></span>
</div>
</div>
<div v-else-if="subQuestion.type === 'fillblank' && subQuestion.answers" class="sub-question-fill-blanks">
<div v-else-if="subQuestion.type === 'fillblank' && subQuestion.answers"
class="sub-question-fill-blanks">
<div v-for="(answer, answerIndex) in subQuestion.answers" :key="answerIndex"
class="preview-fill-blank-item">
class="preview-fill-blank-item">
<div class="blank-number">{{ answerIndex + 1 }}.</div>
<div class="blank-content">
<span class="blank-answer">{{ answer.value || '请输入答案' }}</span>
@ -202,7 +204,8 @@
</div>
</div>
<div v-else-if="subQuestion.type === 'shortanswer'" class="sub-question-short-answer">
<div v-else-if="subQuestion.type === 'shortanswer'"
class="sub-question-short-answer">
<div class="short-answer-label">参考答案</div>
<div class="short-answer-content">
{{ subQuestion.data || '请输入参考答案' }}
@ -291,27 +294,18 @@ const questionTypeOptions = ref([
{ label: '复合题', value: 'composite' }
]);
//
const categoryOptions = ref([
{ label: '分类试题', value: 'category' },
{ label: '考试试题', value: 'exam' },
{ label: '练习试题', value: 'practice' },
{ label: '模拟试题', value: 'simulation' }
]);
// API
const categoryOptions = ref<Array<{ label: string, value: string }>>([]);
//
const difficultyOptions = ref([
{ label: '简单', value: 0 },
{ label: '中等', value: 1 },
{ label: '困难', value: 2 }
]);
// API
const difficultyOptions = ref<Array<{ label: string, value: string }>>([]);
//
const questionForm = reactive({
type: 'single_choice', //
category: '',
difficulty: 0, //
score: 10,
difficulty: '0', //
score: '10', //
title: '',
options: [
{ content: '' },
@ -322,7 +316,7 @@ const questionForm = reactive({
correctAnswer: null as number | null, //
correctAnswers: [] as number[], //
trueFalseAnswer: null as boolean | null, //
fillBlankAnswers: [{ content: '', score: 1, caseSensitive: false }] as Array<{content: string, score: number, caseSensitive: boolean}>, //
fillBlankAnswers: [{ content: '', score: 1, caseSensitive: false }] as Array<{ content: string, score: number, caseSensitive: boolean }>, //
shortAnswer: '', //
compositeData: { subQuestions: [] } as { subQuestions: any[] }, //
explanation: '' //
@ -346,15 +340,23 @@ const formRules = {
},
difficulty: {
required: true,
type: 'number',
message: '请选择难度',
trigger: 'change'
},
score: {
required: true,
type: 'number',
message: '请输入分值',
trigger: 'blur'
trigger: 'blur',
validator: (_rule: any, value: any) => {
if (!value || value === '') {
return new Error('请输入分值');
}
const numValue = Number(value);
if (isNaN(numValue) || numValue <= 0) {
return new Error('分值必须是大于0的数字');
}
return true;
}
},
title: {
required: true,
@ -426,7 +428,7 @@ const getQuestionTypeNumber = (type: string): number => {
return typeMap[type] || 0;
};
//
//
const getDifficultyNumber = (difficulty: number | string): number => {
//
if (typeof difficulty === 'number') {
@ -435,9 +437,12 @@ const getDifficultyNumber = (difficulty: number | string): number => {
//
const difficultyMap: Record<string, number> = {
'easy': 0, //
'medium': 1, //
'hard': 2 //
'0': 0, //
'1': 1, //
'2': 2, //
'easy': 0, //
'medium': 1, //
'hard': 2 //
};
return difficultyMap[difficulty] || 0;
};
@ -445,6 +450,20 @@ const getDifficultyNumber = (difficulty: number | string): number => {
//
const saveQuestion = async () => {
try {
console.log('🔍 开始保存试题,当前表单数据:', {
type: questionForm.type,
title: questionForm.title,
category: questionForm.category,
difficulty: questionForm.difficulty,
score: Number(questionForm.score),
options: questionForm.options,
correctAnswer: questionForm.correctAnswer,
correctAnswers: questionForm.correctAnswers,
trueFalseAnswer: questionForm.trueFalseAnswer,
fillBlankAnswers: questionForm.fillBlankAnswers,
shortAnswer: questionForm.shortAnswer
});
//
await formRef.value?.validate();
@ -509,10 +528,97 @@ const saveQuestion = async () => {
}
};
//
const prepareQuestionOptions = () => {
const questionType = getQuestionTypeNumber(questionForm.type);
const options: Array<{
content: string;
isCorrect: boolean;
orderNo: number;
}> = [];
if (questionType === 0) { //
const formOptions = questionForm.options || [];
const correctAnswer = questionForm.correctAnswer;
console.log('🔍 准备单选题选项 - 表单选项:', formOptions);
console.log('🔍 准备单选题选项 - 正确答案索引:', correctAnswer);
formOptions.forEach((option: any, index: number) => {
const content = option.content || option.text || '';
console.log(`🔍 处理选项 ${index}:`, { content, isCorrect: index === correctAnswer });
//
if (content.trim()) {
options.push({
content: content,
isCorrect: index === correctAnswer, // 使
orderNo: index
});
}
});
} else if (questionType === 1) { //
const formOptions = questionForm.options || [];
const correctAnswers = questionForm.correctAnswers || [];
formOptions.forEach((option: any, index: number) => {
const content = option.content || option.text || '';
//
if (content.trim()) {
options.push({
content: content,
isCorrect: correctAnswers.includes(index), // 使
orderNo: index
});
}
});
} else if (questionType === 2) { //
options.push(
{ content: '正确', isCorrect: questionForm.trueFalseAnswer === true, orderNo: 0 },
{ content: '错误', isCorrect: questionForm.trueFalseAnswer === false, orderNo: 1 }
);
}
console.log('🔍 准备题目选项数据结果:', options);
return options;
};
//
const prepareQuestionAnswers = () => {
const questionType = getQuestionTypeNumber(questionForm.type);
const answers: Array<{
answerText: string;
orderNo: number;
}> = [];
if (questionType === 3) { //
const fillBlankAnswers = questionForm.fillBlankAnswers || [];
fillBlankAnswers.forEach((answer: any, index: number) => {
const answerText = typeof answer === 'string' ? answer : answer.content;
if (answerText && answerText.trim()) {
answers.push({
answerText: answerText.trim(),
orderNo: index
});
}
});
} else if (questionType === 4) { //
const shortAnswer = questionForm.shortAnswer;
if (shortAnswer && shortAnswer.trim()) {
answers.push({
answerText: shortAnswer.trim(),
orderNo: 0
});
}
}
return answers;
};
//
const createNewQuestion = async (bankId: string) => {
try {
// API
//
const questionData = {
repoId: bankId, // ID
parentId: undefined, // IDundefined
@ -520,34 +626,18 @@ const createNewQuestion = async (bankId: string) => {
content: questionForm.title,
analysis: questionForm.explanation || '',
difficulty: getDifficultyNumber(questionForm.difficulty),
score: questionForm.score,
score: Number(questionForm.score),
degree: 1, // 1
ability: 1 // 1
ability: 1, // 1
options: prepareQuestionOptions(),
answers: prepareQuestionAnswers()
};
console.log('🚀 创建题目,数据:', questionData);
const response = await ExamApi.createQuestion(questionData);
console.log('📊 创建题目API响应:', response);
console.log('🚀 创建完整题目,数据:', questionData);
const response = await ExamApi.createCompleteQuestion(questionData);
console.log('📊 创建完整题目API响应:', response);
// API
let success = false;
let questionId = null;
if (response.data) {
const apiResponse = response.data as any;
// {success, code, result}
if (typeof apiResponse === 'object' && ('success' in apiResponse || 'code' in apiResponse)) {
success = apiResponse.success === true || apiResponse.code === 200 || apiResponse.code === 0;
questionId = apiResponse.result || apiResponse.data;
} else {
// ID
success = true;
questionId = apiResponse;
}
}
if (!success || !questionId) {
if (!response.data) {
throw new Error('创建题目失败未获取到题目ID');
}
@ -557,10 +647,10 @@ const createNewQuestion = async (bankId: string) => {
const questionType = getQuestionTypeNumber(questionForm.type);
if (questionType === 0 || questionType === 1 || questionType === 2) {
//
await createQuestionOptions(questionId, questionType);
await updateQuestionOptions(questionId!, questionType);
} else if (questionType === 3 || questionType === 4) {
//
await createQuestionAnswers(questionId, questionType);
await createQuestionAnswers(questionId!, questionType);
}
message.success('题目创建成功!');
@ -573,98 +663,6 @@ const createNewQuestion = async (bankId: string) => {
}
};
//
const createQuestionOptions = async (questionId: string, questionType: number) => {
try {
console.log('🚀 开始创建题目选项题目ID:', questionId, '题目类型:', questionType);
let optionsToCreate: Array<{
content: string;
izCorrent: number;
orderNo: number;
}> = [];
if (questionType === 0) { //
//
const options = questionForm.options || [];
const correctAnswer = questionForm.correctAnswer;
console.log('🔍 单选题选项创建逻辑:', {
options,
correctAnswer,
correctAnswerType: typeof correctAnswer
});
optionsToCreate = options.map((option: any, index: number) => ({
content: option.content || option.text || '',
izCorrent: index === correctAnswer ? 1 : 0,
orderNo: index
}));
} else if (questionType === 1) { //
const options = questionForm.options || [];
const correctAnswers = questionForm.correctAnswers || [];
console.log('🔍 多选题选项创建逻辑:', {
options,
correctAnswers,
correctAnswersType: typeof correctAnswers
});
optionsToCreate = options.map((option: any, index: number) => ({
content: option.content || option.text || '',
izCorrent: correctAnswers.includes(index) ? 1 : 0,
orderNo: index
}));
} else if (questionType === 2) { //
//
const trueFalseAnswer = questionForm.trueFalseAnswer;
const isCorrectTrue = trueFalseAnswer === true;
console.log('🔍 判断题选项创建逻辑:', {
trueFalseAnswer,
isCorrectTrue,
trueFalseAnswerType: typeof trueFalseAnswer
});
optionsToCreate = [
{
content: '正确',
izCorrent: isCorrectTrue ? 1 : 0,
orderNo: 0
},
{
content: '错误',
izCorrent: isCorrectTrue ? 0 : 1,
orderNo: 1
}
];
}
console.log('📊 准备创建的选项:', optionsToCreate);
//
for (const option of optionsToCreate) {
const optionData = {
questionId: questionId,
content: option.content,
izCorrent: option.izCorrent,
orderNo: option.orderNo
};
console.log('🚀 创建选项:', optionData);
const optionResponse = await ExamApi.createQuestionOption(optionData);
console.log('✅ 选项创建成功:', optionResponse);
}
console.log('✅ 所有选项创建完成');
} catch (error: any) {
console.error('❌ 创建题目选项失败:', error);
throw new Error('创建题目选项失败:' + (error.message || '未知错误'));
}
};
//
const createQuestionAnswers = async (questionId: string, questionType: number) => {
@ -729,7 +727,7 @@ const updateExistingQuestion = async (questionId: string) => {
content: questionForm.title,
analysis: questionForm.explanation || '',
difficulty: getDifficultyNumber(questionForm.difficulty),
score: questionForm.score
score: Number(questionForm.score)
};
console.log('🚀 更新题目基本信息:', questionData);
@ -1095,12 +1093,65 @@ const validateCompositeQuestion = (): boolean => {
return true;
};
//
const loadCategoriesAndDifficulties = async () => {
try {
console.log('🚀 加载分类和难度数据');
//
const [categoriesResponse, difficultiesResponse] = await Promise.all([
ExamApi.getQuestionCategories(),
ExamApi.getQuestionDifficulties()
]);
//
if (categoriesResponse.data) {
categoryOptions.value = categoriesResponse.data.map(item => ({
label: item.name,
value: item.id
}));
console.log('✅ 分类数据加载成功:', categoryOptions.value);
}
//
if (difficultiesResponse.data) {
difficultyOptions.value = difficultiesResponse.data.map(item => ({
label: item.name,
value: item.id
}));
console.log('✅ 难度数据加载成功:', difficultyOptions.value);
//
if (!questionForm.difficulty && difficultyOptions.value.length > 0) {
questionForm.difficulty = difficultyOptions.value[0].value;
}
}
} catch (error) {
console.error('❌ 加载分类和难度数据失败:', error);
// 使
categoryOptions.value = [
{ label: '考试试题', value: 'exam' },
{ label: '练习试题', value: 'practice' },
{ label: '模拟试题', value: 'simulation' }
];
difficultyOptions.value = [
{ label: '简单', value: '0' },
{ label: '中等', value: '1' },
{ label: '困难', value: '2' }
];
}
};
//
onMounted(async () => {
console.log('AddQuestion 组件挂载完成');
console.log('编辑模式:', isEditMode.value);
console.log('题目ID:', questionId);
//
await loadCategoriesAndDifficulties();
//
if (isEditMode.value && questionId) {
//
@ -1127,15 +1178,34 @@ const renderQuestionData = (questionData: any) => {
if (!questionData) return;
const { question, answer = [], children = [] } = questionData;
//
let question = null;
let answer = [];
let children = [];
//
if (questionData.question) {
// 1: { question, answer, children }
question = questionData.question;
answer = questionData.answer || [];
children = questionData.children || [];
} else if (questionData.id && questionData.type !== undefined) {
// 2:
question = questionData;
answer = questionData.options || questionData.answer || [];
children = questionData.children || [];
} else {
console.error('❌ 未知的数据结构:', questionData);
return;
}
if (question) {
//
questionForm.type = getQuestionTypeKey(question.type);
questionForm.title = question.content || '';
questionForm.explanation = question.analysis || '';
questionForm.score = question.score || 10;
questionForm.difficulty = question.difficulty || 0;
questionForm.score = String(question.score || 10);
questionForm.difficulty = String(question.difficulty || 0);
console.log('📝 题目基本信息:', {
type: questionForm.type,
@ -1144,6 +1214,8 @@ const renderQuestionData = (questionData: any) => {
score: questionForm.score
});
console.log('🔍 选项数据:', answer);
//
if (question.type === 0) { //
renderSingleChoiceData(answer);
@ -1308,16 +1380,21 @@ const loadQuestionData = async (id: string) => {
try {
console.log('🚀 开始加载题目数据题目ID:', id);
//
const response = await ExamApi.getQuestionDetail(id);
console.log('📊 题目详情API响应:', response);
//
const [questionResponse, optionsResponse] = await Promise.all([
ExamApi.getQuestionDetail(id),
ExamApi.getQuestionOptions(id)
]);
// API
console.log('📊 题目详情API响应:', questionResponse);
console.log('📊 题目选项API响应:', optionsResponse);
//
let questionData = null;
let success = false;
if (response.data) {
const apiResponse = response.data as any;
if (questionResponse.data) {
const apiResponse = questionResponse.data as any;
// {success, code, result}
if (typeof apiResponse === 'object' && 'result' in apiResponse) {
@ -1330,9 +1407,25 @@ const loadQuestionData = async (id: string) => {
}
}
//
let optionsData = [];
if (optionsResponse.data) {
const apiResponse = optionsResponse.data as any;
if (Array.isArray(apiResponse)) {
optionsData = apiResponse;
} else if (apiResponse && Array.isArray(apiResponse.result)) {
optionsData = apiResponse.result;
}
}
if (success && questionData) {
console.log('✅ 获取题目详情成功,开始渲染数据');
renderQuestionData(questionData);
//
const completeQuestionData = {
...questionData,
options: optionsData
};
renderQuestionData(completeQuestionData);
} else {
console.error('❌ 获取题目详情失败');
message.error('获取题目详情失败');

View File

@ -6,26 +6,49 @@
<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 v-model:value="searchKeyword" placeholder="请输入想要搜索的内容" @keyup.enter="handleSearch" />
<n-button type="error" ghost @click="handleBatchDelete" :disabled="checkedRowKeys.length === 0">
删除 ({{ checkedRowKeys.length }})
</n-button>
<n-input v-model:value="searchKeyword" placeholder="请输入想要搜索的内容" @keyup.enter="handleSearch"
@input="handleRealTimeSearch" />
<n-button type="primary" @click="handleSearch">搜索</n-button>
</n-space>
</div>
<n-data-table :columns="columns" :data="examData" :loading="loading" :row-key="(row: Exam) => row.id"
@update:checked-row-keys="handleCheck" class="exam-table" :single-line="false"
:pagination="paginationConfig" />
<transition name="fade" mode="out-in">
<n-data-table :columns="columns" :data="paginatedExamData" :loading="loading || isSearching"
:row-key="(row: any) => row.id" @update:checked-row-keys="handleCheck" class="exam-table"
:single-line="false" :pagination="paginationConfig" :key="searchKeyword">
<template #empty>
<div class="empty-state">
<div v-if="isSearching" class="searching-state">
<n-spin size="small" />
<span style="margin-left: 8px;">搜索中...</span>
</div>
<div v-else-if="searchKeyword.trim()" class="no-results">
未找到匹配的试卷
</div>
<div v-else>
暂无数据
</div>
</div>
</template>
</n-data-table>
</transition>
</div>
</template>
<script setup lang="ts">
import { h, ref, VNode, computed, onMounted } from 'vue';
import { NButton, NSpace, useMessage, NDataTable, NInput } from 'naive-ui';
import { NButton, NSpace, useMessage, NDataTable, NInput, useDialog, NSpin } from 'naive-ui';
import type { DataTableColumns } from 'naive-ui';
import { useRouter, useRoute } from 'vue-router';
import { ExamApi } from '@/api/modules/exam';
// import { useUserStore } from '@/stores/user';
// import type { ExamInfo } from '@/api/types';
const router = useRouter();
const route = useRoute();
// const userStore = useUserStore();
//
type Exam = {
@ -44,24 +67,53 @@ type Exam = {
};
const message = useMessage();
const dialog = useDialog();
//
const loading = ref(false);
const examData = ref<Exam[]>([]);
const searchKeyword = ref('');
const filters = ref({
category: '',
status: '',
difficulty: '',
creator: ''
const isSearching = ref(false);
// 使
// const filters = ref({
// category: '',
// status: '',
// difficulty: '',
// creator: ''
// });
//
const filteredExamData = computed(() => {
if (!searchKeyword.value.trim()) {
return examData.value;
}
const keyword = searchKeyword.value.toLowerCase().trim();
return examData.value.filter(item => {
return (
item.name.toLowerCase().includes(keyword) ||
item.category.toLowerCase().includes(keyword) ||
item.creator.toLowerCase().includes(keyword) ||
item.chapter.toLowerCase().includes(keyword) ||
item.status.toLowerCase().includes(keyword) ||
item.difficulty.toLowerCase().includes(keyword)
);
});
});
//
const paginatedExamData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return filteredExamData.value.slice(start, end);
});
//
const createColumns = ({
handleAction,
}: {
handleAction: (action: string, rowData: Exam) => void;
}): DataTableColumns<Exam> => {
handleAction: (action: string, rowData: any) => void;
}): DataTableColumns<any> => {
return [
{
type: 'selection',
@ -103,7 +155,7 @@ const createColumns = ({
{
title: '起止时间',
key: 'startTime',
render(row) {
render(row: any) {
return `${row.startTime} - ${row.endTime}`;
},
},
@ -118,7 +170,7 @@ const createColumns = ({
{
title: '操作',
key: 'actions',
render(row) {
render(row: any) {
const buttons: VNode[] = [];
if (row.status === '发布中') {
buttons.push(
@ -145,53 +197,115 @@ const createColumns = ({
];
};
// 使getExamInfo
// 使
// const loadExamInfo = async () => {
// loading.value = true;
// try {
// // ID
// const currentUser = userStore.user;
// if (!currentUser || !currentUser.id) {
// message.error('');
// return;
// }
// console.log('🔍 ID:', currentUser.id);
// const response = await ExamApi.getExamInfo(currentUser.id.toString());
// console.log(' :', response);
// let listData: ExamInfo[] = [];
// if (response.data && Array.isArray(response.data)) {
// listData = response.data;
// }
// //
// const mappedList = listData.map((item: ExamInfo) => {
// const statusMap: { [key: number]: string } = {
// 0: '',
// 1: '',
// 2: ''
// };
// const categoryMap: { [key: number]: string } = {
// 0: '',
// 1: ''
// };
// const difficultyMap: { [key: number]: string } = {
// 0: '',
// 1: '',
// 2: ''
// };
// //
// const formatTime = (startTime: string, endTime: string) => {
// if (startTime && endTime) {
// return `${startTime} - ${endTime}`;
// } else if (startTime) {
// return startTime;
// } else if (endTime) {
// return endTime;
// }
// return '-';
// };
// return {
// id: item.id || '',
// name: item.name || '',
// category: (categoryMap[item.type] || '') as '' | '',
// questionCount: 0, // 0
// chapter: '', //
// totalScore: 0, // 0
// difficulty: (difficultyMap[Number(item.difficulty) || 0] || '') as '' | '' | '',
// status: (statusMap[item.status] || '') as '' | '' | '',
// startTime: formatTime(item.startTime, item.endTime),
// endTime: item.endTime || '',
// creator: item.createBy || '',
// creationTime: item.createTime || ''
// };
// });
// examData.value = mappedList;
// totalItems.value = mappedList.length;
// } catch (error) {
// console.error(':', error);
// message.error('');
// examData.value = [];
// totalItems.value = 0;
// } finally {
// loading.value = false;
// }
// };
//
const loadExamPaperList = async () => {
loading.value = true;
try {
const params: any = {
page: currentPage.value,
pageSize: pageSize.value
page: 1,
pageSize: 1000 //
};
if (searchKeyword.value) {
params.keyword = searchKeyword.value;
}
if (filters.value.category) {
params.category = filters.value.category;
}
if (filters.value.status) {
params.status = filters.value.status;
}
if (filters.value.difficulty) {
params.difficulty = filters.value.difficulty;
}
if (filters.value.creator) {
params.creator = filters.value.creator;
}
// API
console.log('🔍 获取试卷列表参数:', params);
const response = await ExamApi.getExamPaperList(params);
console.log('✅ 获取试卷列表成功:', response);
console.log('📊 响应数据结构:', JSON.stringify(response, null, 2));
let listData: any[] = [];
let totalCount = 0;
if (response.data) {
const data = response.data as any;
if (data.result) {
// API result.records
listData = data.result.records || [];
totalCount = data.result.total || 0;
} else if (Array.isArray(data)) {
listData = data;
totalCount = data.length;
} else if (data.list) {
listData = data.list;
totalCount = data.total || data.totalCount || 0;
} else if (data.records) {
listData = data.records;
totalCount = data.total || data.totalCount || 0;
}
}
@ -200,23 +314,35 @@ const loadExamPaperList = async () => {
listData = [];
}
// API
if (listData.length === 0) {
console.log('📝 API返回空数据当前用户没有试卷');
}
//
const mappedList = listData.map((item: any) => {
const statusMap: { [key: number]: string } = {
0: '未发布',
1: '发布中',
2: '已结束'
//
const getDifficulty = (totalScore: number): '易' | '中' | '难' => {
if (totalScore <= 60) return '易';
if (totalScore <= 100) return '中';
return '难';
};
const categoryMap: { [key: number]: string } = {
0: '练习',
1: '考试'
//
const getCategory = (generateMode: number | null): '练习' | '考试' => {
if (generateMode === 0) return '考试'; //
if (generateMode === 1) return '练习'; //
return '考试'; //
};
const difficultyMap: { [key: number]: string } = {
0: '易',
1: '中',
2: '难'
//
const getStatus = (item: any): '发布中' | '未发布' | '已结束' => {
// 0
if (item.title && item.totalScore > 0) {
return '发布中';
}
// 0
return '未发布';
};
//
@ -232,15 +358,15 @@ const loadExamPaperList = async () => {
};
return {
id: item.id || item.paperId || '',
name: item.name || '未命名试卷',
category: (categoryMap[item.type] || '练习') as '练习' | '考试',
id: item.id || '',
name: item.title || '未命名试卷',
category: getCategory(item.generateMode),
questionCount: 0, // 0
chapter: '未分类', //
totalScore: 0, // 0
difficulty: (difficultyMap[item.difficulty] || '易') as '易' | '中' | '难',
status: (statusMap[item.status] || '未发布') as '发布中' | '未发布' | '已结束',
startTime: formatTime(item.startTime, item.endTime),
totalScore: item.totalScore || 0,
difficulty: getDifficulty(item.totalScore || 0),
status: getStatus(item),
startTime: formatTime(item.startTime || '', item.endTime || ''),
endTime: item.endTime || '',
creator: item.createBy || '未知',
creationTime: item.createTime || ''
@ -248,7 +374,7 @@ const loadExamPaperList = async () => {
});
examData.value = mappedList;
totalItems.value = totalCount;
totalItems.value = mappedList.length; //
} catch (error) {
console.error('加载试卷列表失败:', error);
message.error('加载试卷列表失败');
@ -260,41 +386,53 @@ const loadExamPaperList = async () => {
};
const columns = createColumns({
handleAction: (action, row) => {
if(action === '试卷分析'){
//
const currentRoute = route.path;
if (currentRoute.includes('/course-editor/')) {
const courseId = route.params.id;
router.push(`/teacher/course-editor/${courseId}/practice/exam/analysis?examId=${row.id}`);
} else {
router.push({ name: 'ExamAnalysis', query: { examId: row.id } });
handleAction: (action: string, row: any) => {
try {
if (action === '试卷分析') {
//
const currentRoute = route.path;
if (currentRoute.includes('/course-editor/')) {
const courseId = route.params.id;
router.push(`/teacher/course-editor/${courseId}/practice/exam/analysis?examId=${row.id}`);
} else {
//
message.info('试卷分析功能正在开发中');
}
return;
}
return;
}
if(action === '批阅'){
//
const currentRoute = route.path;
if (currentRoute.includes('/course-editor/')) {
const courseId = route.params.id;
router.push(`/teacher/course-editor/${courseId}/practice/review/student-list/${row.id}`);
} else {
router.push({ name: 'StudentList', params: { paperId: row.id } });
if (action === '批阅') {
//
const currentRoute = route.path;
if (currentRoute.includes('/course-editor/')) {
const courseId = route.params.id;
router.push(`/teacher/course-editor/${courseId}/practice/review/student-list/${row.id}`);
} else {
//
message.info('批阅功能正在开发中');
}
return;
}
return;
}
if(action === '编辑'){
//
const currentRoute = route.path;
if (currentRoute.includes('/course-editor/')) {
const courseId = route.params.id;
router.push(`/teacher/course-editor/${courseId}/practice/exam/edit/${row.id}`);
} else {
router.push({ name: 'EditExam', params: { id: row.id } });
if (action === '编辑') {
//
const currentRoute = route.path;
if (currentRoute.includes('/course-editor/')) {
const courseId = route.params.id;
router.push(`/teacher/course-editor/${courseId}/practice/exam/edit/${row.id}`);
} else {
//
router.push(`/teacher/exam-management/edit/${row.id}`);
}
return;
}
return;
if (action === '删除') {
handleDeletePaper(row);
return;
}
message.info(`执行操作: ${action} on row ${row.id}`);
} catch (error) {
console.error('处理操作时发生错误:', error);
message.error('操作失败,请重试');
}
message.info(`执行操作: ${action} on row ${row.id}`);
},
});
@ -312,7 +450,7 @@ const totalItems = ref(0);
const paginationConfig = computed(() => ({
page: currentPage.value,
pageSize: pageSize.value,
itemCount: totalItems.value,
itemCount: filteredExamData.value.length,
pageSizes: [10, 20, 50, 100],
showSizePicker: true,
showQuickJumper: true,
@ -322,37 +460,164 @@ const paginationConfig = computed(() => ({
},
onUpdatePage: (page: number) => {
currentPage.value = page;
loadExamPaperList();
},
onUpdatePageSize: (newPageSize: number) => {
pageSize.value = newPageSize;
currentPage.value = 1;
loadExamPaperList();
}
}));
//
const handleSearch = () => {
//
let searchTimeout: NodeJS.Timeout | null = null;
//
const handleRealTimeSearch = () => {
//
if (searchTimeout) {
clearTimeout(searchTimeout);
}
// 300ms
searchTimeout = setTimeout(() => {
handleSearch();
}, 300);
};
// - API
const handleSearch = async () => {
//
currentPage.value = 1;
loadExamPaperList();
//
if (searchKeyword.value.trim()) {
isSearching.value = true;
//
await new Promise(resolve => setTimeout(resolve, 200));
isSearching.value = false;
}
};
const handleAddExam = () => {
//
const currentRoute = route.path;
if (currentRoute.includes('/course-editor/')) {
// 使 practice
const courseId = route.params.id;
router.push(`/teacher/course-editor/${courseId}/practice/exam/add`);
} else {
// 使
router.push({ name: 'AddExam' });
try {
//
const currentRoute = route.path;
if (currentRoute.includes('/course-editor/')) {
// 使 practice
const courseId = route.params.id;
router.push(`/teacher/course-editor/${courseId}/practice/exam/add`);
} else {
//
router.push('/teacher/exam-management/add');
}
} catch (error) {
console.error('跳转到添加试卷页面时发生错误:', error);
message.error('跳转失败,请重试');
}
};
//
const handleDeletePaper = async (row: any) => {
try {
// 使 Naive UI
dialog.warning({
title: '确认删除',
content: `确定要删除试卷"${row.name}"吗?此操作不可撤销。`,
positiveText: '确定删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
console.log('🗑️ 开始删除试卷:', row.id);
// API
const response = await ExamApi.deleteExamPaper(row.id);
console.log('✅ 删除试卷成功:', response);
//
message.success('试卷删除成功!');
//
await loadExamPaperList();
} catch (error) {
console.error('删除试卷失败:', error);
message.error('删除试卷失败,请重试');
}
}
});
} catch (error) {
console.error('删除试卷失败:', error);
message.error('删除试卷失败,请重试');
}
};
//
const handleBatchDelete = async () => {
if (checkedRowKeys.value.length === 0) {
message.warning('请先选择要删除的试卷');
return;
}
try {
// 使 Naive UI
dialog.error({
title: '确认批量删除',
content: `确定要删除选中的 ${checkedRowKeys.value.length} 个试卷吗?此操作不可撤销。`,
positiveText: '确定删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
console.log('🗑️ 开始批量删除试卷:', checkedRowKeys.value);
// API
const response = await ExamApi.batchDeleteExamPapers(checkedRowKeys.value as string[]);
console.log('✅ 批量删除试卷完成:', response);
//
if (response.data) {
const { success, failed, errors } = response.data;
if (failed === 0) {
//
message.success(`成功删除 ${success} 个试卷!`);
} else if (success > 0) {
//
message.warning(`删除完成:成功 ${success} 个,失败 ${failed}`);
if (errors.length > 0) {
console.warn('删除失败的试卷:', errors);
}
} else {
//
message.error(`删除失败:${failed} 个试卷删除失败`);
if (errors.length > 0) {
console.error('删除错误详情:', errors);
}
}
} else {
message.success(`成功删除 ${checkedRowKeys.value.length} 个试卷!`);
}
//
checkedRowKeys.value = [];
//
await loadExamPaperList();
} catch (error) {
console.error('批量删除试卷失败:', error);
message.error('批量删除试卷失败,请重试');
}
}
});
} catch (error) {
console.error('批量删除试卷失败:', error);
message.error('批量删除试卷失败,请重试');
}
};
//
onMounted(() => {
// 使
loadExamPaperList();
});
@ -388,4 +653,45 @@ onMounted(() => {
.exam-table {
margin-top: 20px;
}
/* 搜索过渡效果 */
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
transform: translateY(0);
}
/* 空状态样式 */
.empty-state {
padding: 40px 20px;
text-align: center;
color: #999;
}
.searching-state {
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.no-results {
color: #999;
font-size: 14px;
}
</style>

View File

@ -16,8 +16,18 @@
</div>
<n-data-table ref="tableRef" :columns="columns" :data="questionBankList" :loading="loading"
:pagination="paginationConfig" :row-key="(row: QuestionBank) => row.id" :checked-row-keys="selectedRowKeys"
@update:checked-row-keys="handleCheck" class="question-bank-table" :single-line="false" />
:row-key="(row: QuestionBank) => row.id" :checked-row-keys="selectedRowKeys"
@update:checked-row-keys="handleCheck" class="question-bank-table" :single-line="false" :key="paginationKey"
@contextmenu="handleContextMenu" />
<!-- 独立的分页器 -->
<div style="margin-top: 16px; display: flex; justify-content: flex-end;">
<n-pagination v-model:page="pagination.page" v-model:page-size="pagination.pageSize"
:item-count="pagination.total" :page-sizes="[10, 20, 50, 100]" show-size-picker show-quick-jumper
:prefix="(info: any) => `共 ${info.itemCount} 条`" @update:page="handlePageChange"
@update:page-size="handlePageSizeChange" />
</div>
<!-- 新建/编辑题库弹窗 -->
<n-modal v-model:show="showCreateModal" preset="dialog" :title="isEditMode ? '编辑题库' : '新建题库'"
@ -53,17 +63,9 @@
<p class="import-tip">请选择要导入的Excel文件支持.xlsx, .xls格式</p>
</div>
<n-upload
ref="uploadRef"
v-model:file-list="importFileList"
:max="1"
accept=".xlsx,.xls"
:custom-request="handleImportUpload"
@change="handleImportFileChange"
show-file-list
list-type="text"
:default-upload="false"
>
<n-upload ref="uploadRef" v-model:file-list="importFileList" :max="1" accept=".xlsx,.xls"
:custom-request="handleImportUpload" @change="handleImportFileChange" show-file-list
list-type="text" :default-upload="false">
<n-upload-dragger>
<div style="margin-bottom: 12px">
<n-icon size="48" :depth="3">
@ -92,12 +94,7 @@
<template #action>
<n-space>
<n-button @click="closeImportModal">取消</n-button>
<n-button
type="primary"
@click="startImport"
:loading="importing"
:disabled="!selectedImportFile"
>
<n-button type="primary" @click="startImport" :loading="importing" :disabled="!selectedImportFile">
{{ importing ? '导入中...' : '开始导入' }}
</n-button>
</n-space>
@ -107,7 +104,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, h, VNode } from 'vue';
import { ref, reactive, onMounted, h, VNode, watch, nextTick } from 'vue';
import { NButton, NSpace, NSelect, NIcon, NText, NP, NAlert, NUpload, NUploadDragger, useMessage, useDialog } from 'naive-ui';
import { CloudUploadOutline } from '@vicons/ionicons5';
import { useRouter, useRoute } from 'vue-router';
@ -154,6 +151,12 @@ const loading = ref(false);
const selectedRowKeys = ref<string[]>([]);
const questionBankList = ref<QuestionBank[]>([]);
// 使
// const contextMenuVisible = ref(false);
// const contextMenuX = ref(0);
// const contextMenuY = ref(0);
// const contextMenuRow = ref<QuestionBank | null>(null);
//
const courseOptions = ref<CourseOption[]>([]);
@ -187,28 +190,17 @@ const pagination = reactive({
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: { itemCount?: number }) => {
const itemCount = info.itemCount || 0;
return `${itemCount}`;
},
onUpdatePage: (page: number) => {
pagination.page = page;
loadQuestionBanks();
},
onUpdatePageSize: (pageSize: number) => {
pagination.pageSize = pageSize;
pagination.page = 1;
loadQuestionBanks();
}
}));
// key
const paginationKey = ref(0);
// pagination.total
watch(() => pagination.total, () => {
nextTick(() => {
paginationKey.value++;
});
});
// paginationConfig 使使 n-pagination
//
const createColumns = ({
@ -313,6 +305,15 @@ const createColumns = ({
}, { default: () => '编辑' })
);
buttons.push(
h(NButton, {
size: 'small',
type: 'info',
ghost: true,
onClick: () => handleAction('导出', row)
}, { default: () => '导出' })
);
buttons.push(
h(NButton, {
size: 'small',
@ -335,6 +336,8 @@ const columns = createColumns({
enterQuestionBank(row.id, row.name);
} else if (action === '编辑') {
editQuestionBank(row.id);
} else if (action === '导出') {
exportQuestionBank();
} else if (action === '删除') {
deleteQuestionBank(row.id);
}
@ -352,6 +355,18 @@ const searchQuestionBanks = () => {
loadQuestionBanks();
};
//
const handlePageChange = (page: number) => {
pagination.page = page;
loadQuestionBanks();
};
const handlePageSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize;
pagination.page = 1;
loadQuestionBanks();
};
// ID
const getCourseNameById = (courseId: string): string => {
const course = courseOptions.value.find(option => option.value === courseId);
@ -799,6 +814,12 @@ const closeImportModal = () => {
// message.info('...');
// };
//
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
//
};
//
onMounted(() => {
loadCourseList();

View File

@ -426,29 +426,7 @@ const columns = createColumns({
},
});
//
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 = customCategories.value.map(cat => cat.value);
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[]) => {
@ -504,7 +482,14 @@ const loadQuestions = async () => {
// API
const apiResponse = response.data as any;
if (apiResponse && (apiResponse.code === 200 || apiResponse.code === 0) && apiResponse.result) {
//
if (apiResponse && apiResponse.success === false &&
(apiResponse.message?.includes('该题库下没有题目') ||
apiResponse.message?.includes('该题库不存在'))) {
console.log('📝 题库没有题目,显示空状态');
allData = [];
} else if (apiResponse && (apiResponse.code === 200 || apiResponse.code === 0) && apiResponse.result) {
// API
allData = apiResponse.result.map((item: any, index: number) => ({
id: item.id || `question_${index}`,
@ -521,8 +506,8 @@ const loadQuestions = async () => {
}));
console.log('✅ 题目数据转换成功:', allData.length, '条题目');
} else {
console.warn('⚠️ 题目列表API返回异常使用模拟数据');
allData = generateMockData();
console.warn('⚠️ 题目列表API返回异常数据');
allData = [];
}
//
@ -547,27 +532,24 @@ const loadQuestions = async () => {
console.log('📊 题目列表加载完成:', questionList.value.length, '条题目');
} catch (error) {
} catch (error: any) {
console.error('❌ 加载题目失败:', error);
// 使
const allData = generateMockData();
let filteredData = allData;
if (filters.category) {
filteredData = filteredData.filter(item => item.category.includes(filters.category));
// ""
if (error?.message?.includes('该题库下没有题目') ||
error?.message?.includes('该题库不存在') ||
error?.response?.data?.message?.includes('该题库下没有题目') ||
error?.response?.data?.message?.includes('该题库不存在')) {
console.log('📝 题库没有题目,显示空状态');
questionList.value = [];
pagination.total = 0;
//
} else {
//
message.error('加载题目失败,请稍后重试');
questionList.value = [];
pagination.total = 0;
}
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);
} finally {
loading.value = false;
}
@ -591,21 +573,106 @@ const importQuestions = () => {
showImportModal.value = true;
};
const exportQuestions = () => {
console.log('导出题目');
const exportQuestions = async () => {
try {
console.log('🚀 开始导出题目题库ID:', currentBankId.value);
message.loading('正在导出题目,请稍候...', { duration: 0 });
const response = await ExamApi.exportQuestions(currentBankId.value);
if (!response || !(response instanceof Blob)) {
throw new Error('服务器返回的数据格式不正确');
}
if (response.size === 0) {
throw new Error('题目文件为空');
}
//
const url = window.URL.createObjectURL(response);
const link = document.createElement('a');
link.href = url;
link.download = `${currentBankTitle.value || '题库'}_题目.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.destroyAll();
message.success('题目导出成功!');
} catch (error: any) {
message.destroyAll();
console.error('❌ 导出题目失败:', error);
message.error(error.message || '导出题目失败,请稍后重试');
}
};
//
const handleImportSuccess = (result: any) => {
const handleImportSuccess = async (result: any) => {
console.log('导入成功:', result);
//
loadQuestions();
try {
if (result.file) {
message.loading('正在处理导入文件...', { duration: 0 });
// FormData
const formData = new FormData();
formData.append('file', result.file);
// API
const response = await ExamApi.importQuestions(currentBankId.value, formData);
console.log('📊 导入题目API响应:', response);
message.destroyAll();
message.success('题目导入成功!');
//
await loadQuestions();
} else {
message.success('题目导入成功!');
await loadQuestions();
}
} catch (error: any) {
message.destroyAll();
console.error('❌ 导入处理失败:', error);
message.error(error.message || '导入处理失败,请重试');
}
};
//
const handleTemplateDownload = (type?: string) => {
console.log('下载模板:', type);
// TODO: API
const handleTemplateDownload = async (type?: string) => {
try {
console.log('🚀 下载题目模板:', type);
message.loading('正在下载模板...', { duration: 0 });
const response = await ExamApi.downloadTemplate();
if (!response || !(response instanceof Blob)) {
throw new Error('服务器返回的数据格式不正确');
}
if (response.size === 0) {
throw new Error('模板文件为空');
}
//
const url = window.URL.createObjectURL(response);
const link = document.createElement('a');
link.href = url;
link.download = '题目导入模板.xlsx';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.destroyAll();
message.success('模板下载成功!');
} catch (error: any) {
message.destroyAll();
console.error('❌ 下载模板失败:', error);
message.error(error.message || '模板下载失败,请稍后重试');
}
};
const deleteSelected = () => {
@ -616,43 +683,118 @@ const editQuestion = async (id: string) => {
console.log('🔍 编辑题目题目ID:', id);
try {
//
console.log('🚀 调用题目详情接口...');
const response = await ExamApi.getQuestionDetail(id);
console.log('📊 题目详情API响应:', response);
//
console.log('🚀 调用题目详情和选项接口...');
console.log('🔍 题目ID:', id);
// API
const [questionResponse, optionsResponse] = await Promise.allSettled([
ExamApi.getQuestionDetail(id),
ExamApi.getQuestionOptions(id)
]);
//
let questionData = null;
let success = false;
if (response.data) {
// 使API
const apiResponse = response.data as any;
if (questionResponse.status === 'fulfilled') {
const response = questionResponse.value;
console.log('📊 题目详情API响应:', response);
// {success, code, result}
if (typeof apiResponse === 'object' && 'result' in apiResponse) {
success = apiResponse.success === true || apiResponse.code === 200 || apiResponse.code === 0;
questionData = apiResponse.result;
if (response.data) {
const apiResponse = response.data as any;
console.log('🔍 题目详情API原始响应:', apiResponse);
// {success, code, result}
if (typeof apiResponse === 'object' && 'result' in apiResponse) {
success = apiResponse.success === true || apiResponse.code === 200 || apiResponse.code === 0;
questionData = apiResponse.result;
} else {
//
success = true;
questionData = apiResponse;
}
console.log('🔍 解析后的题目数据:', questionData);
} else {
//
success = true;
questionData = apiResponse;
console.warn('⚠️ 题目详情API响应数据为空');
}
} else {
console.error('❌ 题目详情API调用失败:', questionResponse.reason);
}
//
let optionsData = [];
if (optionsResponse.status === 'fulfilled') {
const response = optionsResponse.value;
console.log('📊 题目选项API响应:', response);
if (response.data) {
const apiResponse = response.data as any;
console.log('🔍 选项API原始响应:', apiResponse);
console.log('🔍 选项API result字段:', apiResponse.result);
console.log('🔍 选项API result类型:', typeof apiResponse.result);
console.log('🔍 选项API result是否为数组:', Array.isArray(apiResponse.result));
if (Array.isArray(apiResponse)) {
optionsData = apiResponse;
console.log('✅ 选项数据是数组格式');
} else if (apiResponse && Array.isArray(apiResponse.result)) {
optionsData = apiResponse.result;
console.log('✅ 选项数据在result字段中');
} else if (apiResponse && apiResponse.success && Array.isArray(apiResponse.result)) {
optionsData = apiResponse.result;
console.log('✅ 选项数据在success.result字段中');
} else if (apiResponse && apiResponse.success && apiResponse.result) {
// result
if (Array.isArray(apiResponse.result)) {
optionsData = apiResponse.result;
console.log('✅ 选项数据在success.result字段中数组');
} else if (apiResponse.result && apiResponse.result.records) {
// records
optionsData = apiResponse.result.records || [];
console.log('✅ 选项数据在分页格式的records字段中');
} else {
//
optionsData = [apiResponse.result];
console.log('✅ 选项数据是单个对象,转换为数组');
}
} else {
console.warn('⚠️ 选项数据格式未知:', apiResponse);
}
console.log('🔍 解析后的选项数据:', optionsData);
} else {
console.warn('⚠️ 选项API响应数据为空');
}
} else {
console.error('❌ 题目选项API调用失败:', optionsResponse.reason);
}
if (success && questionData) {
console.log('✅ 获取题目详情成功:', questionData);
console.log('✅ 获取选项数据成功:', optionsData);
console.log('🔍 选项数据长度:', optionsData.length);
//
//
const completeQuestionData = {
question: questionData,
answer: optionsData, // answer
children: [] //
};
console.log('🔍 完整题目数据:', completeQuestionData);
//
router.push({
path: `/teacher/exam-management/add-question/${currentBankId.value}/${id}`,
query: {
questionData: JSON.stringify(questionData),
questionData: JSON.stringify(completeQuestionData),
mode: 'edit'
}
});
} else {
console.error('❌ 获取题目详情失败:', response);
console.error('❌ 获取题目详情失败:', questionResponse);
message.error('获取题目详情失败');
}
} catch (error) {