merge: integrate origin/dev and resolve conflicts

This commit is contained in:
Wxp 2025-08-22 17:08:52 +08:00
commit 4e983b923c
10 changed files with 3056 additions and 68 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, NBreadcrumbItem,
NInput, NInput,
NInputGroup, NInputGroup,
NInputNumber,
NForm, NForm,
NFormItem, NFormItem,
NSelect, NSelect,
@ -41,6 +42,7 @@ import {
NTimePicker, NTimePicker,
NCheckbox, NCheckbox,
NRadio, NRadio,
NRadioGroup,
NSwitch, NSwitch,
NSlider, NSlider,
NRate, NRate,
@ -82,7 +84,8 @@ import {
NStep, NStep,
NTimeline, NTimeline,
NTimelineItem, NTimelineItem,
NMessageProvider NMessageProvider,
NPopselect
} from 'naive-ui' } from 'naive-ui'
const naive = create({ const naive = create({
@ -104,6 +107,7 @@ const naive = create({
NBreadcrumbItem, NBreadcrumbItem,
NInput, NInput,
NInputGroup, NInputGroup,
NInputNumber,
NForm, NForm,
NFormItem, NFormItem,
NSelect, NSelect,
@ -111,6 +115,7 @@ const naive = create({
NTimePicker, NTimePicker,
NCheckbox, NCheckbox,
NRadio, NRadio,
NRadioGroup,
NSwitch, NSwitch,
NSlider, NSlider,
NRate, NRate,
@ -152,7 +157,8 @@ const naive = create({
NStep, NStep,
NTimeline, NTimeline,
NTimelineItem, NTimelineItem,
NMessageProvider NMessageProvider,
NPopselect
] ]
}) })

View File

@ -51,9 +51,13 @@ import StatisticsManagement from '@/views/teacher/course/StatisticsManagement.vu
import NotificationManagement from '@/views/teacher/course/NotificationManagement.vue' import NotificationManagement from '@/views/teacher/course/NotificationManagement.vue'
import GeneralManagement from '@/views/teacher/course/GeneralManagement.vue' import GeneralManagement from '@/views/teacher/course/GeneralManagement.vue'
// 作业管理子组件 // 作业子组件
import HomeworkLibrary from '@/views/teacher/course/HomeworkLibrary.vue' import HomeworkLibrary from '@/views/teacher/course/HomeworkLibrary.vue'
import HomeworkReview from '@/views/teacher/course/HomeworkReview.vue' import HomeworkReview from '@/views/teacher/course/HomeworkReview.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[] = [ const routes: RouteRecordRaw[] = [
@ -148,7 +152,35 @@ const routes: RouteRecordRaw[] = [
path: 'practice', path: 'practice',
name: 'PracticeManagement', name: 'PracticeManagement',
component: 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', path: 'question-bank',

View File

@ -1,11 +1,16 @@
<template> <template>
<div class="course-editor"> <div class="course-editor">
<!-- 左侧导航菜单 --> <!-- 左侧导航菜单 -->
<div class="sidebar"> <div class="sidebar" v-if="showSidebar">
<router-link :to="`/teacher/course-editor/${courseId}/courseware`" class="menu-item" <router-link
:class="{ active: $route.path.includes('courseware') }"> :to="`/teacher/course-editor/${courseId}/courseware`"
<img :src="$route.path.includes('courseware') ? '/images/teacher/课件-选中.png' : '/images/teacher/课件.png'" class="menu-item"
alt="课件" /> :class="{ active: $route.path.includes('courseware') }"
>
<img
:src="$route.path.includes('courseware') ? '/images/teacher/课件-选中.png' : '/images/teacher/课件.png'"
alt="课件"
/>
<span>课件</span> <span>课件</span>
</router-link> </router-link>
<router-link :to="`/teacher/course-editor/${courseId}/chapters`" class="menu-item" <router-link :to="`/teacher/course-editor/${courseId}/chapters`" class="menu-item"
@ -14,41 +19,63 @@
alt="章节" /> alt="章节" />
<span>章节</span> <span>章节</span>
</router-link> </router-link>
<!-- 作业二级导航 --> <router-link
<div class="menu-group"> :to="`/teacher/course-editor/${courseId}/homework`"
<div class="menu-header" @click="toggleHomework"> class="menu-item"
<img :src="$route.path.includes('homework') ? '/images/teacher/作业-选中.png' : '/images/teacher/作业.png'" :class="{ active: $route.path.includes('homework') }"
alt="作业" /> >
<span>作业</span> <img
<i class="n-base-icon" :class="{ 'expanded': homeworkExpanded }"> :src="$route.path.includes('homework') ? '/images/teacher/作业-选中.png' : '/images/teacher/作业.png'"
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> alt="作业"
<path />
d="M5.64645 3.14645C5.45118 3.34171 5.45118 3.65829 5.64645 3.85355L9.79289 8L5.64645 12.1464C5.45118 12.3417 5.45118 12.6583 5.64645 12.8536C5.84171 13.0488 6.15829 13.0488 6.35355 12.8536L10.8536 8.35355C11.0488 8.15829 11.0488 7.84171 10.8536 7.64645L6.35355 3.14645C6.15829 2.95118 5.84171 2.95118 5.64645 3.14645Z" <span>作业</span>
fill="#C2C2C2"></path> </router-link>
</svg>
</i> <!-- 练考通父菜单 -->
</div> <div
<div class="submenu" v-show="homeworkExpanded"> class="menu-item"
<router-link :to="`/teacher/course-editor/${courseId}/homework/library`" class="submenu-item" :class="{ active: $route.path.includes('practice') }"
:class="{ active: $route.path.includes('homework/library') }"> @click="togglePracticeMenu"
<span>作业库</span> >
</router-link> <img
<router-link :to="`/teacher/course-editor/${courseId}/homework/review`" class="submenu-item" :src="($route.path.includes('practice')) ? '/images/teacher/练考通-选中.png' : '/images/teacher/练考通.png'"
:class="{ active: $route.path.includes('homework/review') }"> alt="练考通"
<span>批阅作业</span> />
</router-link> <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> </div>
<router-link :to="`/teacher/course-editor/${courseId}/practice`" class="menu-item"
:class="{ active: $route.path.includes('practice') }"> <!-- 练考通子菜单 -->
<img :src="$route.path.includes('practice') ? '/images/teacher/练考通-选中.png' : '/images/teacher/练考通.png'" <div class="submenu" :class="{ expanded: practiceMenuExpanded }">
alt="练考通" /> <router-link
<span>练考通</span> :to="`/teacher/course-editor/${courseId}/practice/exam-library`"
</router-link> class="submenu-item"
<router-link :to="`/teacher/course-editor/${courseId}/question-bank`" class="menu-item" :class="{ active: $route.path.includes('exam-library') }"
:class="{ active: $route.path.includes('question-bank') }"> >
<img :src="$route.path.includes('question-bank') ? '/images/teacher/题库-选中.png' : '/images/teacher/题库.png'" <span>试卷库</span>
alt="题库" /> </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"
:class="{ active: $route.path.includes('question-bank') }"
>
<img
:src="$route.path.includes('question-bank') ? '/images/teacher/题库-选中.png' : '/images/teacher/题库.png'"
alt="题库"
/>
<span>题库</span> <span>题库</span>
</router-link> </router-link>
<router-link :to="`/teacher/course-editor/${courseId}/certificate`" class="menu-item" <router-link :to="`/teacher/course-editor/${courseId}/certificate`" class="menu-item"
@ -91,21 +118,34 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ref } from 'vue'
const route = useRoute() const route = useRoute()
// ID // ID
const courseId = route.params.id const courseId = route.params.id
// //
const homeworkExpanded = ref(false) const practiceMenuExpanded = ref(false)
// / //
const toggleHomework = () => { const togglePracticeMenu = () => {
homeworkExpanded.value = !homeworkExpanded.value 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> </script>
<style scoped> <style scoped>
@ -257,7 +297,60 @@ const toggleHomework = () => {
.submenu-item span { .submenu-item span {
font-size: 16px; font-size: 16px;
color: #666; 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;
}
/* 右侧内容区域 */ /* 右侧内容区域 */
.content-area { .content-area {
flex: 1; flex: 1;

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> <template>
<div class="practice-management"> <div class="practice-management">
<div class="content-placeholder"> <router-view></router-view>
<h2>练考通管理</h2>
<p>练考通管理功能正在开发中...</p>
</div>
</div> </div>
</template> </template>
@ -17,20 +14,4 @@
background: #fff; background: #fff;
height: 100%; 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> </style>