Merge branch 'dev' of http://110.42.96.65:19890/GoCo/OL-LearnPlatform-Frontend into dev
BIN
public/images/personal/切换_switch备份 2@2x.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
public/images/personal/切换_switch备份@2x.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
public/images/personal/未读消息_message-unread备份@2x.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
public/images/personal/用户_user备份 2@2x.png
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
public/images/personal/用户_user备份@2x.png
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
public/images/personal/矩形备份 35@2x.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
public/images/personal/退出_logout备份 2@2x.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
public/images/personal/退出_logout备份 3@2x.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
12
src/App.vue
@ -3,8 +3,14 @@ import { onMounted, computed } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import { NConfigProvider, NMessageProvider } from 'naive-ui'
|
||||
import { NConfigProvider, NMessageProvider, NDialogProvider, zhCN, dateZhCN, enUS, dateEnUS } from 'naive-ui'
|
||||
import type { GlobalThemeOverrides } from 'naive-ui';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { locale } = useI18n()
|
||||
|
||||
// 响应式获取naive-ui语言包
|
||||
const naiveLocale = computed(() => locale.value === 'en' ? enUS : zhCN)
|
||||
const naiveDateLocale = computed(() => locale.value === 'en' ? dateEnUS : dateZhCN)
|
||||
|
||||
|
||||
// 自定义naive-ui主题颜色
|
||||
@ -71,7 +77,8 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<n-config-provider :theme-overrides="themeOverrides">
|
||||
<n-config-provider :theme-overrides="themeOverrides" :locale="naiveLocale" :date-locale="naiveDateLocale">
|
||||
<n-dialog-provider>
|
||||
<!-- 登录页面不使用 AppLayout,但需要 message provider -->
|
||||
<template v-if="isLoginPage">
|
||||
<n-message-provider>
|
||||
@ -84,6 +91,7 @@ onMounted(() => {
|
||||
<RouterView />
|
||||
</AppLayout>
|
||||
</template>
|
||||
</n-dialog-provider>
|
||||
</n-config-provider>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -10,6 +10,7 @@ export { default as FavoriteApi } from './modules/favorite'
|
||||
export { default as OrderApi } from './modules/order'
|
||||
export { default as UploadApi } from './modules/upload'
|
||||
export { default as StatisticsApi } from './modules/statistics'
|
||||
export { default as ExamApi } from './modules/exam'
|
||||
|
||||
// API 基础配置
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/jeecgboot'
|
||||
@ -183,6 +184,37 @@ export const API_ENDPOINTS = {
|
||||
EXPORT: '/statistics/export/:type',
|
||||
},
|
||||
|
||||
// 考试题库相关
|
||||
EXAM: {
|
||||
// 题库管理
|
||||
REPO_CREATE: '/biz/repo/courseAdd',
|
||||
REPO_LIST: '/biz/repo/repoList',
|
||||
REPO_DELETE: '/gen/repo/repo/delete',
|
||||
REPO_EDIT: '/gen/repo/repo/edit',
|
||||
|
||||
// 题目管理
|
||||
QUESTION_LIST: '/biz/repo/questionList/:repoId',
|
||||
QUESTION_DETAIL: '/biz/repo/repoList/:questionId',
|
||||
QUESTION_ADD: '/gen/question/question/add',
|
||||
QUESTION_EDIT: '/gen/question/question/edit',
|
||||
QUESTION_DELETE: '/gen/question/question/delete',
|
||||
|
||||
// 题目选项管理
|
||||
QUESTION_OPTION_ADD: '/gen/questionoption/questionOption/add',
|
||||
QUESTION_OPTION_EDIT: '/gen/questionoption/questionOption/edit',
|
||||
QUESTION_OPTION_DELETE: '/gen/questionoption/questionOption/delete',
|
||||
|
||||
// 题目答案管理
|
||||
QUESTION_ANSWER_ADD: '/gen/questionanswer/questionAnswer/add',
|
||||
QUESTION_ANSWER_EDIT: '/gen/questionanswer/questionAnswer/edit',
|
||||
QUESTION_ANSWER_DELETE: '/gen/questionanswer/questionAnswer/delete',
|
||||
|
||||
// 题库题目关联管理
|
||||
QUESTION_REPO_ADD: '/gen/questionrepo/questionRepo/add',
|
||||
QUESTION_REPO_EDIT: '/gen/questionrepo/questionRepo/edit',
|
||||
QUESTION_REPO_DELETE: '/gen/questionrepo/questionRepo/delete',
|
||||
},
|
||||
|
||||
// 学习进度相关
|
||||
LEARNING: {
|
||||
PROGRESS: '/learning-progress',
|
||||
|
289
src/api/modules/exam.ts
Normal file
@ -0,0 +1,289 @@
|
||||
// 考试题库相关API接口
|
||||
import { ApiRequest } from '../request'
|
||||
import type {
|
||||
ApiResponse,
|
||||
Repo,
|
||||
Question,
|
||||
CreateRepoRequest,
|
||||
UpdateRepoRequest,
|
||||
CreateQuestionRequest,
|
||||
UpdateQuestionRequest,
|
||||
CreateQuestionOptionRequest,
|
||||
UpdateQuestionOptionRequest,
|
||||
CreateQuestionAnswerRequest,
|
||||
UpdateQuestionAnswerRequest,
|
||||
CreateQuestionRepoRequest,
|
||||
UpdateQuestionRepoRequest,
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* 考试题库API模块
|
||||
*/
|
||||
export class ExamApi {
|
||||
|
||||
// ========== 题库管理 ==========
|
||||
|
||||
/**
|
||||
* 课程新建题库
|
||||
*/
|
||||
static async createCourseRepo(data: CreateRepoRequest): Promise<ApiResponse<string>> {
|
||||
console.log('🚀 创建课程题库:', data)
|
||||
const response = await ApiRequest.post<string>('/biz/repo/courseAdd', data)
|
||||
console.log('✅ 创建课程题库成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取课程题库
|
||||
*/
|
||||
static async getCourseRepoList(): Promise<ApiResponse<Repo[]>> {
|
||||
const response = await ApiRequest.get<Repo[]>(`/biz/repo/repoList`)
|
||||
console.log('✅ 获取课程题库列表成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除题库
|
||||
*/
|
||||
static async deleteRepo(id: string): Promise<ApiResponse<string>> {
|
||||
console.log('🚀 删除题库:', { id })
|
||||
const response = await ApiRequest.delete<string>('/gen/repo/repo/delete', {
|
||||
params: { id }
|
||||
})
|
||||
console.log('✅ 删除题库成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑题库
|
||||
*/
|
||||
static async updateRepo(data: UpdateRepoRequest): Promise<ApiResponse<string>> {
|
||||
console.log('🚀 编辑题库:', data)
|
||||
const response = await ApiRequest.put<string>('/gen/repo/repo/edit', data)
|
||||
console.log('✅ 编辑题库成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
// ========== 题目管理 ==========
|
||||
|
||||
/**
|
||||
* 查询题库下题目
|
||||
*/
|
||||
static async getQuestionsByRepo(repoId: string): Promise<ApiResponse<Question[]>> {
|
||||
console.log('🚀 查询题库下题目:', { repoId })
|
||||
const response = await ApiRequest.get<Question[]>(`/biz/repo/questionList/${repoId}`)
|
||||
console.log('✅ 查询题库下题目成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询题目详情
|
||||
*/
|
||||
static async getQuestionDetail(questionId: string): Promise<ApiResponse<Question>> {
|
||||
console.log('🚀 查询题目详情:', { questionId })
|
||||
const response = await ApiRequest.get<Question>(`/biz/repo/repoList/${questionId}`)
|
||||
console.log('✅ 查询题目详情成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加题目
|
||||
*/
|
||||
static async createQuestion(data: CreateQuestionRequest): Promise<ApiResponse<string>> {
|
||||
console.log('🚀 添加题目:', data)
|
||||
const response = await ApiRequest.post<string>('/gen/question/question/add', data)
|
||||
console.log('✅ 添加题目成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑题目
|
||||
*/
|
||||
static async updateQuestion(data: UpdateQuestionRequest): Promise<ApiResponse<string>> {
|
||||
console.log('🚀 编辑题目:', data)
|
||||
const response = await ApiRequest.put<string>('/gen/question/question/edit', data)
|
||||
console.log('✅ 编辑题目成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除题目
|
||||
*/
|
||||
static async deleteQuestion(id: string): Promise<ApiResponse<string>> {
|
||||
console.log('🚀 删除题目:', { id })
|
||||
const response = await ApiRequest.delete<string>('/gen/question/question/delete', {
|
||||
params: { id }
|
||||
})
|
||||
console.log('✅ 删除题目成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
// ========== 题目选项管理 ==========
|
||||
|
||||
/**
|
||||
* 添加题目选项
|
||||
*/
|
||||
static async createQuestionOption(data: CreateQuestionOptionRequest): Promise<ApiResponse<string>> {
|
||||
console.log('🚀 添加题目选项:', data)
|
||||
const response = await ApiRequest.post<string>('/gen/questionoption/questionOption/add', data)
|
||||
console.log('✅ 添加题目选项成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑题目选项
|
||||
*/
|
||||
static async updateQuestionOption(data: UpdateQuestionOptionRequest): Promise<ApiResponse<string>> {
|
||||
console.log('🚀 编辑题目选项:', data)
|
||||
const response = await ApiRequest.put<string>('/gen/questionoption/questionOption/edit', data)
|
||||
console.log('✅ 编辑题目选项成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除题目选项
|
||||
*/
|
||||
static async deleteQuestionOption(id: string): Promise<ApiResponse<string>> {
|
||||
console.log('🚀 删除题目选项:', { id })
|
||||
const response = await ApiRequest.delete<string>('/gen/questionoption/questionOption/delete', {
|
||||
params: { id }
|
||||
})
|
||||
console.log('✅ 删除题目选项成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
// ========== 题目答案管理 ==========
|
||||
|
||||
/**
|
||||
* 添加题目答案
|
||||
*/
|
||||
static async createQuestionAnswer(data: CreateQuestionAnswerRequest): Promise<ApiResponse<string>> {
|
||||
console.log('🚀 添加题目答案:', data)
|
||||
const response = await ApiRequest.post<string>('/gen/questionanswer/questionAnswer/add', data)
|
||||
console.log('✅ 添加题目答案成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑题目答案
|
||||
*/
|
||||
static async updateQuestionAnswer(data: UpdateQuestionAnswerRequest): Promise<ApiResponse<string>> {
|
||||
console.log('🚀 编辑题目答案:', data)
|
||||
const response = await ApiRequest.put<string>('/gen/questionanswer/questionAnswer/edit', data)
|
||||
console.log('✅ 编辑题目答案成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除题目答案
|
||||
*/
|
||||
static async deleteQuestionAnswer(id: string): Promise<ApiResponse<string>> {
|
||||
console.log('🚀 删除题目答案:', { id })
|
||||
const response = await ApiRequest.delete<string>('/gen/questionanswer/questionAnswer/delete', {
|
||||
params: { id }
|
||||
})
|
||||
console.log('✅ 删除题目答案成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
// ========== 题库题目关联管理 ==========
|
||||
|
||||
/**
|
||||
* 添加题库题目关联
|
||||
*/
|
||||
static async createQuestionRepo(data: CreateQuestionRepoRequest): Promise<ApiResponse<string>> {
|
||||
console.log('🚀 添加题库题目关联:', data)
|
||||
const response = await ApiRequest.post<string>('/gen/questionrepo/questionRepo/add', data)
|
||||
console.log('✅ 添加题库题目关联成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑题库题目关联
|
||||
*/
|
||||
static async updateQuestionRepo(data: UpdateQuestionRepoRequest): Promise<ApiResponse<string>> {
|
||||
console.log('🚀 编辑题库题目关联:', data)
|
||||
const response = await ApiRequest.put<string>('/gen/questionrepo/questionRepo/edit', data)
|
||||
console.log('✅ 编辑题库题目关联成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除题库题目关联
|
||||
*/
|
||||
static async deleteQuestionRepo(id: string): Promise<ApiResponse<string>> {
|
||||
console.log('🚀 删除题库题目关联:', { id })
|
||||
const response = await ApiRequest.delete<string>('/gen/questionrepo/questionRepo/delete', {
|
||||
params: { id }
|
||||
})
|
||||
console.log('✅ 删除题库题目关联成功:', response)
|
||||
return response
|
||||
}
|
||||
|
||||
// ========== 常用工具方法 ==========
|
||||
|
||||
/**
|
||||
* 题目类型映射
|
||||
*/
|
||||
static getQuestionTypeText(type: number): string {
|
||||
const typeMap: Record<number, string> = {
|
||||
0: '单选题',
|
||||
1: '多选题',
|
||||
2: '判断题',
|
||||
3: '填空题',
|
||||
4: '简答题',
|
||||
5: '复合题'
|
||||
}
|
||||
return typeMap[type] || '未知类型'
|
||||
}
|
||||
|
||||
/**
|
||||
* 难度等级映射
|
||||
*/
|
||||
static getDifficultyText(difficulty: number): string {
|
||||
const difficultyMap: Record<number, string> = {
|
||||
1: '简单',
|
||||
2: '中等',
|
||||
3: '困难'
|
||||
}
|
||||
return difficultyMap[difficulty] || '未知难度'
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加题目选项
|
||||
*/
|
||||
static async batchCreateQuestionOptions(
|
||||
questionId: string,
|
||||
options: Omit<CreateQuestionOptionRequest, 'questionId'>[]
|
||||
): Promise<ApiResponse<string>[]> {
|
||||
console.log('🚀 批量添加题目选项:', { questionId, options })
|
||||
|
||||
const promises = options.map(option =>
|
||||
this.createQuestionOption({ ...option, questionId })
|
||||
)
|
||||
|
||||
const responses = await Promise.all(promises)
|
||||
console.log('✅ 批量添加题目选项成功:', responses)
|
||||
return responses
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加题目答案
|
||||
*/
|
||||
static async batchCreateQuestionAnswers(
|
||||
questionId: string,
|
||||
answers: Omit<CreateQuestionAnswerRequest, 'questionId'>[]
|
||||
): Promise<ApiResponse<string>[]> {
|
||||
console.log('🚀 批量添加题目答案:', { questionId, answers })
|
||||
|
||||
const promises = answers.map(answer =>
|
||||
this.createQuestionAnswer({ ...answer, questionId })
|
||||
)
|
||||
|
||||
const responses = await Promise.all(promises)
|
||||
console.log('✅ 批量添加题目答案成功:', responses)
|
||||
return responses
|
||||
}
|
||||
}
|
||||
|
||||
export default ExamApi
|
131
src/api/types.ts
@ -684,3 +684,134 @@ export interface Statistics {
|
||||
count: number
|
||||
}>
|
||||
}
|
||||
|
||||
// 考试题库相关类型
|
||||
export interface Repo {
|
||||
id: string
|
||||
title: string
|
||||
remark: string
|
||||
questionCount?: number
|
||||
createBy: string
|
||||
createTime: string
|
||||
updateBy: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: string
|
||||
parentId: string
|
||||
type: number // 0单选题 1多选题 2判断题 3填空题 4简答题 5复合题
|
||||
content: string
|
||||
analysis: string
|
||||
difficulty: number
|
||||
score: number
|
||||
createBy: string
|
||||
createTime: string
|
||||
updateBy: string
|
||||
updateTime: string
|
||||
options?: QuestionOption[]
|
||||
answers?: QuestionAnswer[]
|
||||
}
|
||||
|
||||
export interface QuestionOption {
|
||||
id: string
|
||||
questionId: string
|
||||
content: string
|
||||
izCorrent: number // 是否正确答案
|
||||
orderNo: number
|
||||
createBy: string
|
||||
createTime: string
|
||||
updateBy: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
export interface QuestionAnswer {
|
||||
id: string
|
||||
questionId: string
|
||||
answerText: string
|
||||
orderNo: number
|
||||
createBy: string
|
||||
createTime: string
|
||||
updateBy: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
export interface QuestionRepo {
|
||||
id: string
|
||||
repoId: string
|
||||
questionId: string
|
||||
createBy: string
|
||||
createTime: string
|
||||
updateBy: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
// 题库相关请求参数类型
|
||||
export interface CreateRepoRequest {
|
||||
title: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface UpdateRepoRequest {
|
||||
id: string
|
||||
title: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface CreateQuestionRequest {
|
||||
parentId?: string
|
||||
type: number
|
||||
content: string
|
||||
analysis?: string
|
||||
difficulty: number
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface UpdateQuestionRequest {
|
||||
id: string
|
||||
parentId?: string
|
||||
type: number
|
||||
content: string
|
||||
analysis?: string
|
||||
difficulty: number
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface CreateQuestionOptionRequest {
|
||||
questionId: string
|
||||
content: string
|
||||
izCorrent: number
|
||||
orderNo: number
|
||||
}
|
||||
|
||||
export interface UpdateQuestionOptionRequest {
|
||||
id: string
|
||||
questionId: string
|
||||
content: string
|
||||
izCorrent: number
|
||||
orderNo: number
|
||||
}
|
||||
|
||||
export interface CreateQuestionAnswerRequest {
|
||||
questionId: string
|
||||
answerText: string
|
||||
orderNo: number
|
||||
}
|
||||
|
||||
export interface UpdateQuestionAnswerRequest {
|
||||
id: string
|
||||
questionId: string
|
||||
answerText: string
|
||||
orderNo: number
|
||||
}
|
||||
|
||||
export interface CreateQuestionRepoRequest {
|
||||
repoId: string
|
||||
questionId: string
|
||||
}
|
||||
|
||||
export interface UpdateQuestionRepoRequest {
|
||||
id: string
|
||||
repoId: string
|
||||
questionId: string
|
||||
}
|
||||
|
@ -12,6 +12,8 @@
|
||||
class="big-question-section">
|
||||
<div v-for="(subQuestion, subIndex) in bigQuestion.subQuestions" :key="subQuestion.id"
|
||||
class="question-item">
|
||||
<!-- 普通题目显示 -->
|
||||
<template v-if="subQuestion.type !== 'composite'">
|
||||
<div class="question-info">
|
||||
<span class="question-number">{{ bigIndex + 1 }}.{{ subIndex + 1 }}</span>
|
||||
<div class="question-content">
|
||||
@ -30,6 +32,53 @@
|
||||
@update:value="updateQuestionScore(bigIndex, subIndex, $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 复合题目显示 -->
|
||||
<template v-else>
|
||||
<div class="composite-question-container">
|
||||
<!-- 复合题主体 -->
|
||||
<div class="composite-main">
|
||||
<div class="question-info">
|
||||
<span class="question-number composite">{{ bigIndex + 1 }}.{{ subIndex + 1 }}</span>
|
||||
<div class="question-content">
|
||||
{{ subQuestion.title }}
|
||||
</div>
|
||||
<span class="question-type composite">{{ getQuestionTypeName(subQuestion.type) }}</span>
|
||||
</div>
|
||||
<div class="question-score">
|
||||
<span class="score-label">总分:</span>
|
||||
<span class="total-score">{{ getCompositeQuestionTotalScore(subQuestion) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 复合题的子题目 -->
|
||||
<div class="composite-sub-questions">
|
||||
<div v-for="(compositeSubQ, compositeIndex) in (subQuestion.subQuestions || [])"
|
||||
:key="compositeSubQ.id"
|
||||
class="composite-sub-item">
|
||||
<div class="question-info sub">
|
||||
<span class="question-number sub">{{ bigIndex + 1 }}.{{ subIndex + 1 }}.{{ compositeIndex + 1 }}</span>
|
||||
<div class="question-content sub">
|
||||
{{ compositeSubQ.title }}
|
||||
</div>
|
||||
<span class="question-type sub">{{ getQuestionTypeName(compositeSubQ.type) }}</span>
|
||||
</div>
|
||||
<div class="question-score">
|
||||
<span class="score-label">分数:</span>
|
||||
<n-input-number
|
||||
v-model:value="compositeSubQ.score"
|
||||
size="small"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:precision="1"
|
||||
@update:value="updateCompositeSubQuestionScore(bigIndex, subIndex, compositeIndex, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -159,6 +208,33 @@ const updateQuestionScore = (bigIndex: number, subIndex: number, score: number)
|
||||
}
|
||||
};
|
||||
|
||||
// 获取复合题总分
|
||||
const getCompositeQuestionTotalScore = (subQuestion: SubQuestion): number => {
|
||||
if (subQuestion.type === QuestionType.COMPOSITE && subQuestion.subQuestions) {
|
||||
return subQuestion.subQuestions.reduce((total, sq) => total + (sq.score || 0), 0);
|
||||
}
|
||||
return subQuestion.score || 0;
|
||||
};
|
||||
|
||||
// 更新复合题子题目分数
|
||||
const updateCompositeSubQuestionScore = (bigIndex: number, subIndex: number, compositeIndex: number, score: number) => {
|
||||
const bigQuestion = questionList.value[bigIndex];
|
||||
const subQuestion = bigQuestion?.subQuestions[subIndex];
|
||||
|
||||
if (subQuestion && subQuestion.type === QuestionType.COMPOSITE && subQuestion.subQuestions) {
|
||||
const compositeSubQ = subQuestion.subQuestions[compositeIndex];
|
||||
if (compositeSubQ) {
|
||||
compositeSubQ.score = score || 0;
|
||||
|
||||
// 重新计算复合题总分
|
||||
subQuestion.score = subQuestion.subQuestions.reduce((total, sq) => total + (sq.score || 0), 0);
|
||||
|
||||
// 重新计算大题总分
|
||||
bigQuestion.totalScore = bigQuestion.subQuestions.reduce((total, sub) => total + (sub.score || 0), 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 取消批量设置
|
||||
const cancelBatchSet = () => {
|
||||
showModal.value = false;
|
||||
@ -231,6 +307,41 @@ const confirmBatchSet = () => {
|
||||
border-color: #d1e7dd;
|
||||
}
|
||||
|
||||
.composite-question {
|
||||
background-color: #f0f8ff;
|
||||
border-left: 3px solid #1890ff;
|
||||
}
|
||||
|
||||
.composite-sub-question {
|
||||
margin: 8px 0 8px 20px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.composite-sub-question::before {
|
||||
content: "└";
|
||||
position: absolute;
|
||||
left: -15px;
|
||||
top: 8px;
|
||||
color: #1890ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.composite-sub-header {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.total-score {
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.question-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -21,6 +21,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 考试人数限制 -->
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">考试人数</label>
|
||||
<n-input v-model:value="formData.maxParticipants" type="number" :min="1" placeholder="请输入考试人数上限" class="setting-input" />
|
||||
</div>
|
||||
<!-- 试卷分类 -->
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">试卷分类</label>
|
||||
@ -323,6 +328,8 @@ interface ExamSettings {
|
||||
useLastScore: boolean; // 最后一次练习成绩为最终成绩
|
||||
};
|
||||
paperMode: 'show_all' | 'show_current' | 'hide_all';
|
||||
// 新增考试人数限制字段
|
||||
maxParticipants: number | null;
|
||||
}
|
||||
|
||||
// Props 定义
|
||||
@ -396,6 +403,8 @@ const formData = ref<ExamSettings>({
|
||||
useLastScore: false,
|
||||
},
|
||||
paperMode: 'show_all',
|
||||
// 新增考试人数限制字段
|
||||
maxParticipants: null,
|
||||
});
|
||||
|
||||
// 监听 props.examData 变化,更新本地副本
|
||||
|
@ -286,11 +286,33 @@ const initializePlayer = async (videoUrl?: string) => {
|
||||
})
|
||||
|
||||
player.on('error', (error: any) => {
|
||||
console.error('DPlayer 播放错误:', error)
|
||||
// 检查是否为无害的错误
|
||||
const isHarmlessError = (
|
||||
!error.message || // 没有错误消息通常是无害的
|
||||
error.message === undefined ||
|
||||
(player && player.video && !player.video.error) // 播放器本身没有错误
|
||||
)
|
||||
|
||||
if (isHarmlessError && player && player.video && player.video.readyState > 0) {
|
||||
console.warn('⚠️ 检测到可能的假阳性错误,但视频仍可播放:', {
|
||||
type: error.type,
|
||||
message: error.message,
|
||||
url: url,
|
||||
videoReadyState: player.video.readyState,
|
||||
videoPaused: player.video.paused
|
||||
})
|
||||
// 不触发错误事件,因为视频实际上是可以播放的
|
||||
return
|
||||
}
|
||||
|
||||
console.error('❌ DPlayer 播放错误:', error)
|
||||
console.error('错误详情:', {
|
||||
type: error.type,
|
||||
message: error.message,
|
||||
url: url
|
||||
url: url,
|
||||
videoError: player?.video?.error,
|
||||
networkState: player?.video?.networkState,
|
||||
readyState: player?.video?.readyState
|
||||
})
|
||||
emit('error', error)
|
||||
})
|
||||
@ -442,7 +464,8 @@ const switchQuality = (quality: any) => {
|
||||
from: getCurrentQualityLabel(),
|
||||
to: quality.label,
|
||||
currentTime: currentTime,
|
||||
wasPlaying: wasPlaying
|
||||
wasPlaying: wasPlaying,
|
||||
newUrl: quality.url
|
||||
})
|
||||
|
||||
// 暂停播放
|
||||
@ -450,25 +473,15 @@ const switchQuality = (quality: any) => {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
// 切换视频源
|
||||
if (typeof player.switchVideo === 'function') {
|
||||
player.switchVideo({
|
||||
url: quality.url,
|
||||
type: 'auto'
|
||||
})
|
||||
// 销毁当前播放器
|
||||
if (player) {
|
||||
player.destroy()
|
||||
player = null
|
||||
}
|
||||
|
||||
// 恢复播放状态
|
||||
setTimeout(() => {
|
||||
if (player && player.video) {
|
||||
player.seek(currentTime)
|
||||
if (wasPlaying) {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
// 重新初始化播放器
|
||||
// 重新初始化播放器使用新的URL
|
||||
initializePlayer(quality.url).then(() => {
|
||||
console.log('✅ 播放器重新初始化完成,新URL:', quality.url)
|
||||
// 恢复播放时间
|
||||
setTimeout(() => {
|
||||
if (player && player.video) {
|
||||
@ -476,11 +489,14 @@ const switchQuality = (quality: any) => {
|
||||
if (wasPlaying) {
|
||||
player.play()
|
||||
}
|
||||
console.log('✅ 恢复播放状态:', { currentTime, wasPlaying })
|
||||
}
|
||||
}, 500)
|
||||
}).catch(error => {
|
||||
console.error('❌ 重新初始化播放器失败:', error)
|
||||
})
|
||||
}
|
||||
|
||||
// 通知父组件清晰度已切换
|
||||
emit('qualityChange', quality.value)
|
||||
console.log('✅ 切换清晰度到:', quality.label)
|
||||
} catch (error) {
|
||||
|
@ -38,10 +38,6 @@
|
||||
<div class="nav-item" :class="{ active: activeKey === 'ai' }" @click="handleMenuSelect('ai')">
|
||||
<span class="nav-item-ai">AI体验</span>
|
||||
</div>
|
||||
|
||||
<!-- <div class="nav-item" :class="{ active: activeKey === 'practice' }" @click="handleMenuSelect('practice')">
|
||||
<span class="nav-item-practice">AI伴学</span>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
@ -54,10 +50,10 @@
|
||||
|
||||
<!-- 移动端汉堡菜单按钮 -->
|
||||
<div class="mobile-menu-toggle" @click="toggleMobileMenu">
|
||||
<n-icon size="24">
|
||||
<NIcon size="24">
|
||||
<MenuOutline v-if="!mobileMenuOpen" />
|
||||
<CloseOutline v-else />
|
||||
</n-icon>
|
||||
</NIcon>
|
||||
</div>
|
||||
|
||||
<!-- 右侧操作区域 -->
|
||||
@ -84,31 +80,32 @@
|
||||
<span>{{ t('header.learningCenter') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 管理端 -->
|
||||
<!-- 管理端
|
||||
<div class="action-item">
|
||||
<img src="/nav-icons/管理端.png" alt="" class="action-icon default-icon" />
|
||||
<img src="/nav-icons/管理端-选中.png" alt="" class="action-icon hover-icon" />
|
||||
<span>{{ t('header.management') }}</span>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<div v-if="!userStore.isLoggedIn" class="auth-buttons" @click="showLoginModal">
|
||||
<div class="auth-combined-btn">
|
||||
<span class="auth-login" >{{ t('header.login') }}</span>
|
||||
<span class="auth-login">{{ t('header.login') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录后的用户区域 -->
|
||||
<div v-else class="user-menu">
|
||||
<n-dropdown :options="userMenuOptions" @select="handleUserMenuSelect">
|
||||
<NDropdown :options="userMenuOptions" @select="handleUserMenuSelect">
|
||||
<div class="user-info">
|
||||
<SafeAvatar :src="userStore.user?.avatar"
|
||||
<SafeAvatar
|
||||
:src="userStore.user?.avatar"
|
||||
:name="userStore.user?.profile?.realName || userStore.user?.nickname || userStore.user?.username"
|
||||
:size="32" />
|
||||
<span class="username">{{ userStore.user?.profile?.realName || userStore.user?.nickname ||
|
||||
userStore.user?.username }}</span>
|
||||
:size="32"
|
||||
/>
|
||||
<span class="username">{{ userStore.user?.profile?.realName || userStore.user?.nickname || userStore.user?.username }}</span>
|
||||
</div>
|
||||
</n-dropdown>
|
||||
</NDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -117,14 +114,11 @@
|
||||
|
||||
<!-- 注册模态框 -->
|
||||
<RegisterModal v-model:show="registerModalVisible" @success="handleAuthSuccess" />
|
||||
|
||||
<!-- 内测通知模态框 -->
|
||||
<RegisterNotice v-model:show="registerNoticeVisible" @close="closeRegisterNotice" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { computed, ref, onMounted, onUnmounted, h, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@ -133,9 +127,9 @@ import {
|
||||
MenuOutline,
|
||||
CloseOutline
|
||||
} from '@vicons/ionicons5'
|
||||
import { NDropdown, NIcon } from 'naive-ui'
|
||||
import LoginModal from '@/components/auth/LoginModal.vue'
|
||||
import RegisterModal from '@/components/auth/RegisterModal.vue'
|
||||
import RegisterNotice from '@/components/auth/RegisterNotice.vue'
|
||||
import SafeAvatar from '@/components/common/SafeAvatar.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@ -195,7 +189,7 @@ const setActiveKeyFromRoute = () => {
|
||||
// 认证模态框相关
|
||||
const loginModalVisible = ref(false)
|
||||
const registerModalVisible = ref(false)
|
||||
const registerNoticeVisible = ref(false)
|
||||
|
||||
|
||||
// 语言切换相关
|
||||
const showLanguageDropdown = ref(false)
|
||||
@ -229,17 +223,150 @@ const handleLearningCenter = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理菜单项悬停效果
|
||||
const setupMenuHoverEffects = () => {
|
||||
const handleMouseEnter = (e: Event) => {
|
||||
const option = e.currentTarget as HTMLElement
|
||||
const container = option.querySelector('.menu-icon-container') as HTMLElement
|
||||
if (!container) return
|
||||
|
||||
const defaultIcon = container.querySelector('.default-icon') as HTMLElement
|
||||
const hoverIcon = container.querySelector('.hover-icon') as HTMLElement
|
||||
|
||||
// 图标切换
|
||||
if (defaultIcon) defaultIcon.style.opacity = '0'
|
||||
if (hoverIcon) hoverIcon.style.opacity = '1'
|
||||
|
||||
// 文字和背景颜色 - 使用更强的选择器
|
||||
option.style.setProperty('color', '#0088D1', 'important')
|
||||
option.style.setProperty('background', 'linear-gradient(135deg, #f0f8ff, #e6f4ff)', 'important')
|
||||
|
||||
// 查找所有可能的文字元素并设置颜色
|
||||
const allTextElements = option.querySelectorAll('*')
|
||||
allTextElements.forEach((el: Element) => {
|
||||
const element = el as HTMLElement
|
||||
if (element.textContent && element.textContent.trim()) {
|
||||
element.style.setProperty('color', '#0088D1', 'important')
|
||||
}
|
||||
})
|
||||
|
||||
console.log('悬停进入:', option.textContent?.trim())
|
||||
}
|
||||
|
||||
const handleMouseLeave = (e: Event) => {
|
||||
const option = e.currentTarget as HTMLElement
|
||||
const container = option.querySelector('.menu-icon-container') as HTMLElement
|
||||
if (!container) return
|
||||
|
||||
const defaultIcon = container.querySelector('.default-icon') as HTMLElement
|
||||
const hoverIcon = container.querySelector('.hover-icon') as HTMLElement
|
||||
|
||||
// 图标恢复
|
||||
if (defaultIcon) defaultIcon.style.opacity = '1'
|
||||
if (hoverIcon) hoverIcon.style.opacity = '0'
|
||||
|
||||
// 文字和背景恢复
|
||||
option.style.removeProperty('color')
|
||||
option.style.removeProperty('background')
|
||||
|
||||
// 恢复所有文字元素的颜色
|
||||
const allTextElements = option.querySelectorAll('*')
|
||||
allTextElements.forEach((el: Element) => {
|
||||
const element = el as HTMLElement
|
||||
element.style.removeProperty('color')
|
||||
})
|
||||
|
||||
console.log('悬停离开:', option.textContent?.trim())
|
||||
}
|
||||
|
||||
// 使用MutationObserver监听dropdown的渲染
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList') {
|
||||
const menuOptions = document.querySelectorAll('.n-dropdown-option')
|
||||
if (menuOptions.length > 0) {
|
||||
menuOptions.forEach((option) => {
|
||||
const container = option.querySelector('.menu-icon-container')
|
||||
if (container) {
|
||||
// 移除旧的事件监听器(如果存在)
|
||||
option.removeEventListener('mouseenter', handleMouseEnter)
|
||||
option.removeEventListener('mouseleave', handleMouseLeave)
|
||||
// 添加新的事件监听器
|
||||
option.addEventListener('mouseenter', handleMouseEnter)
|
||||
option.addEventListener('mouseleave', handleMouseLeave)
|
||||
}
|
||||
})
|
||||
console.log('菜单悬停事件已绑定')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 开始观察body的变化
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
})
|
||||
|
||||
// 也尝试立即绑定(如果元素已存在)
|
||||
setTimeout(() => {
|
||||
const menuOptions = document.querySelectorAll('.n-dropdown-option')
|
||||
if (menuOptions.length > 0) {
|
||||
menuOptions.forEach((option) => {
|
||||
const container = option.querySelector('.menu-icon-container')
|
||||
if (container) {
|
||||
option.addEventListener('mouseenter', handleMouseEnter)
|
||||
option.addEventListener('mouseleave', handleMouseLeave)
|
||||
}
|
||||
})
|
||||
console.log('菜单悬停事件已立即绑定')
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 用户菜单选项
|
||||
const userMenuOptions = computed(() => [
|
||||
{
|
||||
label: '个人中心',
|
||||
key: 'profile',
|
||||
icon: () => h('div', { class: 'custom-icon' }, '👤')
|
||||
icon: () => h('div', {
|
||||
class: 'menu-icon-container',
|
||||
style: 'position: relative; width: 18px; height: 18px; display: inline-block; margin-right: 1px; overflow: hidden;'
|
||||
}, [
|
||||
h('img', {
|
||||
src: '/images/personal/用户_user备份@2x.png',
|
||||
alt: '个人中心',
|
||||
class: 'menu-icon default-icon',
|
||||
style: 'width: 18px !important; height: 18px !important; max-width: 18px; max-height: 18px; object-fit: contain; position: absolute; top: 0; left: 0;'
|
||||
}),
|
||||
h('img', {
|
||||
src: '/images/personal/用户_user备份 2@2x.png',
|
||||
alt: '个人中心',
|
||||
class: 'menu-icon hover-icon',
|
||||
style: 'width: 18px !important; height: 18px !important; max-width: 18px; max-height: 18px; object-fit: contain; position: absolute; top: 0; left: 0; opacity: 0;'
|
||||
})
|
||||
])
|
||||
},
|
||||
{
|
||||
label: '切换教师端',
|
||||
key: 'teacher',
|
||||
icon: () => h('div', { class: 'custom-icon' }, '👨🏫')
|
||||
icon: () => h('div', {
|
||||
class: 'menu-icon-container',
|
||||
style: 'position: relative; width: 18px; height: 18px; display: inline-block; margin-right: 1px; overflow: hidden;'
|
||||
}, [
|
||||
h('img', {
|
||||
src: '/images/personal/切换_switch备份@2x.png',
|
||||
alt: '切换教师端',
|
||||
class: 'menu-icon default-icon',
|
||||
style: 'width: 18px !important; height: 18px !important; max-width: 18px; max-height: 18px; object-fit: contain; position: absolute; top: 0; left: 0;'
|
||||
}),
|
||||
h('img', {
|
||||
src: '/images/personal/切换_switch备份 2@2x.png',
|
||||
alt: '切换教师端',
|
||||
class: 'menu-icon hover-icon',
|
||||
style: 'width: 18px !important; height: 18px !important; max-width: 18px; max-height: 18px; object-fit: contain; position: absolute; top: 0; left: 0; opacity: 0;'
|
||||
})
|
||||
])
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
@ -247,10 +374,28 @@ const userMenuOptions = computed(() => [
|
||||
{
|
||||
label: '退出登录',
|
||||
key: 'logout',
|
||||
icon: () => h('div', { class: 'custom-icon' }, '🚪')
|
||||
icon: () => h('div', {
|
||||
class: 'menu-icon-container',
|
||||
style: 'position: relative; width: 18px; height: 18px; display: inline-block; margin-right: 1px; overflow: hidden;'
|
||||
}, [
|
||||
h('img', {
|
||||
src: '/images/personal/退出_logout备份 2@2x.png',
|
||||
alt: '退出登录',
|
||||
class: 'menu-icon default-icon',
|
||||
style: 'width: 18px !important; height: 18px !important; max-width: 18px; max-height: 18px; object-fit: contain; position: absolute; top: 0; left: 0;'
|
||||
}),
|
||||
h('img', {
|
||||
src: '/images/personal/退出_logout备份 3@2x.png',
|
||||
alt: '退出登录',
|
||||
class: 'menu-icon hover-icon',
|
||||
style: 'width: 18px !important; height: 18px !important; max-width: 18px; max-height: 18px; object-fit: contain; position: absolute; top: 0; left: 0; opacity: 0;'
|
||||
})
|
||||
])
|
||||
}
|
||||
])
|
||||
|
||||
// 菜单图标悬停效果现在完全由CSS处理
|
||||
|
||||
// 处理菜单选择
|
||||
const handleMenuSelect = (key: string) => {
|
||||
activeKey.value = key
|
||||
@ -324,10 +469,6 @@ const handleAuthSuccess = () => {
|
||||
console.log('认证成功')
|
||||
}
|
||||
|
||||
// 关闭内测通知
|
||||
const closeRegisterNotice = () => {
|
||||
registerNoticeVisible.value = false
|
||||
}
|
||||
|
||||
// 修复未定义的handleClickOutside
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@ -342,6 +483,8 @@ onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
// 初始化时根据当前路由设置激活状态
|
||||
setActiveKeyFromRoute()
|
||||
// 设置菜单悬停效果
|
||||
setupMenuHoverEffects()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@ -754,26 +897,170 @@ watch(() => route.path, () => {
|
||||
:deep(.n-dropdown-option) {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:deep(.n-dropdown-option:hover) {
|
||||
background: linear-gradient(135deg, #f0f8ff, #e6f4ff);
|
||||
color: #1890ff;
|
||||
/* 默认文字颜色 */
|
||||
:deep(.n-dropdown-option .n-dropdown-option__label) {
|
||||
color: #666666 !important;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.n-dropdown-option .n-dropdown-option-icon) {
|
||||
margin-right: 12px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
/* 强制覆盖Naive UI的默认样式 */
|
||||
:deep(.n-dropdown-option) {
|
||||
color: #666666 !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 菜单图标容器样式 - 使用更强的选择器 */
|
||||
:deep(.menu-icon-container) {
|
||||
position: relative !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
display: inline-block !important;
|
||||
margin-right: 8px !important;
|
||||
flex-shrink: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* 菜单图标样式 - 使用通配符强制覆盖所有图片 */
|
||||
:deep(.n-dropdown-option img),
|
||||
:deep(.n-dropdown-option .menu-icon-container img),
|
||||
:deep(.n-dropdown-option .menu-icon-container .menu-icon),
|
||||
:deep(.n-dropdown-option img.menu-icon),
|
||||
:deep(.menu-icon-container img),
|
||||
:deep(.menu-icon-container .menu-icon),
|
||||
:deep(img.menu-icon),
|
||||
:deep(.n-dropdown-option *) img {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
max-width: 18px !important;
|
||||
max-height: 18px !important;
|
||||
min-width: 18px !important;
|
||||
min-height: 18px !important;
|
||||
object-fit: contain !important;
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
transition: opacity 0.2s ease !important;
|
||||
display: block !important;
|
||||
transform: scale(1) !important;
|
||||
}
|
||||
|
||||
/* 默认状态:显示默认图标,隐藏悬停图标 */
|
||||
:deep(.menu-icon-container img.default-icon),
|
||||
:deep(.menu-icon-container .menu-icon.default-icon),
|
||||
:deep(img.menu-icon.default-icon) {
|
||||
opacity: 1 !important;
|
||||
z-index: 2 !important;
|
||||
}
|
||||
|
||||
:deep(.menu-icon-container img.hover-icon),
|
||||
:deep(.menu-icon-container .menu-icon.hover-icon),
|
||||
:deep(img.menu-icon.hover-icon) {
|
||||
opacity: 0 !important;
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
/* 悬停时的背景和文字颜色效果 */
|
||||
:deep(.n-dropdown-option:hover) {
|
||||
background: linear-gradient(135deg, #f0f8ff, #e6f4ff) !important;
|
||||
color: #0088D1 !important;
|
||||
}
|
||||
|
||||
/* 悬停时文字颜色变化 - 使用多重选择器确保生效 */
|
||||
:deep(.n-dropdown-option:hover .n-dropdown-option__label),
|
||||
:deep(.n-dropdown-option:hover span),
|
||||
:deep(.n-dropdown-option:hover) {
|
||||
color: #0088D1 !important;
|
||||
}
|
||||
|
||||
/* 强制覆盖所有可能的文字颜色 */
|
||||
:deep(.n-dropdown-option:hover *:not(img)) {
|
||||
color: #0088D1 !important;
|
||||
}
|
||||
|
||||
/* 悬停时的图标切换效果 - 使用所有可能的选择器 */
|
||||
:deep(.n-dropdown-option:hover .menu-icon-container img.default-icon),
|
||||
:deep(.n-dropdown-option:hover .menu-icon-container .menu-icon.default-icon),
|
||||
:deep(.n-dropdown-option:hover img.menu-icon.default-icon) {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.n-dropdown-option:hover .menu-icon-container img.hover-icon),
|
||||
:deep(.n-dropdown-option:hover .menu-icon-container .menu-icon.hover-icon),
|
||||
:deep(.n-dropdown-option:hover img.menu-icon.hover-icon) {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* 覆盖Naive UI的图标容器样式 */
|
||||
:deep(.n-dropdown-option .n-dropdown-option-icon) {
|
||||
margin-right: 8px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
flex-shrink: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* 确保图标容器内的元素正确显示 */
|
||||
:deep(.n-dropdown-option .n-dropdown-option-icon .menu-icon-container) {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
|
||||
/* 强制限制所有图片元素的尺寸 - 全局重置 */
|
||||
:deep(.n-dropdown-option img) {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
max-width: 18px !important;
|
||||
max-height: 18px !important;
|
||||
min-width: 18px !important;
|
||||
min-height: 18px !important;
|
||||
object-fit: contain !important;
|
||||
box-sizing: border-box !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* 全局强制重置 - 覆盖任何可能的样式 */
|
||||
:deep(.n-dropdown) img,
|
||||
:deep(.n-dropdown-menu) img,
|
||||
:deep(.n-dropdown-option) img {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
max-width: 18px !important;
|
||||
max-height: 18px !important;
|
||||
min-width: 18px !important;
|
||||
min-height: 18px !important;
|
||||
}
|
||||
|
||||
/* 全局样式重置 - 不使用 :deep() */
|
||||
.n-dropdown img,
|
||||
.n-dropdown-menu img,
|
||||
.n-dropdown-option img {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
max-width: 18px !important;
|
||||
max-height: 18px !important;
|
||||
min-width: 18px !important;
|
||||
min-height: 18px !important;
|
||||
object-fit: contain !important;
|
||||
}
|
||||
|
||||
/* 针对用户菜单的特殊重置 */
|
||||
.user-menu img,
|
||||
.user-menu * img {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
max-width: 18px !important;
|
||||
max-height: 18px !important;
|
||||
}
|
||||
|
||||
.custom-icon {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
|
@ -166,7 +166,10 @@ let nextId = 1
|
||||
// 监听props变化
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (newValue?.subQuestions) {
|
||||
subQuestions.value = newValue.subQuestions
|
||||
subQuestions.value = newValue.subQuestions.map(sq => ({ ...sq }));
|
||||
console.log('CompositeQuestion props 更新:', {
|
||||
newSubQuestions: subQuestions.value.map(sq => ({ id: sq.id, title: sq.title, score: sq.score }))
|
||||
});
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
|
@ -85,6 +85,7 @@ import {
|
||||
NTimeline,
|
||||
NTimelineItem,
|
||||
NMessageProvider,
|
||||
NDialogProvider,
|
||||
NPopselect
|
||||
} from 'naive-ui'
|
||||
|
||||
@ -159,6 +160,7 @@ const naive = create({
|
||||
NTimeline,
|
||||
NTimelineItem,
|
||||
NMessageProvider,
|
||||
NDialogProvider,
|
||||
NPopselect
|
||||
]
|
||||
})
|
||||
|
@ -63,6 +63,7 @@ import HomeworkTemplateImport from '@/views/teacher/course/HomeworkTemplateImpor
|
||||
|
||||
// 考试管理组件
|
||||
import ExamManagement from '@/views/teacher/ExamPages/ExamPage.vue'
|
||||
import ExamQuestionBankManagement from '@/views/teacher/ExamPages/QuestionBankManagement.vue'
|
||||
import QuestionManagement from '@/views/teacher/ExamPages/QuestionManagement.vue'
|
||||
import ExamLibrary from '@/views/teacher/ExamPages/ExamLibrary.vue'
|
||||
import MarkingCenter from '@/views/teacher/ExamPages/MarkingCenter.vue'
|
||||
@ -70,6 +71,7 @@ import AddExam from '@/views/teacher/ExamPages/AddExam.vue'
|
||||
import AddQuestion from '@/views/teacher/ExamPages/AddQuestion.vue'
|
||||
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 ChapterEditor from '@/views/teacher/course/ChapterEditor.vue'
|
||||
|
||||
@ -294,10 +296,16 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'ExamManagement',
|
||||
component: ExamManagement,
|
||||
meta: { title: '考试管理' },
|
||||
redirect: '/teacher/exam-management/question-management',
|
||||
redirect: '/teacher/exam-management/question-bank',
|
||||
children: [
|
||||
{
|
||||
path: 'question-management',
|
||||
path: 'question-bank',
|
||||
name: 'ExamQuestionBankManagement',
|
||||
component: ExamQuestionBankManagement,
|
||||
meta: { title: '题库管理' }
|
||||
},
|
||||
{
|
||||
path: 'question-bank/:bankId/questions',
|
||||
name: 'QuestionManagement',
|
||||
component: QuestionManagement,
|
||||
meta: { title: '试题管理' }
|
||||
@ -348,7 +356,7 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { title: '试卷预览' }
|
||||
},
|
||||
{
|
||||
path: 'add-question/:id?',
|
||||
path: 'add-question/:id/:questionId?',
|
||||
name: 'AddQuestionPage',
|
||||
component: AddQuestion,
|
||||
meta: { title: '添加试题' }
|
||||
@ -358,7 +366,12 @@ const routes: RouteRecordRaw[] = [
|
||||
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
path: '/taking/:id',
|
||||
name: 'ExamTaking',
|
||||
component: ExamTaking,
|
||||
meta: { title: '考试进行中' }
|
||||
},
|
||||
|
||||
// 帮助中心
|
||||
{
|
||||
|
@ -75,6 +75,7 @@
|
||||
@error="onVideoError"
|
||||
@screenshot="onScreenshot"
|
||||
@danmaku-send="onDanmakuSend"
|
||||
@qualityChange="onQualityChange"
|
||||
/>
|
||||
<div v-else class="video-placeholder"
|
||||
:style="{ backgroundImage: course?.coverImage || course?.thumbnail ? `url(${course.coverImage || course.thumbnail})` : '' }">
|
||||
@ -1451,7 +1452,11 @@ const onDanmakuSend = (text: string) => {
|
||||
// 可以在这里添加弹幕发送的逻辑,比如保存到数据库
|
||||
}
|
||||
|
||||
|
||||
// 清晰度切换事件处理
|
||||
const onQualityChange = (newQuality: string) => {
|
||||
console.log('🔄 清晰度已切换到:', newQuality)
|
||||
currentQuality.value = newQuality
|
||||
}
|
||||
|
||||
// 加载课程详情
|
||||
const loadCourseDetail = async () => {
|
||||
|
@ -36,10 +36,10 @@
|
||||
|
||||
<!-- 考试管理子菜单 -->
|
||||
<div class="submenu-container" :class="{ expanded: examMenuExpanded }">
|
||||
<router-link to="/teacher/exam-management/question-management" class="submenu-item"
|
||||
:class="{ active: activeSubNavItem === 'question-management' }"
|
||||
@click="setActiveSubNavItem('question-management')">
|
||||
<span>试题管理</span>
|
||||
<router-link to="/teacher/exam-management/question-bank" class="submenu-item"
|
||||
:class="{ active: activeSubNavItem === 'question-bank' }"
|
||||
@click="setActiveSubNavItem('question-bank')">
|
||||
<span>题库管理</span>
|
||||
</router-link>
|
||||
<router-link to="/teacher/exam-management/exam-library" class="submenu-item"
|
||||
:class="{ active: activeSubNavItem === 'exam-library' }" @click="setActiveSubNavItem('exam-library')">
|
||||
@ -506,7 +506,7 @@ const updateActiveNavItem = () => {
|
||||
activeNavItem.value = 4; // 考试管理
|
||||
examMenuExpanded.value = true;
|
||||
|
||||
const arr = ['question-management', 'exam-library', 'marking-center'];
|
||||
const arr = ['question-bank', 'exam-library', 'marking-center'];
|
||||
const found = arr.find(item => path.includes(item));
|
||||
activeSubNavItem.value = found || '';
|
||||
}
|
||||
|
@ -252,6 +252,7 @@ import { useRouter, useRoute } from 'vue-router';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { ArrowBackOutline } from '@vicons/ionicons5';
|
||||
import QuestionTypeContainer from '@/components/teacher/QuestionTypeContainer.vue';
|
||||
import { ExamApi } from '@/api';
|
||||
|
||||
// 路由和消息
|
||||
const router = useRouter();
|
||||
@ -259,9 +260,10 @@ const route = useRoute();
|
||||
const message = useMessage();
|
||||
|
||||
// 编辑模式判断
|
||||
const questionId = route.params.id as string | undefined;
|
||||
const questionId = route.params.questionId as string | undefined;
|
||||
const isEditMode = ref(!!questionId);
|
||||
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref();
|
||||
const saving = ref(false);
|
||||
@ -382,6 +384,29 @@ const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
// 类型映射函数:将字符串类型转换为数字类型
|
||||
const getQuestionTypeNumber = (type: string): number => {
|
||||
const typeMap: Record<string, number> = {
|
||||
'single_choice': 0, // 单选题
|
||||
'multiple_choice': 1, // 多选题
|
||||
'true_false': 2, // 判断题
|
||||
'fill_blank': 3, // 填空题
|
||||
'short_answer': 4, // 简答题
|
||||
'composite': 5 // 复合题
|
||||
};
|
||||
return typeMap[type] || 0;
|
||||
};
|
||||
|
||||
// 难度映射函数:将字符串难度转换为数字
|
||||
const getDifficultyNumber = (difficulty: string): number => {
|
||||
const difficultyMap: Record<string, number> = {
|
||||
'easy': 1, // 简单
|
||||
'medium': 2, // 中等
|
||||
'hard': 3 // 困难
|
||||
};
|
||||
return difficultyMap[difficulty] || 1;
|
||||
};
|
||||
|
||||
// 保存试题
|
||||
const saveQuestion = async () => {
|
||||
try {
|
||||
@ -395,22 +420,33 @@ const saveQuestion = async () => {
|
||||
|
||||
saving.value = true;
|
||||
|
||||
// 构建保存数据
|
||||
const saveData = buildSaveData();
|
||||
// 获取题库ID(可能从路由参数或者查询参数获取)
|
||||
// 如果从题库管理页面跳转过来,应该有bankId或者通过其他方式传递
|
||||
let bankId = route.params.bankId as string || route.params.id as string || route.query.bankId as string;
|
||||
|
||||
// 模拟保存API - 根据编辑模式调用不同接口
|
||||
if (isEditMode.value && questionId) {
|
||||
await mockUpdateQuestion(questionId, saveData);
|
||||
console.log('更新试题数据:', saveData);
|
||||
message.success('试题更新成功');
|
||||
} else {
|
||||
await mockCreateQuestion(saveData);
|
||||
console.log('保存试题数据:', saveData);
|
||||
message.success('试题保存成功');
|
||||
if (!bankId) {
|
||||
// 尝试从浏览器历史记录或者本地存储获取
|
||||
const referrer = document.referrer;
|
||||
const bankIdMatch = referrer.match(/question-bank\/([^\/]+)\/questions/);
|
||||
if (bankIdMatch) {
|
||||
bankId = bankIdMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// 返回试题管理页面
|
||||
router.push('/teacher/exam-management/question-management');
|
||||
if (!bankId) {
|
||||
message.error('缺少题库ID参数,请从题库管理页面进入');
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据编辑模式调用不同接口
|
||||
if (isEditMode.value && questionId) {
|
||||
// TODO: 实现编辑模式的接口调用
|
||||
message.info('编辑模式暂未实现');
|
||||
return;
|
||||
} else {
|
||||
// 新增模式 - 按照接口调用顺序执行
|
||||
await createNewQuestion(bankId);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('保存试题失败:', error);
|
||||
@ -439,6 +475,99 @@ const saveQuestion = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 创建新题目的完整流程
|
||||
const createNewQuestion = async (bankId: string) => {
|
||||
try {
|
||||
// 第一步:添加题干,获取questionId
|
||||
const questionData = {
|
||||
type: getQuestionTypeNumber(questionForm.type),
|
||||
content: questionForm.title,
|
||||
analysis: questionForm.explanation || '',
|
||||
difficulty: getDifficultyNumber(questionForm.difficulty),
|
||||
score: questionForm.score
|
||||
};
|
||||
|
||||
console.log('🚀 第一步:创建题目题干:', questionData);
|
||||
const questionResponse = await ExamApi.createQuestion(questionData);
|
||||
|
||||
if (!questionResponse.data) {
|
||||
throw new Error('创建题目失败,未返回题目ID');
|
||||
}
|
||||
|
||||
const createdQuestionId = questionResponse.data;
|
||||
console.log('✅ 题目创建成功,questionId:', createdQuestionId);
|
||||
|
||||
// 第二步:根据题型添加选项和答案
|
||||
if (questionForm.type === 'single_choice') {
|
||||
await handleSingleChoiceQuestion(createdQuestionId);
|
||||
}
|
||||
// TODO: 处理其他题型
|
||||
// else if (questionForm.type === 'multiple_choice') {
|
||||
// await handleMultipleChoiceQuestion(createdQuestionId);
|
||||
// } else if (questionForm.type === 'true_false') {
|
||||
// await handleTrueFalseQuestion(createdQuestionId);
|
||||
// }
|
||||
// 其他题型暂不处理
|
||||
|
||||
// 最后一步:将题目绑定到题库
|
||||
const questionRepoData = {
|
||||
repoId: bankId,
|
||||
questionId: createdQuestionId
|
||||
};
|
||||
|
||||
console.log('🚀 最后一步:绑定题目到题库:', questionRepoData);
|
||||
await ExamApi.createQuestionRepo(questionRepoData);
|
||||
console.log('✅ 题目绑定到题库成功');
|
||||
|
||||
message.success('题目保存成功');
|
||||
|
||||
// 返回题库管理页面
|
||||
router.push(`/teacher/exam-management/question-bank/${bankId}/questions`);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('创建题目流程失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理单选题的选项和答案
|
||||
const handleSingleChoiceQuestion = async (questionId: string) => {
|
||||
try {
|
||||
// 添加选项
|
||||
const optionPromises = questionForm.options.map((option, index) => {
|
||||
const isCorrect = questionForm.correctAnswer === index ? 1 : 0;
|
||||
return ExamApi.createQuestionOption({
|
||||
questionId,
|
||||
content: option.content,
|
||||
izCorrent: isCorrect,
|
||||
orderNo: index + 1
|
||||
});
|
||||
});
|
||||
|
||||
console.log('🚀 第二步:添加选项,选项数量:', questionForm.options.length);
|
||||
await Promise.all(optionPromises);
|
||||
console.log('✅ 选项添加成功');
|
||||
|
||||
// 添加正确答案
|
||||
if (questionForm.correctAnswer !== null) {
|
||||
const correctOption = questionForm.options[questionForm.correctAnswer];
|
||||
const answerData = {
|
||||
questionId,
|
||||
answerText: correctOption.content,
|
||||
orderNo: 1
|
||||
};
|
||||
|
||||
console.log('🚀 第三步:添加答案:', answerData);
|
||||
await ExamApi.createQuestionAnswer(answerData);
|
||||
console.log('✅ 答案添加成功');
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('处理单选题失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 验证答案设置
|
||||
const validateAnswers = (): boolean => {
|
||||
console.log(questionForm);
|
||||
@ -449,12 +578,30 @@ const validateAnswers = (): boolean => {
|
||||
|
||||
switch (questionForm.type) {
|
||||
case 'single_choice':
|
||||
// 检查选项内容
|
||||
if (questionForm.options.length < 2) {
|
||||
message.error('单选题至少需要2个选项');
|
||||
return false;
|
||||
}
|
||||
if (questionForm.options.some(option => !option.content.trim())) {
|
||||
message.error('请填写所有选项的内容');
|
||||
return false;
|
||||
}
|
||||
if (questionForm.correctAnswer === null) {
|
||||
message.error('请设置单选题的正确答案');
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 'multiple_choice':
|
||||
// 检查选项内容
|
||||
if (questionForm.options.length < 2) {
|
||||
message.error('多选题至少需要2个选项');
|
||||
return false;
|
||||
}
|
||||
if (questionForm.options.some(option => !option.content.trim())) {
|
||||
message.error('请填写所有选项的内容');
|
||||
return false;
|
||||
}
|
||||
if (questionForm.correctAnswers.length === 0) {
|
||||
message.error('请设置多选题的正确答案');
|
||||
return false;
|
||||
@ -623,51 +770,6 @@ const validateCompositeQuestion = (): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// 构建保存数据
|
||||
const buildSaveData = () => {
|
||||
const baseData = {
|
||||
type: questionForm.type,
|
||||
category: questionForm.category,
|
||||
difficulty: questionForm.difficulty,
|
||||
score: questionForm.score,
|
||||
title: questionForm.title,
|
||||
explanation: questionForm.explanation
|
||||
};
|
||||
|
||||
switch (questionForm.type) {
|
||||
case 'single_choice':
|
||||
case 'multiple_choice':
|
||||
return {
|
||||
...baseData,
|
||||
options: questionForm.options,
|
||||
correctAnswer: questionForm.type === 'single_choice' ? questionForm.correctAnswer : undefined,
|
||||
correctAnswers: questionForm.type === 'multiple_choice' ? questionForm.correctAnswers : undefined
|
||||
};
|
||||
case 'true_false':
|
||||
return {
|
||||
...baseData,
|
||||
answer: questionForm.trueFalseAnswer
|
||||
};
|
||||
case 'fill_blank':
|
||||
return {
|
||||
...baseData,
|
||||
answers: questionForm.fillBlankAnswers.filter(answer => answer.content.trim())
|
||||
};
|
||||
case 'short_answer':
|
||||
return {
|
||||
...baseData,
|
||||
answer: questionForm.shortAnswer
|
||||
};
|
||||
case 'composite':
|
||||
return {
|
||||
...baseData,
|
||||
compositeData: questionForm.compositeData
|
||||
};
|
||||
default:
|
||||
return baseData;
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载
|
||||
onMounted(async () => {
|
||||
// 如果是编辑模式,加载题目数据
|
||||
@ -676,164 +778,22 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// 加载题目数据
|
||||
// 加载题目数据(编辑模式使用)
|
||||
const loadQuestionData = async (id: string) => {
|
||||
try {
|
||||
// 模拟API调用
|
||||
const response = await mockGetQuestionById(id);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const questionData = response.data as any; // 使用any类型避免复杂的类型定义
|
||||
|
||||
// 回显基本信息
|
||||
questionForm.type = questionData.type;
|
||||
questionForm.category = questionData.category;
|
||||
questionForm.difficulty = questionData.difficulty;
|
||||
questionForm.score = questionData.score;
|
||||
questionForm.title = questionData.title;
|
||||
questionForm.explanation = questionData.explanation || '';
|
||||
|
||||
// 根据题型回显具体数据
|
||||
switch (questionData.type) {
|
||||
case 'single_choice':
|
||||
questionForm.options = questionData.options || [];
|
||||
questionForm.correctAnswer = questionData.correctAnswer;
|
||||
break;
|
||||
case 'multiple_choice':
|
||||
questionForm.options = questionData.options || [];
|
||||
questionForm.correctAnswers = questionData.correctAnswers || [];
|
||||
break;
|
||||
case 'true_false':
|
||||
questionForm.trueFalseAnswer = questionData.answer;
|
||||
break;
|
||||
case 'fill_blank':
|
||||
questionForm.fillBlankAnswers = questionData.answers || [];
|
||||
break;
|
||||
case 'short_answer':
|
||||
questionForm.shortAnswer = questionData.answer || '';
|
||||
break;
|
||||
case 'composite':
|
||||
questionForm.compositeData = questionData.compositeData || { subQuestions: [] };
|
||||
break;
|
||||
}
|
||||
|
||||
message.success('题目数据加载成功');
|
||||
} else {
|
||||
message.error('加载题目数据失败');
|
||||
}
|
||||
// TODO: 实现编辑模式的数据加载
|
||||
// const response = await ExamApi.getQuestionDetail(id);
|
||||
// if (response.data) {
|
||||
// // 根据返回的数据回显表单
|
||||
// }
|
||||
console.log('加载题目数据,题目ID:', id);
|
||||
message.info('编辑模式数据加载暂未实现');
|
||||
} catch (error) {
|
||||
console.error('加载题目数据错误:', error);
|
||||
message.error('加载题目数据失败,请检查网络连接');
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟获取题目数据的API
|
||||
const mockGetQuestionById = async (id: string) => {
|
||||
// 模拟API延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 模拟返回数据
|
||||
const mockQuestions = {
|
||||
'1': {
|
||||
type: 'single_choice',
|
||||
category: 'exam',
|
||||
difficulty: 'medium',
|
||||
score: 5,
|
||||
title: '以下哪个是Vue.js的核心特性?',
|
||||
explanation: 'Vue.js的核心特性包括响应式数据绑定、组件系统等',
|
||||
options: [
|
||||
{ content: '响应式数据绑定' },
|
||||
{ content: '虚拟DOM' },
|
||||
{ content: '组件系统' },
|
||||
{ content: '以上都是' }
|
||||
],
|
||||
correctAnswer: 3
|
||||
},
|
||||
'2': {
|
||||
type: 'composite',
|
||||
category: 'exam',
|
||||
difficulty: 'hard',
|
||||
score: 20,
|
||||
title: '阅读以下关于JavaScript的材料,并回答相关问题。\\n\\nJavaScript是一种高级的、解释型的编程语言,具有动态类型、原型继承等特性。它最初被设计用于网页开发,但现在已经扩展到服务器端、移动应用等多个领域。',
|
||||
explanation: '这是一道综合性的复合题',
|
||||
compositeData: {
|
||||
subQuestions: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'single',
|
||||
title: 'JavaScript属于什么类型的编程语言?',
|
||||
score: 5,
|
||||
data: [
|
||||
{ option: '编译型', id: 1 },
|
||||
{ option: '解释型', id: 2 },
|
||||
{ option: '汇编型', id: 3 },
|
||||
{ option: '机器型', id: 4 }
|
||||
],
|
||||
correctAnswer: 1,
|
||||
explanation: 'JavaScript是解释型编程语言'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'multiple',
|
||||
title: 'JavaScript有哪些特性?',
|
||||
score: 10,
|
||||
data: [
|
||||
{ option: '动态类型', id: 1 },
|
||||
{ option: '原型继承', id: 2 },
|
||||
{ option: '静态类型', id: 3 },
|
||||
{ option: '面向对象', id: 4 }
|
||||
],
|
||||
correctAnswers: [0, 1, 3],
|
||||
explanation: 'JavaScript具有动态类型、原型继承和面向对象等特性'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const question = mockQuestions[id as keyof typeof mockQuestions];
|
||||
|
||||
if (question) {
|
||||
return {
|
||||
success: true,
|
||||
data: question
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: '题目不存在'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟创建题目的API
|
||||
const mockCreateQuestion = async (data: any) => {
|
||||
// 模拟API延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 这里应该调用真实的API
|
||||
console.log('创建题目:', data);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { id: Math.random().toString(36).substr(2, 9) }
|
||||
};
|
||||
};
|
||||
|
||||
// 模拟更新题目的API
|
||||
const mockUpdateQuestion = async (id: string, data: any) => {
|
||||
// 模拟API延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 这里应该调用真实的API
|
||||
console.log('更新题目:', id, data);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { id }
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const getDifficultyLabel = (difficulty: string): string => {
|
||||
const difficultyMap: { [key: string]: string } = {
|
||||
|
@ -168,9 +168,6 @@ const paginationConfig = computed(() => ({
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
showSizePicker: true,
|
||||
showQuickJumper: true,
|
||||
goto: ()=>{
|
||||
return '跳转'
|
||||
},
|
||||
prefix: (info: { itemCount?: number }) => {
|
||||
const itemCount = info.itemCount || 0;
|
||||
return `共 ${itemCount} 条`;
|
||||
|
@ -64,7 +64,6 @@
|
||||
</div>
|
||||
|
||||
<div class="question-title">{{ subQuestion.title }}</div>
|
||||
|
||||
<!-- 单选题 -->
|
||||
<div v-if="subQuestion.type === 'single_choice'" class="question-content">
|
||||
<div class="options">
|
||||
@ -116,13 +115,13 @@
|
||||
<div class="true-false-options">
|
||||
<div class="option-item"
|
||||
:class="{ 'correct-option': subQuestion.trueFalseAnswer === true }">
|
||||
<span class="option-label">A</span>
|
||||
<span class="option-label"></span>
|
||||
<span class="option-content">正确</span>
|
||||
<span v-if="subQuestion.trueFalseAnswer === true" class="correct-mark">✓</span>
|
||||
</div>
|
||||
<div class="option-item"
|
||||
:class="{ 'correct-option': subQuestion.trueFalseAnswer === false }">
|
||||
<span class="option-label">B</span>
|
||||
<span class="option-label"></span>
|
||||
<span class="option-content">错误</span>
|
||||
<span v-if="subQuestion.trueFalseAnswer === false" class="correct-mark">✓</span>
|
||||
</div>
|
||||
@ -131,7 +130,7 @@
|
||||
<div class="answer-analysis">
|
||||
<div class="correct-answer">
|
||||
<span class="label">正确答案:</span>
|
||||
<span class="answer">{{ subQuestion.trueFalseAnswer ? 'A 正确' : 'B 错误' }}</span>
|
||||
<span class="answer">{{ subQuestion.trueFalseAnswer === undefined ? '未设置答案' : (subQuestion.trueFalseAnswer === true ? '正确' : '错误') }}</span>
|
||||
</div>
|
||||
<div v-if="subQuestion.explanation" class="explanation">
|
||||
<span class="label">答案解析:</span>
|
||||
@ -182,6 +181,147 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 复合题 -->
|
||||
<div v-if="subQuestion.type === 'composite'" class="question-content">
|
||||
<div class="composite-questions">
|
||||
<!-- 复合题的子题目循环 -->
|
||||
<div v-for="(compSubQ, compIndex) in subQuestion.subQuestions" :key="compSubQ.id" class="composite-sub-question">
|
||||
<div class="comp-question-header">
|
||||
<span class="comp-question-number">{{ compIndex + 1 }}. {{ getQuestionTypeName(compSubQ.type) }}</span>
|
||||
<span class="comp-question-score">{{ compSubQ.score }}分</span>
|
||||
</div>
|
||||
|
||||
<div class="comp-question-title">{{ compSubQ.title }}</div>
|
||||
|
||||
<!-- 复合题中的单选题 -->
|
||||
<div v-if="compSubQ.type === 'single_choice'" class="comp-question-content">
|
||||
<div class="options">
|
||||
<div v-for="(option, optIndex) in compSubQ.options" :key="option.id"
|
||||
class="option-item" :class="{ 'correct-option': option.isCorrect }">
|
||||
<span class="option-label">{{ String.fromCharCode(65 + optIndex) }}</span>
|
||||
<span class="option-content">{{ option.content }}</span>
|
||||
<span v-if="option.isCorrect" class="correct-mark">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 答案解析 -->
|
||||
<div class="answer-analysis">
|
||||
<div class="correct-answer">
|
||||
<span class="label">正确答案:</span>
|
||||
<span class="answer">{{ getCorrectAnswerText(compSubQ) }}</span>
|
||||
</div>
|
||||
<div v-if="compSubQ.explanation" class="explanation">
|
||||
<span class="label">答案解析:</span>
|
||||
<p>{{ compSubQ.explanation }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 复合题中的多选题 -->
|
||||
<div v-if="compSubQ.type === 'multiple_choice'" class="comp-question-content">
|
||||
<div class="options">
|
||||
<div v-for="(option, optIndex) in compSubQ.options" :key="option.id"
|
||||
class="option-item" :class="{ 'correct-option': option.isCorrect }">
|
||||
<span class="option-label">{{ String.fromCharCode(65 + optIndex) }}</span>
|
||||
<span class="option-content">{{ option.content }}</span>
|
||||
<span v-if="option.isCorrect" class="correct-mark">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 答案解析 -->
|
||||
<div class="answer-analysis">
|
||||
<div class="correct-answer">
|
||||
<span class="label">正确答案:</span>
|
||||
<span class="answer">{{ getCorrectAnswerText(compSubQ) }}</span>
|
||||
</div>
|
||||
<div v-if="compSubQ.explanation" class="explanation">
|
||||
<span class="label">答案解析:</span>
|
||||
<p>{{ compSubQ.explanation }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 复合题中的判断题 -->
|
||||
<div v-if="compSubQ.type === 'true_false'" class="comp-question-content">
|
||||
<div class="true-false-options">
|
||||
<div class="option-item"
|
||||
:class="{ 'correct-option': compSubQ.trueFalseAnswer === true }">
|
||||
<span class="option-label"></span>
|
||||
<span class="option-content">正确</span>
|
||||
<span v-if="compSubQ.trueFalseAnswer === true" class="correct-mark">✓</span>
|
||||
</div>
|
||||
<div class="option-item"
|
||||
:class="{ 'correct-option': compSubQ.trueFalseAnswer === false }">
|
||||
<span class="option-label"></span>
|
||||
<span class="option-content">错误</span>
|
||||
<span v-if="compSubQ.trueFalseAnswer === false" class="correct-mark">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 答案解析 -->
|
||||
<div class="answer-analysis">
|
||||
<div class="correct-answer">
|
||||
<span class="label">正确答案:</span>
|
||||
<span class="answer">{{ compSubQ.trueFalseAnswer === undefined ? '未设置答案' : (compSubQ.trueFalseAnswer === true ? '正确' : '错误') }}</span>
|
||||
</div>
|
||||
<div v-if="compSubQ.explanation" class="explanation">
|
||||
<span class="label">答案解析:</span>
|
||||
<p>{{ compSubQ.explanation }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 复合题中的填空题 -->
|
||||
<div v-if="compSubQ.type === 'fill_blank'" class="comp-question-content">
|
||||
<div class="fill-blanks">
|
||||
<div v-for="(blank,index) in compSubQ.fillBlanks" :key="blank.id" class="blank-item">
|
||||
<span>第{{ index + 1 }}空:</span>
|
||||
<div class="blank-answer">{{ blank.content || '未设置' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 答案解析 -->
|
||||
<div class="answer-analysis">
|
||||
<div class="correct-answer">
|
||||
<span class="label">正确答案:</span>
|
||||
<div class="answer">
|
||||
<div v-for="(blank,index) in compSubQ.fillBlanks" :key="blank.id">
|
||||
第{{ index + 1 }}空:{{ blank.content || '未设置' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="compSubQ.explanation" class="explanation">
|
||||
<span class="label">答案解析:</span>
|
||||
<p>{{ compSubQ.explanation }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 复合题中的简答题 -->
|
||||
<div v-if="compSubQ.type === 'short_answer'" class="comp-question-content">
|
||||
<div class="answer-area">
|
||||
<div class="answer-text">{{ compSubQ.textAnswer || '未设置参考答案' }}</div>
|
||||
</div>
|
||||
<!-- 答案解析 -->
|
||||
<div class="answer-analysis">
|
||||
<div class="correct-answer">
|
||||
<span class="label">参考答案:</span>
|
||||
<p class="answer">{{ compSubQ.textAnswer || '未设置参考答案' }}</p>
|
||||
</div>
|
||||
<div v-if="compSubQ.explanation" class="explanation">
|
||||
<span class="label">答案解析:</span>
|
||||
<p>{{ compSubQ.explanation }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 复合题整体解析 -->
|
||||
<div v-if="subQuestion.explanation" class="answer-analysis">
|
||||
<div class="explanation">
|
||||
<span class="label">整体解析:</span>
|
||||
<p>{{ subQuestion.explanation }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -658,6 +798,58 @@ onMounted(() => {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 复合题样式 */
|
||||
.composite-questions {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.composite-sub-question {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.composite-sub-question:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comp-question-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.comp-question-number {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.comp-question-score {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.comp-question-title {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.comp-question-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
|
1104
src/views/teacher/ExamPages/ExamTaking.vue
Normal file
@ -80,7 +80,7 @@
|
||||
<div class="tip-box ungraded"></div>未答
|
||||
</div>
|
||||
</div>
|
||||
<n-button type="primary" size="large" block @click="submitGrading">
|
||||
<n-button v-if="!isViewMode" type="primary" size="large" block @click="submitGrading">
|
||||
提交批阅
|
||||
</n-button>
|
||||
</div>
|
||||
@ -176,6 +176,7 @@
|
||||
<div class="correct-status">
|
||||
<label>对错:</label>
|
||||
<n-radio-group v-model:value="question.isCorrect"
|
||||
:disabled="isViewMode"
|
||||
@update:value="(value: boolean | null) => updateQuestionStatus(question.id, value)">
|
||||
<n-radio :value="true" size="small">对</n-radio>
|
||||
<n-radio :value="false" size="small">错</n-radio>
|
||||
@ -187,6 +188,7 @@
|
||||
<div class="score-input-wrapper">
|
||||
<n-input type="number" v-model:value="question.studentScore" :min="0"
|
||||
:max="question.score"
|
||||
:disabled="isViewMode"
|
||||
@update:value="(value: number | null) => updateQuestionScore(question.id, value)" />
|
||||
</div>
|
||||
</div>
|
||||
@ -197,9 +199,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="text">
|
||||
<!-- 编辑模式:显示富文本编辑器 -->
|
||||
<div v-if="!isViewMode">
|
||||
<QuillEditor :placeholder="'请输入阅卷评语...(300字以内)'" v-model="gradingComments" height="400px">
|
||||
</QuillEditor>
|
||||
</div>
|
||||
<!-- 查看模式:显示评语内容 -->
|
||||
<div v-else class="comments-display">
|
||||
<h4>阅卷评语:</h4>
|
||||
<div class="comments-content" v-if="gradingComments">
|
||||
<div v-html="gradingComments"></div>
|
||||
</div>
|
||||
<div v-else class="no-comments">
|
||||
暂无评语
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -208,15 +223,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ArrowBackOutline, CheckmarkCircle, CloseCircle } from '@vicons/ionicons5'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import QuillEditor from '@/components/common/QuillEditor.vue'
|
||||
|
||||
// 路由
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
|
||||
// 根据路由参数判断是查看模式还是编辑模式
|
||||
const isViewMode = computed(() => route.query.mode === 'view')
|
||||
|
||||
// 接口定义
|
||||
interface StudentInfo {
|
||||
name: string
|
||||
@ -506,6 +525,11 @@ onMounted(() => {
|
||||
if (questions.value.length > 0) {
|
||||
currentQuestionId.value = questions.value[0].id
|
||||
}
|
||||
|
||||
// 如果是查看模式,加载已有的评语
|
||||
if (isViewMode.value) {
|
||||
// gradingComments.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 监听筛选状态变化
|
||||
@ -926,6 +950,7 @@ watch(showOnlyWrong, () => {
|
||||
.explanation-section h4 {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selected-answer,
|
||||
@ -1026,6 +1051,36 @@ watch(showOnlyWrong, () => {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 评语展示样式 */
|
||||
.comments-display {
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.comments-display h4 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.comments-content {
|
||||
background-color: #fff;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
min-height: 100px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.no-comments {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
648
src/views/teacher/ExamPages/QuestionBankManagement.vue
Normal file
@ -0,0 +1,648 @@
|
||||
<template>
|
||||
<div class="question-bank-container">
|
||||
<div class="header-section">
|
||||
<h1 class="title">题库管理</h1>
|
||||
<n-space class="actions-group">
|
||||
<n-button type="primary" @click="addQuestionBank">新建题库</n-button>
|
||||
<n-button ghost @click="importQuestionBank">导入题库</n-button>
|
||||
<n-button ghost @click="exportQuestionBank">导出题库</n-button>
|
||||
<n-button type="error" ghost @click="deleteSelected" :disabled="selectedRowKeys.length === 0">删除</n-button>
|
||||
<n-input
|
||||
v-model:value="filters.keyword"
|
||||
placeholder="请输入想要搜索的内容"
|
||||
style="width: 200px"
|
||||
clearable
|
||||
/>
|
||||
<n-button type="primary" @click="searchQuestionBanks">搜索</n-button>
|
||||
</n-space>
|
||||
</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"
|
||||
/>
|
||||
|
||||
<!-- 新建/编辑题库弹窗 -->
|
||||
<n-modal
|
||||
v-model:show="showCreateModal"
|
||||
preset="dialog"
|
||||
:title="isEditMode ? '编辑题库' : '新建题库'"
|
||||
style="width: 500px;"
|
||||
>
|
||||
<div class="create-modal-content">
|
||||
<div class="form-item">
|
||||
<label>题库名称:</label>
|
||||
<n-input
|
||||
v-model:value="createForm.name"
|
||||
placeholder="请输入题库名称"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label>题库描述:</label>
|
||||
<n-input
|
||||
v-model:value="createForm.description"
|
||||
type="textarea"
|
||||
placeholder="请输入题库描述"
|
||||
style="width: 100%;"
|
||||
:rows="3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="closeCreateModal">取消</n-button>
|
||||
<n-button type="primary" @click="submitQuestionBank" :disabled="!createForm.name.trim()">
|
||||
{{ isEditMode ? '保存' : '创建' }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- 导入弹窗 -->
|
||||
<ImportModal
|
||||
v-model:show="showImportModal"
|
||||
template-name="question_bank_template.xlsx"
|
||||
import-type="questionBank"
|
||||
@success="handleImportSuccess"
|
||||
@template-download="handleTemplateDownload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, h, VNode } from 'vue';
|
||||
import { NButton, NSpace, useMessage, useDialog } from 'naive-ui';
|
||||
import { useRouter } from 'vue-router';
|
||||
import ImportModal from '@/components/common/ImportModal.vue';
|
||||
import { ExamApi } from '@/api'
|
||||
|
||||
// 消息提示和对话框
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
// 路由
|
||||
const router = useRouter();
|
||||
|
||||
// 题库数据接口
|
||||
interface QuestionBank {
|
||||
id: string;
|
||||
sequence: number;
|
||||
name: string;
|
||||
description: string;
|
||||
questionCount: number;
|
||||
creator: string;
|
||||
createTime: string;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
keyword: ''
|
||||
});
|
||||
|
||||
|
||||
// 表格相关状态
|
||||
const loading = ref(false);
|
||||
const selectedRowKeys = ref<string[]>([]);
|
||||
const questionBankList = ref<QuestionBank[]>([]);
|
||||
|
||||
// 新建/编辑题库相关状态
|
||||
const showCreateModal = ref(false);
|
||||
const isEditMode = ref(false);
|
||||
const editingId = ref('');
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
// 导入弹窗状态
|
||||
const showImportModal = ref(false);
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
});
|
||||
|
||||
// 表格分页配置
|
||||
const paginationConfig = computed(() => ({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
itemCount: pagination.total,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
showSizePicker: true,
|
||||
showQuickJumper: true,
|
||||
prefix: (info: { 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();
|
||||
}
|
||||
}));
|
||||
|
||||
// 创建表格列的函数
|
||||
const createColumns = ({
|
||||
handleAction,
|
||||
}: {
|
||||
handleAction: (action: string, rowData: QuestionBank) => void;
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
type: 'selection',
|
||||
},
|
||||
{
|
||||
title: '序号',
|
||||
key: 'sequence',
|
||||
width: 80,
|
||||
align: 'center' as const
|
||||
},
|
||||
{
|
||||
title: '题库名称',
|
||||
key: 'name',
|
||||
width: 200,
|
||||
ellipsis: {
|
||||
tooltip: true
|
||||
},
|
||||
render(row: QuestionBank) {
|
||||
return h(
|
||||
'span',
|
||||
{
|
||||
style: 'color: #1890ff; cursor: pointer;',
|
||||
onClick: () => handleAction('进入', row)
|
||||
},
|
||||
row.name
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '题库描述',
|
||||
key: 'description',
|
||||
width: 250,
|
||||
ellipsis: {
|
||||
tooltip: true
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '题目数量',
|
||||
key: 'questionCount',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
render(row: QuestionBank) {
|
||||
return h('span', { style: 'color: #1890ff; font-weight: 500;' }, row.questionCount.toString());
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '创建人',
|
||||
key: 'creator',
|
||||
width: 100,
|
||||
align: 'center' as const
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'createTime',
|
||||
width: 160,
|
||||
align: 'center' as const
|
||||
},
|
||||
{
|
||||
title: '最后修改',
|
||||
key: 'lastModified',
|
||||
width: 160,
|
||||
align: 'center' as const
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
align: 'center' as const,
|
||||
render(row: QuestionBank) {
|
||||
const buttons: VNode[] = [];
|
||||
|
||||
buttons.push(
|
||||
h(NButton, {
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
ghost: true,
|
||||
style: 'margin: 0 3px;',
|
||||
onClick: () => handleAction('进入', row)
|
||||
}, { default: () => '进入' })
|
||||
);
|
||||
|
||||
buttons.push(
|
||||
h(NButton, {
|
||||
size: 'small',
|
||||
ghost: true,
|
||||
style: 'margin: 0 3px;',
|
||||
onClick: () => handleAction('编辑', row)
|
||||
}, { default: () => '编辑' })
|
||||
);
|
||||
|
||||
buttons.push(
|
||||
h(NButton, {
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
ghost: true,
|
||||
style: 'margin: 0 3px;',
|
||||
onClick: () => handleAction('删除', row)
|
||||
}, { default: () => '删除' })
|
||||
);
|
||||
|
||||
return h(NSpace, {}, { default: () => buttons });
|
||||
}
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// 表格列配置
|
||||
const columns = createColumns({
|
||||
handleAction: (action, row) => {
|
||||
if (action === '进入') {
|
||||
enterQuestionBank(row.id);
|
||||
} else if (action === '编辑') {
|
||||
editQuestionBank(row.id);
|
||||
} else if (action === '删除') {
|
||||
deleteQuestionBank(row.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 生成模拟数据
|
||||
const generateMockData = (): QuestionBank[] => {
|
||||
const mockData: QuestionBank[] = [];
|
||||
const creators = ['王建国', '李明', '张三', '刘老师', '陈教授'];
|
||||
const names = ['计算机基础题库', '数学专业题库', '英语考试题库', '物理练习题库', '化学综合题库'];
|
||||
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
mockData.push({
|
||||
id: '1960998116632399873',
|
||||
sequence: i,
|
||||
name: names[Math.floor(Math.random() * names.length)] + ` ${i}`,
|
||||
description: `这是一个包含多种题型的综合性题库,适用于教学和考试场景...`,
|
||||
questionCount: Math.floor(Math.random() * 500) + 50,
|
||||
creator: creators[Math.floor(Math.random() * creators.length)],
|
||||
createTime: '2025.08.20 09:20',
|
||||
lastModified: '2025.08.28 15:30'
|
||||
});
|
||||
}
|
||||
return mockData;
|
||||
};
|
||||
|
||||
// 处理复选框选择
|
||||
const handleCheck = (rowKeys: string[]) => {
|
||||
selectedRowKeys.value = rowKeys;
|
||||
};
|
||||
|
||||
// 搜索题库
|
||||
const searchQuestionBanks = () => {
|
||||
pagination.page = 1;
|
||||
loadQuestionBanks();
|
||||
};
|
||||
|
||||
// 加载题库列表
|
||||
const loadQuestionBanks = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// TODO API调用
|
||||
// await ExamApi.getCourseRepoList();
|
||||
|
||||
const allData = generateMockData();
|
||||
// 模拟筛选
|
||||
let filteredData = allData;
|
||||
|
||||
if (filters.keyword) {
|
||||
filteredData = filteredData.filter(item =>
|
||||
item.name.includes(filters.keyword) ||
|
||||
item.description.includes(filters.keyword) ||
|
||||
item.creator.includes(filters.keyword)
|
||||
);
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
pagination.total = filteredData.length;
|
||||
const start = (pagination.page - 1) * pagination.pageSize;
|
||||
const end = start + pagination.pageSize;
|
||||
questionBankList.value = filteredData.slice(start, end);
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载题库失败:', error);
|
||||
message.error('加载题库失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 操作方法
|
||||
const addQuestionBank = () => {
|
||||
// 重置为新建模式
|
||||
isEditMode.value = false;
|
||||
editingId.value = '';
|
||||
createForm.name = '';
|
||||
createForm.description = '';
|
||||
showCreateModal.value = true;
|
||||
};
|
||||
|
||||
const importQuestionBank = () => {
|
||||
showImportModal.value = true;
|
||||
};
|
||||
|
||||
const exportQuestionBank = () => {
|
||||
console.log('导出题库');
|
||||
message.info('导出功能开发中...');
|
||||
};
|
||||
|
||||
const deleteSelected = () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
message.warning('请先选择要删除的题库');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取选中的题库信息
|
||||
const selectedBanks = questionBankList.value.filter(item =>
|
||||
selectedRowKeys.value.includes(item.id)
|
||||
);
|
||||
|
||||
const bankNames = selectedBanks.map(bank => bank.name).join('、');
|
||||
|
||||
// 二次确认弹窗
|
||||
dialog.warning({
|
||||
title: '批量删除确认',
|
||||
content: `确定要删除以下 ${selectedRowKeys.value.length} 个题库吗?
|
||||
|
||||
题库名称:${bankNames}
|
||||
|
||||
`,
|
||||
positiveText: '确定删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
// 批量调用删除接口
|
||||
const deletePromises = selectedRowKeys.value.map(id =>
|
||||
ExamApi.deleteRepo(id)
|
||||
);
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
message.success(`成功删除 ${selectedRowKeys.value.length} 个题库`);
|
||||
|
||||
// 清空选中状态
|
||||
selectedRowKeys.value = [];
|
||||
|
||||
// 重新加载列表
|
||||
loadQuestionBanks();
|
||||
|
||||
} catch (error) {
|
||||
console.error('批量删除题库失败:', error);
|
||||
message.error('批量删除失败,请重试');
|
||||
}
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
// 用户取消删除,不需要做任何操作
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 进入题库(跳转到试题管理页面)
|
||||
const enterQuestionBank = (bankId: string) => {
|
||||
router.push(`/teacher/exam-management/question-bank/${bankId}/questions`);
|
||||
};
|
||||
|
||||
const editQuestionBank = (id: string) => {
|
||||
console.log('编辑题库:', id);
|
||||
|
||||
// 查找要编辑的题库数据
|
||||
const questionBank = questionBankList.value.find(item => item.id === id);
|
||||
if (!questionBank) {
|
||||
message.error('未找到要编辑的题库');
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置编辑模式
|
||||
isEditMode.value = true;
|
||||
editingId.value = id;
|
||||
|
||||
// 回显数据
|
||||
createForm.name = questionBank.name;
|
||||
createForm.description = questionBank.description;
|
||||
|
||||
// 显示弹窗
|
||||
showCreateModal.value = true;
|
||||
};
|
||||
|
||||
const deleteQuestionBank = (id: string) => {
|
||||
// 查找要删除的题库信息
|
||||
const questionBank = questionBankList.value.find(item => item.id === id);
|
||||
if (!questionBank) {
|
||||
message.error('未找到要删除的题库');
|
||||
return;
|
||||
}
|
||||
|
||||
// 二次确认弹窗
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: `确定要删除题库"${questionBank.name}"吗?`,
|
||||
positiveText: '确定删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
// 调用删除接口
|
||||
await ExamApi.deleteRepo(id);
|
||||
message.success('题库删除成功');
|
||||
|
||||
// 重新加载列表
|
||||
loadQuestionBanks();
|
||||
|
||||
// 清空选中状态
|
||||
selectedRowKeys.value = selectedRowKeys.value.filter(key => key !== id);
|
||||
|
||||
} catch (error) {
|
||||
console.error('删除题库失败:', error);
|
||||
message.error('删除题库失败,请重试');
|
||||
}
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
// 用户取消删除,不需要做任何操作
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 新建/编辑题库相关方法
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false;
|
||||
isEditMode.value = false;
|
||||
editingId.value = '';
|
||||
createForm.name = '';
|
||||
createForm.description = '';
|
||||
};
|
||||
|
||||
// 统一的提交方法
|
||||
const submitQuestionBank = async () => {
|
||||
if (!createForm.name.trim()) {
|
||||
message.warning('请输入题库名称');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEditMode.value) {
|
||||
// 编辑模式
|
||||
await ExamApi.updateRepo({
|
||||
id: editingId.value,
|
||||
title: createForm.name,
|
||||
remark: createForm.description
|
||||
});
|
||||
message.success('题库更新成功');
|
||||
} else {
|
||||
// 新建模式
|
||||
await ExamApi.createCourseRepo({
|
||||
title: createForm.name,
|
||||
remark: createForm.description
|
||||
});
|
||||
message.success('题库创建成功');
|
||||
}
|
||||
|
||||
closeCreateModal();
|
||||
loadQuestionBanks();
|
||||
|
||||
} catch (error) {
|
||||
console.error('提交题库失败:', error);
|
||||
message.error(isEditMode.value ? '更新题库失败,请重试' : '创建题库失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理导入成功
|
||||
const handleImportSuccess = (result: any) => {
|
||||
console.log('导入成功:', result);
|
||||
message.success('题库导入成功');
|
||||
loadQuestionBanks();
|
||||
};
|
||||
|
||||
// 处理模板下载
|
||||
const handleTemplateDownload = (type?: string) => {
|
||||
console.log('下载模板:', type);
|
||||
message.info('模板下载功能开发中...');
|
||||
};
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadQuestionBanks();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.question-bank-container {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #E6E6E6;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.question-bank-table {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 新建题库弹窗样式 */
|
||||
.create-modal-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.create-modal-content .form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.create-modal-content .form-item label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.header-section {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.actions-group {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.question-bank-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.actions-group {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions-group .n-input {
|
||||
width: 150px !important;
|
||||
}
|
||||
|
||||
.actions-group .n-select {
|
||||
width: 100px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.actions-group {
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.actions-group .n-input {
|
||||
width: 120px !important;
|
||||
}
|
||||
|
||||
.actions-group .n-select {
|
||||
width: 80px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,7 +1,17 @@
|
||||
<template>
|
||||
<div class="question-management-container">
|
||||
<div class="breadcrumb-section">
|
||||
<n-breadcrumb>
|
||||
<n-breadcrumb-item @click="goToQuestionBank">
|
||||
<n-icon><ChevronBackOutline /></n-icon>
|
||||
题库管理
|
||||
</n-breadcrumb-item>
|
||||
<n-breadcrumb-item>{{ currentBankName }}</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="header-section">
|
||||
<h1 class="title">全部试题</h1>
|
||||
<h1 class="title">{{ currentBankName }} - 试题管理</h1>
|
||||
<n-space class="actions-group">
|
||||
<n-button type="primary" @click="addQuestion">添加试题</n-button>
|
||||
<n-button ghost @click="importQuestions">导入</n-button>
|
||||
@ -165,15 +175,27 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, h, VNode } from 'vue';
|
||||
import { NButton, NTag, NSpace, useMessage } from 'naive-ui';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { NButton, NTag, NSpace, NBreadcrumb, NBreadcrumbItem, NIcon, useMessage } from 'naive-ui';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { ChevronBackOutline } from '@vicons/ionicons5';
|
||||
import ImportModal from '@/components/common/ImportModal.vue';
|
||||
import { ExamApi } from '@/api';
|
||||
|
||||
// 消息提示
|
||||
const message = useMessage();
|
||||
|
||||
// 路由
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// 当前题库信息
|
||||
const currentBankId = computed(() => route.params.bankId as string);
|
||||
const currentBankName = ref('加载中...');
|
||||
|
||||
// 返回题库管理页面
|
||||
const goToQuestionBank = () => {
|
||||
router.push('/teacher/exam-management/question-bank');
|
||||
};
|
||||
|
||||
// 题目数据接口
|
||||
interface Question {
|
||||
@ -241,9 +263,6 @@ const paginationConfig = computed(() => ({
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
showSizePicker: true,
|
||||
showQuickJumper: true,
|
||||
goto: ()=>{
|
||||
return '跳转'
|
||||
},
|
||||
prefix: (info: { itemCount?: number }) => {
|
||||
const itemCount = info.itemCount || 0;
|
||||
return `共 ${itemCount} 条`;
|
||||
@ -434,7 +453,7 @@ const loadQuestions = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
await ExamApi.getQuestionsByRepo(currentBankId.value);
|
||||
|
||||
const allData = generateMockData();
|
||||
// 模拟筛选
|
||||
@ -466,7 +485,7 @@ const loadQuestions = async () => {
|
||||
|
||||
// 操作方法
|
||||
const addQuestion = () => {
|
||||
router.push('/teacher/exam-management/add-question');
|
||||
router.push(`/teacher/exam-management/add-question/${currentBankId.value}`);
|
||||
};
|
||||
|
||||
const importQuestions = () => {
|
||||
@ -496,6 +515,7 @@ const deleteSelected = () => {
|
||||
|
||||
const editQuestion = (id: string) => {
|
||||
console.log('编辑题目:', id);
|
||||
router.push(`/teacher/exam-management/add-question/${currentBankId.value}/${id}`);
|
||||
};
|
||||
|
||||
const deleteQuestion = (id: string) => {
|
||||
@ -504,9 +524,33 @@ const deleteQuestion = (id: string) => {
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadCurrentBankInfo();
|
||||
loadQuestions();
|
||||
});
|
||||
|
||||
// 加载当前题库信息
|
||||
const loadCurrentBankInfo = async () => {
|
||||
try {
|
||||
// 模拟API调用获取题库信息
|
||||
// await ;
|
||||
|
||||
// 模拟根据bankId获取题库名称
|
||||
const bankNames: { [key: string]: string } = {
|
||||
'bank_1': '计算机基础题库 1',
|
||||
'bank_2': '数学专业题库 2',
|
||||
'bank_3': '英语考试题库 3',
|
||||
'bank_4': '物理练习题库 4',
|
||||
'bank_5': '化学综合题库 5'
|
||||
};
|
||||
|
||||
currentBankName.value = bankNames[currentBankId.value] || `题库 ${currentBankId.value}`;
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载题库信息失败:', error);
|
||||
currentBankName.value = '未知题库';
|
||||
}
|
||||
};
|
||||
|
||||
// 分类设置相关方法
|
||||
const setCategoryForSelected = () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
@ -651,6 +695,21 @@ const deleteCategory = (categoryValue: string) => {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.breadcrumb-section {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.breadcrumb-section .n-breadcrumb-item:first-child {
|
||||
cursor: pointer;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.breadcrumb-section .n-breadcrumb-item:first-child:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
@ -359,13 +359,13 @@ const getStudentStatusText = (status: string) => {
|
||||
}
|
||||
|
||||
const handleViewAnswer = (student: StudentExamInfo) => {
|
||||
// 跳转到答题详情页面
|
||||
router.push(`/teacher/exam-management/marking-center/answer-detail/${examInfo.value.id}/${student.id}`)
|
||||
// 跳转到答题详情页面(查看模式)
|
||||
router.push(`/teacher/exam-management/marking-center/grading/${examInfo.value.id}/${student.id}?mode=view`)
|
||||
}
|
||||
|
||||
const handleGrade = (student: StudentExamInfo) => {
|
||||
// 跳转到批阅页面
|
||||
router.push(`/teacher/exam-management/marking-center/grading/${examInfo.value.id}/${student.id}`)
|
||||
// 跳转到批阅页面(编辑模式)
|
||||
router.push(`/teacher/exam-management/marking-center/grading/${examInfo.value.id}/${student.id}?mode=edit`)
|
||||
}
|
||||
|
||||
const exportResults = () => {
|
||||
@ -386,7 +386,7 @@ const importStudents = () => {
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
// 搜索已经通过 computed 属性自动实现
|
||||
// 搜索通过 computed 属性实现
|
||||
message.info('搜索已应用')
|
||||
}
|
||||
|
||||
|