This commit is contained in:
QDKF 2025-08-29 20:12:37 +08:00
commit 39fff08a0d
30 changed files with 3561 additions and 974 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -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,19 +77,21 @@ onMounted(() => {
<template>
<div id="app">
<n-config-provider :theme-overrides="themeOverrides">
<!-- 登录页面不使用 AppLayout但需要 message provider -->
<template v-if="isLoginPage">
<n-message-provider>
<RouterView />
</n-message-provider>
</template>
<!-- 其他页面使用 AppLayout -->
<template v-else>
<AppLayout>
<RouterView />
</AppLayout>
</template>
<n-config-provider :theme-overrides="themeOverrides" :locale="naiveLocale" :date-locale="naiveDateLocale">
<n-dialog-provider>
<!-- 登录页面不使用 AppLayout但需要 message provider -->
<template v-if="isLoginPage">
<n-message-provider>
<RouterView />
</n-message-provider>
</template>
<!-- 其他页面使用 AppLayout -->
<template v-else>
<AppLayout>
<RouterView />
</AppLayout>
</template>
</n-dialog-provider>
</n-config-provider>
</div>
</template>

View File

@ -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
View 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

View File

@ -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
}

View File

@ -12,24 +12,73 @@
class="big-question-section">
<div v-for="(subQuestion, subIndex) in bigQuestion.subQuestions" :key="subQuestion.id"
class="question-item">
<div class="question-info">
<span class="question-number">{{ bigIndex + 1 }}.{{ subIndex + 1 }}</span>
<div class="question-content">
{{ subQuestion.title }}
<!-- 普通题目显示 -->
<template v-if="subQuestion.type !== 'composite'">
<div class="question-info">
<span class="question-number">{{ bigIndex + 1 }}.{{ subIndex + 1 }}</span>
<div class="question-content">
{{ subQuestion.title }}
</div>
<span class="question-type">{{ getQuestionTypeName(subQuestion.type) }}</span>
</div>
<span class="question-type">{{ getQuestionTypeName(subQuestion.type) }}</span>
</div>
<div class="question-score">
<span class="score-label">分数</span>
<n-input-number
v-model:value="subQuestion.score"
size="small"
:min="0"
:max="100"
:precision="1"
@update:value="updateQuestionScore(bigIndex, subIndex, $event)"
/>
</div>
<div class="question-score">
<span class="score-label">分数</span>
<n-input-number
v-model:value="subQuestion.score"
size="small"
:min="0"
:max="100"
:precision="1"
@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;

View File

@ -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

View File

@ -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,37 +473,30 @@ const switchQuality = (quality: any) => {
player.pause()
}
//
if (typeof player.switchVideo === 'function') {
player.switchVideo({
url: quality.url,
type: 'auto'
})
//
if (player) {
player.destroy()
player = null
}
//
// 使URL
initializePlayer(quality.url).then(() => {
console.log('✅ 播放器重新初始化完成新URL:', quality.url)
//
setTimeout(() => {
if (player && player.video) {
player.seek(currentTime)
if (wasPlaying) {
player.play()
}
console.log('✅ 恢复播放状态:', { currentTime, wasPlaying })
}
}, 1000)
} else {
//
initializePlayer(quality.url).then(() => {
//
setTimeout(() => {
if (player && player.video) {
player.seek(currentTime)
if (wasPlaying) {
player.play()
}
}
}, 500)
})
}
}, 500)
}).catch(error => {
console.error('❌ 重新初始化播放器失败:', error)
})
//
emit('qualityChange', quality.value)
console.log('✅ 切换清晰度到:', quality.label)
} catch (error) {

View File

@ -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())
}
// 使MutationObserverdropdown
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;

View File

@ -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 })

View File

@ -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
]
})

View File

@ -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: '考试进行中' }
},
// 帮助中心
{

View File

@ -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 () => {

View File

@ -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 || '';
}

File diff suppressed because it is too large Load Diff

View File

@ -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 } = {

View File

@ -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}`;

View File

@ -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;

File diff suppressed because it is too large Load Diff

View 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,8 +199,21 @@
</div>
</div>
<div class="text">
<QuillEditor :placeholder="'请输入阅卷评语...300字以内'" v-model="gradingComments" height="400px">
</QuillEditor>
<!-- 编辑模式显示富文本编辑器 -->
<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>
@ -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;

View 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>

View File

@ -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;

View File

@ -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('搜索已应用')
}