feat:添加练考通模块下的试卷库和新增试卷功能

This commit is contained in:
yuk255 2025-08-22 16:42:55 +08:00
parent 1a11290c97
commit 8cd22653aa
10 changed files with 3025 additions and 28 deletions

View File

@ -0,0 +1,324 @@
<template>
<n-modal v-model:show="showModal" class="batch-score-modal" preset="card"
:mask-closable="false" :closable="false" :style="{ width: '1000px' }">
<div class="header">
<span class="header-title">批量设置分数</span>
</div>
<n-divider />
<div class="batch-score-content">
<!-- 题目列表 -->
<div class="question-list">
<div v-for="(bigQuestion, bigIndex) in questionList" :key="bigQuestion.id"
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 }}
</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>
</div>
</div>
</div>
<!-- 底部按钮 -->
<div class="modal-actions">
<n-button strong secondary @click="cancelBatchSet">取消</n-button>
<n-button type="info" @click="confirmBatchSet">确定</n-button>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { createDiscreteApi } from 'naive-ui';
// message API
const { message } = createDiscreteApi(['message']);
//
enum QuestionType {
SINGLE_CHOICE = 'single_choice', //
MULTIPLE_CHOICE = 'multiple_choice', //
TRUE_FALSE = 'true_false', //
FILL_BLANK = 'fill_blank', //
SHORT_ANSWER = 'short_answer', //
COMPOSITE = 'composite' //
}
//
interface ChoiceOption {
id: string;
content: string;
isCorrect: boolean;
}
//
interface FillBlankAnswer {
id: string;
content: string;
position: number;
}
//
interface SubQuestion {
id: string;
type: QuestionType;
title: string;
score: number;
difficulty: 'easy' | 'medium' | 'hard';
required: boolean;
options?: ChoiceOption[];
correctAnswer?: string | string[];
fillBlanks?: FillBlankAnswer[];
trueFalseAnswer?: boolean;
textAnswer?: string;
answerKeywords?: string[];
subQuestions?: SubQuestion[];
explanation?: string;
tags?: string[];
createTime: string;
}
//
interface BigQuestion {
id: string;
title: string;
description?: string;
sort: number;
totalScore: number;
subQuestions: SubQuestion[];
createTime: string;
}
// Props
interface Props {
visible: boolean;
questions: BigQuestion[];
}
// Emits
interface Emits {
(e: 'update:visible', value: boolean): void;
(e: 'confirm', questions: BigQuestion[]): void;
(e: 'cancel'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
//
const showModal = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
});
//
const questionList = ref<BigQuestion[]>([]);
// props.questions
watch(() => props.questions, (newQuestions) => {
//
questionList.value = JSON.parse(JSON.stringify(newQuestions));
}, { immediate: true, deep: true });
//
const getQuestionTypeName = (type: QuestionType): string => {
const typeMap = {
[QuestionType.SINGLE_CHOICE]: '单选题',
[QuestionType.MULTIPLE_CHOICE]: '多选题',
[QuestionType.TRUE_FALSE]: '判断题',
[QuestionType.FILL_BLANK]: '填空题',
[QuestionType.SHORT_ANSWER]: '简答题',
[QuestionType.COMPOSITE]: '复合题'
};
return typeMap[type] || '未知题型';
};
//
const updateQuestionScore = (bigIndex: number, subIndex: number, score: number) => {
if (questionList.value[bigIndex] && questionList.value[bigIndex].subQuestions[subIndex]) {
questionList.value[bigIndex].subQuestions[subIndex].score = score || 0;
//
const bigQuestion = questionList.value[bigIndex];
bigQuestion.totalScore = bigQuestion.subQuestions.reduce((total, sub) => total + (sub.score || 0), 0);
}
};
//
const cancelBatchSet = () => {
showModal.value = false;
emit('cancel');
};
//
const confirmBatchSet = () => {
//
let hasInvalidScore = false;
for (const bigQuestion of questionList.value) {
for (const subQuestion of bigQuestion.subQuestions) {
if (subQuestion.score <= 0) {
hasInvalidScore = true;
break;
}
}
if (hasInvalidScore) break;
}
if (hasInvalidScore) {
message.warning('请确保所有题目分数都大于0');
return;
}
showModal.value = false;
emit('confirm', questionList.value);
message.success('批量设置分数成功');
};
</script>
<style scoped>
.batch-score-modal {
--n-color: #ffffff;
}
.header-title{
color: #000;
font-weight: 400;
font-size: 20px;
}
.batch-score-content {
max-height: 500px;
overflow-y: auto;
min-height: 500px;
}
.question-list {
padding: 0;
}
.big-question-section {
margin-bottom: 16px;
}
.question-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
margin-bottom: 8px;
background-color: #fafafa;
border-radius: 6px;
transition: all 0.2s;
}
.question-item:hover {
background-color: #f0f8ff;
border-color: #d1e7dd;
}
.question-info {
display: flex;
align-items: center;
flex: 1;
margin-right: 16px;
}
.question-number {
font-weight: 600;
color: #333;
margin-right: 8px;
min-width: 40px;
}
.question-type {
padding: 2px 6px;
border-radius: 4px;
margin-left: 12px;
white-space: nowrap;
}
.question-content {
color: #062333;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* max-width: 500px; */
flex: 1;
}
.question-score {
display: flex;
align-items: center;
white-space: nowrap;
}
.score-label {
margin-right: 8px;
color: #062333;
font-size: 14px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
}
/* 滚动条样式 */
.batch-score-content::-webkit-scrollbar {
width: 6px;
}
.batch-score-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.batch-score-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.batch-score-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 响应式设计 */
@media (max-width: 600px) {
.question-item {
flex-direction: column;
align-items: flex-start;
}
.question-info {
margin-right: 0;
margin-bottom: 8px;
width: 100%;
}
.question-content {
max-width: none;
}
.question-score {
align-self: flex-end;
}
}
</style>

View File

@ -0,0 +1,761 @@
<template>
<n-modal v-model:show="showModal" class="exam-settings-modal" preset="dialog" title="试卷设置" :mask-closable="false"
:closable="true" :style="{ width: '1000px' }">
<div class="exam-settings-content">
<!-- 试卷名称 -->
<div class="setting-row">
<label class="setting-label">试卷名称</label>
<n-input v-model:value="formData.title" placeholder="试卷名称或试卷名称" class="setting-input" />
</div>
<!-- 起止时间 -->
<div class="setting-row">
<label class="setting-label">起止时间</label>
<div class="time-range">
<n-date-picker v-model:value="formData.startTime" type="datetime" placeholder="选择考试开始时间"
format="yyyy-MM-dd HH:mm" class="time-picker" />
<span class="time-separator"></span>
<n-date-picker v-model:value="formData.endTime" type="datetime" placeholder="选择考试结束时间"
format="yyyy-MM-dd HH:mm" class="time-picker" />
</div>
</div>
<!-- 试卷分类 -->
<div class="setting-row">
<label class="setting-label">试卷分类</label>
<n-radio-group v-model:value="formData.category" class="category-group">
<n-radio value="exam">考试</n-radio>
<n-radio value="practice">练习</n-radio>
</n-radio-group>
</div>
<!-- 答题时间 - 考试模式显示 -->
<div v-if="formData.category === 'exam'" class="setting-row">
<label class="setting-label">答题时间</label>
<div class="answer-time-setting">
<n-radio-group v-model:value="formData.timeLimit" class="time-limit-group">
<n-radio value="limited">
<template #default>
<n-input v-model:value="formData.timeLimitValue" :min="0" size="small"
style="width: 80px; margin-right: 8px;" />
分钟
</template>
</n-radio>
<n-radio value="no_limit">不限时长</n-radio>
</n-radio-group>
</div>
</div>
<!-- 答题次数 -->
<div class="setting-row">
<label class="setting-label">答题次数</label>
<div class="exam-times-setting">
<n-radio-group v-model:value="formData.examTimes" class="exam-times-group">
<n-radio value="unlimited">无限次</n-radio>
<n-radio value="limited">
<n-input v-model:value="formData.examTimesValue" :min="1" size="small"
style="width: 80px; margin-left: 8px; margin-right: 8px;" />
</n-radio>
</n-radio-group>
</div>
</div>
<!-- 所属章节 -->
<div class="setting-row">
<label class="setting-label">所属章节</label>
<n-select v-model:value="formData.chapter" placeholder="第一节 开始想着" :options="chapterOptions"
class="setting-select" />
</div>
<!-- 及格分数 -->
<div class="setting-row">
<label class="setting-label">及格分数</label>
<div class="pass-score-setting">
<n-input v-model:value="formData.passScore" :min="0" :max="100" size="small" style="width: 80px" />
<span></span>
</div>
</div>
<!-- 参与学员 -->
<div class="setting-row">
<label class="setting-label">参与学员</label>
<div class="participants-setting">
<n-radio-group v-model:value="formData.participants" class="participants-group">
<n-radio value="all">全部学员</n-radio>
<n-radio value="by_school">仅部分学员</n-radio>
</n-radio-group>
</div>
</div>
<!-- 详细班级 - 当选择仅部分学员时显示 -->
<div v-if="formData.participants === 'by_school'" class="setting-row">
<label class="setting-label">详细班级</label>
<n-select v-model:value="formData.selectedClasses" placeholder="选择班级" multiple :options="classOptions"
class="setting-select" />
</div>
<!-- 考试说明 -->
<div class="setting-row">
<label class="setting-label">考试说明</label>
<n-input v-model:value="formData.instructions" type="textarea" placeholder="请填写试卷说明"
:autosize="{ minRows: 3, maxRows: 6 }" class="setting-textarea" />
</div>
<!-- 强制阅读考试说明 - 仅考试模式显示 -->
<div v-if="formData.category === 'exam'" class="setting-row">
<label class="setting-label"></label>
<div class="checkbox-setting">
<n-checkbox v-model:checked="formData.enforceInstructions">
强制阅读考试说明
</n-checkbox>
<n-input v-if="formData.enforceInstructions" v-model:value="formData.readingTime" :min="1"
size="small" style="width: 80px; margin-left: 8px; margin-right: 8px;" />
<span v-if="formData.enforceInstructions"></span>
</div>
</div>
<!-- 高级设置 -->
<n-collapse class="advanced-settings" arrow-placement="right">
<n-collapse-item title="高级设置" name="advanced">
<n-divider />
<!-- 考试模式的高级设置 -->
<div v-if="formData.category === 'exam'">
<!-- 交卷设置 -->
<div class="advanced-setting">
<label class="advanced-label">交卷设置</label>
<n-checkbox v-model:checked="formData.submitSettings.allowEarlySubmit">
考试时间结束允许延时自动提交
</n-checkbox>
</div>
<!-- 限时进入 -->
<div class="advanced-setting">
<label class="advanced-label">限时进入</label>
<div class="grading-settings">
<n-input v-model:value="formData.gradingDelay" :min="0" size="small"
style="width: 80px; margin-right: 8px;" />
<span>分钟后不允许参加考试</span>
</div>
</div>
<!-- 查看分数 -->
<div class="advanced-setting">
<label class="advanced-label">查看分数</label>
<n-radio-group v-model:value="formData.scoreDisplay" class="score-display-group">
<n-radio value="show_all">允许教师批阅完试卷后学员查看分数</n-radio>
</n-radio-group>
</div>
<!-- 查看答案 -->
<div class="advanced-setting">
<label class="advanced-label">查看答案</label>
<div class="answer-view-settings">
<n-checkbox v-model:checked="formData.detailedSettings.showQuestions">
考试截止后允许查看答案
</n-checkbox>
<n-checkbox v-model:checked="formData.detailedSettings.showAnalysis">
学生提交后允许查看答案
</n-checkbox>
</div>
</div>
<!-- 展示排名 -->
<div class="advanced-setting">
<label class="advanced-label">展示排名</label>
<n-checkbox v-model:checked="formData.showRanking">
考试截止后展示排名
</n-checkbox>
</div>
<!-- 发送通知 -->
<div class="advanced-setting">
<label class="advanced-label">发送通知</label>
<n-flex vertical>
<n-switch v-model:value="formData.timerEnabled" />
<div v-if="formData.timerEnabled" class="timer-setting">
<span>考试结束前</span>
<n-input v-model:value="formData.timerDuration" :min="1" size="small"
style="width: 80px; margin: 0 8px;" />
<span>分钟发送通知提醒未交学生</span>
</div>
</n-flex>
</div>
<!-- 作答要求 -->
<div class="advanced-setting">
<label class="advanced-label">作答要求</label>
<n-radio-group v-model:value="formData.answerType" class="answer-type-group">
<n-radio value="auto_save">
完成当前课程进度
<n-input v-model:value="formData.courseProgress" :min="0" :max="100" size="small"
style="width: 80px; margin: 0 8px;" />
%允许考试
</n-radio>
</n-radio-group>
</div>
<!-- 详分设置 -->
<div class="advanced-setting">
<label class="advanced-label">详分设置</label>
<n-radio-group v-model:value="formData.detailScoreMode" class="detail-score-group">
<n-radio value="question">填空题简答题题目设为为主观题 <span class="tip">设为主观题后需教师手动批阅</span> </n-radio>
<n-radio value="automatic">填空题简答题不区分大小写 <span class="tip">勾选后英文大写和小写都可以得分</span> </n-radio>
<n-radio value="show_current">填空题简答题忽略符号 <span class="tip">勾选后答案内符号与标准答案不同也给分</span> </n-radio>
<n-radio value="show_all">多选题未全选对时得一半分 <span class="tip">不勾选时全选对才给分</span> </n-radio>
</n-radio-group>
</div>
</div>
<!-- 练习模式的高级设置 -->
<div v-if="formData.category === 'practice'">
<!-- 重做设置 -->
<div class="advanced-setting">
<label class="advanced-label">重做设置</label>
<n-flex>
<n-radio-group v-model:value="formData.correctnessMode" class="correctness-group">
<div class="practice-record-settings">
<n-radio value="no_limit">允许学员练习不限次数</n-radio>
<n-radio value="limit_wrong">
允许学员重新练习
<n-input v-model:value="formData.wrongLimit" :min="1" size="small"
style="width: 80px; margin: 0 8px;" />
</n-radio>
</div>
</n-radio-group>
<div class="practice-record-settings2">
<n-checkbox v-model:checked="formData.practiceSettings.keepPreviousAnswers">
学员重新练习保留前一次的作答记录
</n-checkbox>
<n-checkbox v-model:checked="formData.practiceSettings.useLastScore">
最后一次练习成绩为最终成绩
</n-checkbox>
</div>
</n-flex>
</div>
<!-- 试卷设置 -->
<div class="advanced-setting">
<label class="advanced-label">试卷设置</label>
<div class="practice-settings">
<n-checkbox v-model:checked="formData.practiceSettings.showCorrectAnswer">
允许学生主题完成前查看答案
</n-checkbox>
<n-checkbox v-model:checked="formData.practiceSettings.showWrongAnswer">
每道一次练习题查看提交练习
</n-checkbox>
<n-checkbox v-model:checked="formData.practiceSettings.showAnalysis">
允许学生查看排名
</n-checkbox>
</div>
</div>
</div>
</n-collapse-item>
</n-collapse>
</div>
<!-- 底部按钮 -->
<template #action>
<div class="modal-actions">
<n-button @click="cancelSettings">取消</n-button>
<n-button type="primary" @click="confirmSettings">确定</n-button>
</div>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { createDiscreteApi } from 'naive-ui';
// message API
const { message } = createDiscreteApi(['message']);
//
interface ExamSettings {
title: string;
startTime: number | null;
endTime: number | null;
category: 'exam' | 'practice';
timeLimit: 'unlimited' | 'limited' | 'no_limit';
timeLimitValue: number;
examTimes: 'unlimited' | 'limited' | 'each_day';
examTimesValue: number;
dailyLimit: number;
chapter: string;
passScore: number;
participants: 'all' | 'by_school';
selectedClasses: string[];
instructions: string;
//
enforceOrder: boolean;
enforceInstructions: boolean;
readingTime: number;
submitSettings: {
allowEarlySubmit: boolean;
};
gradingDelay: number;
scoreDisplay: 'show_all' | 'show_score' | 'hide_all';
detailedSettings: {
showQuestions: boolean;
showAnalysis: boolean;
showSubmissionTime: boolean;
};
timerEnabled: boolean;
timerDuration: number;
answerType: 'auto_save' | 'manual_save' | 'multiple_submit';
detailScoreMode: 'question' | 'automatic' | 'show_current' | 'show_all';
showRanking: boolean; //
courseProgress: number; //
//
correctnessMode: 'no_limit' | 'limit_wrong';
wrongLimit: number;
practiceSettings: {
showCorrectAnswer: boolean;
showWrongAnswer: boolean;
showAnalysis: boolean;
keepPreviousAnswers: boolean; //
useLastScore: boolean; //
};
paperMode: 'show_all' | 'show_current' | 'hide_all';
}
// Props
interface Props {
visible: boolean;
examData: ExamSettings;
}
// Emits
interface Emits {
(e: 'update:visible', value: boolean): void;
(e: 'confirm', settings: ExamSettings): void;
(e: 'cancel'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
//
const showModal = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
});
//
const formData = ref<ExamSettings>({
title: '',
startTime: null,
endTime: null,
category: 'exam',
timeLimit: 'limited',
timeLimitValue: 0,
examTimes: 'unlimited',
examTimesValue: 1,
dailyLimit: 1,
chapter: '',
passScore: 60,
participants: 'all',
selectedClasses: [],
instructions: '',
//
enforceOrder: false,
enforceInstructions: false,
readingTime: 10,
submitSettings: {
allowEarlySubmit: true,
},
gradingDelay: 60,
scoreDisplay: 'show_all',
detailedSettings: {
showQuestions: false,
showAnalysis: false,
showSubmissionTime: false,
},
timerEnabled: false,
timerDuration: 10,
answerType: 'auto_save',
detailScoreMode: 'question',
showRanking: false,
courseProgress: 0,
//
correctnessMode: 'no_limit',
wrongLimit: 10,
practiceSettings: {
showCorrectAnswer: false,
showWrongAnswer: false,
showAnalysis: false,
keepPreviousAnswers: false,
useLastScore: false,
},
paperMode: 'show_all',
});
// props.examData
watch(() => props.examData, (newData) => {
if (newData) {
formData.value = JSON.parse(JSON.stringify(newData));
}
}, { immediate: true, deep: true });
//
const chapterOptions = ref([
{ label: '第一节 开课前准备', value: 'chapter1' },
{ label: '第二节 课程内容', value: 'chapter2' },
{ label: '第三节 课程总结', value: 'chapter3' },
]);
//
const classOptions = ref([
{ label: '全部学员', value: 'all' },
{ label: '优班学习', value: 'class1' },
{ label: '一班', value: 'class2' },
]);
//
const cancelSettings = () => {
showModal.value = false;
emit('cancel');
};
//
const confirmSettings = () => {
//
if (!formData.value.title.trim()) {
message.warning('请输入试卷名称');
return;
}
if (!formData.value.startTime || !formData.value.endTime) {
message.warning('请设置起止时间');
return;
}
if (formData.value.startTime >= formData.value.endTime) {
message.warning('结束时间必须大于开始时间');
return;
}
showModal.value = false;
emit('confirm', formData.value);
message.success('试卷设置保存成功');
};
</script>
<style scoped>
.exam-settings-modal {
--n-color: #ffffff;
}
.exam-settings-content {
max-height: 800px;
overflow-y: auto;
padding: 8px 0;
}
.setting-row {
display: flex;
align-items: center;
margin-bottom: 16px;
min-height: 32px;
}
.setting-label {
flex: 0 0 80px;
font-weight: 500;
color: #333;
margin-right: 16px;
line-height: 32px;
font-size: 14px;
}
.setting-input,
.setting-select,
.setting-textarea {
flex: 1;
}
.time-range {
display: flex;
align-items: center;
flex: 1;
}
.time-picker {
flex: 1;
}
.time-separator {
margin: 0 12px;
color: #666;
}
.category-group {
display: flex;
gap: 16px;
}
.answer-time-setting,
.exam-times-setting {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.time-limit-group,
.exam-times-group {
display: flex;
gap: 16px;
}
.time-limit-input,
.exam-times-input {
display: flex;
align-items: center;
gap: 4px;
}
.exam-times-option {
display: flex;
align-items: center;
gap: 4px;
}
.pass-score-setting {
display: flex;
align-items: center;
gap: 4px;
}
.participants-group {
display: flex;
gap: 16px;
}
.advanced-setting {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
}
.practice-settings{
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.advanced-label {
flex: 0 0 80px;
font-weight: 500;
color: #333;
margin-right: 16px;
line-height: 32px;
font-size: 14px;
}
.answer-view-settings {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.timer-setting {
display: flex;
align-items: center;
margin-top: 8px;
gap: 4px;
}
.grading-settings {
display: flex;
align-items: center;
gap: 8px;
}
.score-display-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.detailed-settings {
display: flex;
flex-direction: column;
gap: 8px;
}
.timer-settings {
display: flex;
align-items: center;
margin-top: 8px;
}
.answer-settings {
margin-top: 8px;
}
.answer-type-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.correctness-settings {
display: flex;
flex-direction: column;
gap: 8px;
}
.correctness-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.wrong-limit-input {
display: flex;
align-items: center;
}
.category-group {
display: flex;
gap: 20px;
}
.time-limit-group {
display: flex;
gap: 20px;
align-items: center;
}
.exam-times-group {
display: flex;
gap: 20px;
align-items: center;
}
.correctness-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.answer-type-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.score-display-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-score-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.tip{
margin-left: 10px;
font-size: 12px;
color: #999999;
}
.checkbox-setting {
display: flex;
align-items: center;
gap: 8px;
}
.practice-options {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.practice-record-settings {
display: flex;
flex-direction: row;
gap: 8px;
flex: 1;
}
.practice-record-settings2 {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.paper-settings {
margin-top: 8px;
}
.paper-mode-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 滚动条样式 */
.exam-settings-content::-webkit-scrollbar {
width: 6px;
}
.exam-settings-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.exam-settings-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.exam-settings-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 响应式设计 */
@media (max-width: 600px) {
.setting-row {
flex-direction: column;
align-items: flex-start;
}
.setting-label {
margin-bottom: 8px;
line-height: 1.5;
}
.time-range {
flex-direction: column;
gap: 8px;
}
.time-separator {
align-self: center;
}
}
</style>

View File

@ -34,6 +34,7 @@ import {
NBreadcrumbItem,
NInput,
NInputGroup,
NInputNumber,
NForm,
NFormItem,
NSelect,
@ -41,6 +42,7 @@ import {
NTimePicker,
NCheckbox,
NRadio,
NRadioGroup,
NSwitch,
NSlider,
NRate,
@ -82,7 +84,8 @@ import {
NStep,
NTimeline,
NTimelineItem,
NMessageProvider
NMessageProvider,
NPopselect
} from 'naive-ui'
const naive = create({
@ -104,6 +107,7 @@ const naive = create({
NBreadcrumbItem,
NInput,
NInputGroup,
NInputNumber,
NForm,
NFormItem,
NSelect,
@ -111,6 +115,7 @@ const naive = create({
NTimePicker,
NCheckbox,
NRadio,
NRadioGroup,
NSwitch,
NSlider,
NRate,
@ -152,7 +157,8 @@ const naive = create({
NStep,
NTimeline,
NTimelineItem,
NMessageProvider
NMessageProvider,
NPopselect
]
})

View File

@ -52,6 +52,12 @@ import StatisticsManagement from '@/views/teacher/course/StatisticsManagement.vu
import NotificationManagement from '@/views/teacher/course/NotificationManagement.vue'
import GeneralManagement from '@/views/teacher/course/GeneralManagement.vue'
// 练考通菜单组件
import ExamLibrary from '@/views/teacher/course/ExamPages/ExamLibrary.vue'
import MarkingCenter from '@/views/teacher/course/ExamPages/MarkingCenter.vue'
import AddExam from '@/views/teacher/course/ExamPages/AddExam.vue'
// ========== 路由配置 ==========
const routes: RouteRecordRaw[] = [
// 管理后台路由
@ -132,7 +138,35 @@ const routes: RouteRecordRaw[] = [
path: 'practice',
name: 'PracticeManagement',
component: PracticeManagement,
meta: { title: '练考通管理' }
meta: { title: '练考通管理' },
redirect: (to) => `/teacher/course-editor/${to.params.id}/practice/exam-library`,
children: [
{
path: 'exam-library',
name: 'ExamLibrary',
component: ExamLibrary,
meta: {
title: '试卷库'
},
},
{
path: 'marking-center',
name: 'MarkingCenter',
component: MarkingCenter,
meta: {
title: '阅卷中心'
}
},
{
path: 'add',
name: 'AddExam',
component: AddExam,
meta: {
title: '添加试卷',
hideSidebar: true
}
}
]
},
{
path: 'question-bank',

View File

@ -1,7 +1,7 @@
<template>
<div class="course-editor">
<!-- 左侧导航菜单 -->
<div class="sidebar">
<div class="sidebar" v-if="showSidebar">
<router-link
:to="`/teacher/course-editor/${courseId}/courseware`"
class="menu-item"
@ -35,17 +35,43 @@
/>
<span>作业</span>
</router-link>
<router-link
:to="`/teacher/course-editor/${courseId}/practice`"
<!-- 练考通父菜单 -->
<div
class="menu-item"
:class="{ active: $route.path.includes('practice') }"
@click="togglePracticeMenu"
>
<img
:src="$route.path.includes('practice') ? '/images/teacher/练考通-选中.png' : '/images/teacher/练考通.png'"
:src="($route.path.includes('practice')) ? '/images/teacher/练考通-选中.png' : '/images/teacher/练考通.png'"
alt="练考通"
/>
<span>练考通</span>
<div class="expand-icon" :class="{ expanded: practiceMenuExpanded }">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
<!-- 练考通子菜单 -->
<div class="submenu" :class="{ expanded: practiceMenuExpanded }">
<router-link
:to="`/teacher/course-editor/${courseId}/practice/exam-library`"
class="submenu-item"
:class="{ active: $route.path.includes('exam-library') }"
>
<span>试卷库</span>
</router-link>
<router-link
:to="`/teacher/course-editor/${courseId}/practice/marking-center`"
class="submenu-item"
:class="{ active: $route.path.includes('marking-center') }"
>
<span>阅卷中心</span>
</router-link>
</div>
<router-link
:to="`/teacher/course-editor/${courseId}/question-bank`"
class="menu-item"
@ -122,12 +148,34 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// ID
const courseId = route.params.id
//
const practiceMenuExpanded = ref(false)
//
const togglePracticeMenu = () => {
practiceMenuExpanded.value = !practiceMenuExpanded.value
}
//
watch(() => route.path, (newPath) => {
if (newPath.includes('exam-library') || newPath.includes('marking-center')) {
practiceMenuExpanded.value = true
}
}, { immediate: true })
//
const showSidebar = computed(() => {
return route.meta.hideSidebar !== true
})
</script>
<style scoped>
@ -186,6 +234,58 @@ const courseId = route.params.id
.menu-item span {
font-size: 16px;
color: #666;
flex: 1;
}
.expand-icon {
transition: transform 0.3s ease;
display: flex;
align-items: center;
color: #999;
}
.expand-icon.expanded {
transform: rotate(180deg);
}
/* 子菜单样式 */
.submenu {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
margin-left: 20px;
border-radius: 5px;
}
.submenu.expanded {
max-height: 200px;
}
.submenu-item {
display: flex;
align-items: center;
padding: 12px 20px 12px 60px;
margin: 0;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
color: #666;
font-size: 14px;
border-radius: 5px;
margin-top: 5px;
}
.submenu-item:hover {
background: #e9ecef;
}
.submenu-item.active {
background: #E3F2FD;
color: #0288D1;
}
.submenu-item span {
font-size: 14px;
}
/* 右侧内容区域 */

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,236 @@
<template>
<n-config-provider :theme-overrides="themeOverrides">
<div class="exam-library-container">
<div class="header-section">
<h1 class="title">试卷库</h1>
<n-space class="actions-group">
<n-button type="primary" @click="handleAddExam">添加试卷</n-button>
<n-button ghost>导入</n-button>
<n-button ghost>导出</n-button>
<n-button type="error" ghost>删除</n-button>
<n-input placeholder="请输入想要搜索的内容" />
<n-button type="primary">搜索</n-button>
</n-space>
</div>
<n-data-table :columns="columns" :data="examData" :row-key="(row: Exam) => row.id"
@update:checked-row-keys="handleCheck" class="exam-table" :single-line="false" />
<div class="pagination-container">
<n-pagination v-model:page="currentPage" :page-count="totalPages" />
</div>
</div>
</n-config-provider>
</template>
<script setup lang="ts">
import { h, ref, VNode } from 'vue';
import { NButton, NSpace, useMessage, NDataTable, NPagination, NInput, NConfigProvider } from 'naive-ui';
import type { DataTableColumns, GlobalThemeOverrides } from 'naive-ui';
import { useRouter } from 'vue-router';
const router = useRouter();
//
type Exam = {
id: number;
name: string;
category: '练习' | '考试';
questionCount: number;
chapter: string;
totalScore: number;
difficulty: '易' | '中' | '难';
status: '发布中' | '未发布' | '已结束';
startTime: string;
endTime: string;
creator: string;
creationTime: string;
};
//
const themeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#0288D1',
primaryColorHover: '#0277BD', // A slightly darker shade for hover
primaryColorPressed: '#01579B', // A darker shade for pressed
errorColor: '#FF4D4F',
errorColorHover: '#E54547',
errorColorPressed: '#C0383A',
},
Button: {
// For ghost primary buttons
textColorGhostPrimary: '#0288D1',
borderPrimary: '1px solid #0288D1',
// For ghost error buttons
textColorGhostError: '#FF4D4F',
borderError: '1px solid #FF4D4F',
},
Pagination: {
itemColorActive: '#0288D1',
itemTextColorActive: '#fff',
itemBorderActive: '1px solid #0288D1',
itemColorActiveHover: '#0277BD', // 使
itemTextColorActiveHover: '#fff', //
itemBorderActiveHover: '1px solid #0277BD' //
}
};
const message = useMessage();
//
const createColumns = ({
handleAction,
}: {
handleAction: (action: string, rowData: Exam) => void;
}): DataTableColumns<Exam> => {
return [
{
type: 'selection',
},
{
title: '序号',
key: 'id',
width: 60,
align: 'center',
},
{
title: '试卷名称',
key: 'name',
},
{
title: '分类',
key: 'category',
},
{
title: '题量',
key: 'questionCount',
},
{
title: '所属章节',
key: 'chapter',
},
{
title: '总分',
key: 'totalScore',
},
{
title: '难度',
key: 'difficulty',
},
{
title: '状态',
key: 'status',
},
{
title: '起止时间',
key: 'startTime',
render(row) {
return `${row.startTime} - ${row.endTime}`;
},
},
{
title: '创建人',
key: 'creator',
},
{
title: '创建时间',
key: 'creationTime',
},
{
title: '操作',
key: 'actions',
render(row) {
const buttons: VNode[] = [];
if (row.status === '发布中') {
buttons.push(
h(NButton, { size: 'small', type: 'primary', ghost: true, style: 'margin: 0 3px;', onClick: () => handleAction('批阅', row) }, { default: () => '批阅' })
);
} else if (row.status === '未发布') {
buttons.push(
h(NButton, { size: 'small', type: 'primary', style: 'margin: 0 3px;', onClick: () => handleAction('发布', row) }, { default: () => '发布' })
);
}
buttons.push(
h(NButton, { size: 'small', type: 'primary', ghost: true, style: 'margin: 0 3px;', onClick: () => handleAction('编辑', row) }, { default: () => '编辑' })
);
buttons.push(
h(NButton, { size: 'small', type: 'error', ghost: true, style: 'margin: 0 3px;', onClick: () => handleAction('删除', row) }, { default: () => '删除' })
);
return h(NSpace, {}, { default: () => buttons });
},
},
];
};
//
const examData = ref<Exam[]>([
{ id: 1, name: '试卷名称试卷名称', category: '练习', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '发布中', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
{ id: 2, name: '试卷名称试卷名称', category: '考试', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '未发布', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
{ id: 3, name: '试卷名称试卷名称', category: '练习', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '发布中', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
{ id: 4, name: '试卷名称试卷名称', category: '考试', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '发布中', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
{ id: 5, name: '试卷名称试卷名称', category: '练习', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '发布中', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
{ id: 6, name: '试卷名称试卷名称', category: '考试', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '未发布', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
{ id: 7, name: '试卷名称试卷名称', category: '练习', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '发布中', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
{ id: 8, name: '试卷名称试卷名称', category: '考试', questionCount: 100, chapter: '第一节 开课前准备', totalScore: 150, difficulty: '易', status: '未发布', startTime: '2025.07.25 09:20', endTime: '2025.07.25 09:20', creator: '王建国', creationTime: '2025.07.25 9:20' },
]);
const columns = createColumns({
handleAction: (action, row) => {
message.info(`执行操作: ${action} on row ${row.id}`);
},
});
const checkedRowKeys = ref<Array<string | number>>([]);
const handleCheck = (rowKeys: Array<string | number>) => {
checkedRowKeys.value = rowKeys;
};
//
const currentPage = ref(1);
const totalPages = ref(29); //
const handleAddExam = () => {
//
router.push({ name: 'AddExam' });
};
</script>
<style scoped>
.exam-library-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;
}
.exam-table {
margin-top: 20px;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>

View File

@ -0,0 +1,11 @@
<template>
<div>
<h1>阅卷中心</h1>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>

View File

@ -1,9 +1,6 @@
<template>
<div class="practice-management">
<div class="content-placeholder">
<h2>练考通管理</h2>
<p>练考通管理功能正在开发中...</p>
</div>
<router-view></router-view>
</div>
</template>
@ -17,20 +14,4 @@
background: #fff;
height: 100%;
}
.content-placeholder {
text-align: center;
padding: 60px 20px;
}
.content-placeholder h2 {
font-size: 24px;
color: #333;
margin-bottom: 20px;
}
.content-placeholder p {
font-size: 16px;
color: #666;
}
</style>