|
|
|
|
@ -8,22 +8,14 @@
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<nav v-if="!inProgress" class="prototype-nav" aria-label="考试编辑步骤">
|
|
|
|
|
<button type="button" class="step-node" :class="{ 'is-active': activeStep === 0 }" @click="activeStep = 0"><span class="step-num">01</span>考试设置</button>
|
|
|
|
|
<el-icon class="step-arrow"><ArrowRight /></el-icon>
|
|
|
|
|
<button type="button" class="step-node" :class="{ 'is-active': activeStep === 1 }" @click="activeStep = 1"><span class="step-num">02</span>添加试题</button>
|
|
|
|
|
<el-icon class="step-arrow"><ArrowRight /></el-icon>
|
|
|
|
|
<button type="button" class="step-node" :class="{ 'is-active': activeStep === 2 }" @click="activeStep = 2"><span class="step-num">03</span>试卷预览</button>
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
<section v-if="activeStep === 0 || activeStep === 1" class="editor-panel prototype-base-panel">
|
|
|
|
|
<section class="editor-panel prototype-base-panel">
|
|
|
|
|
<div class="section-card">
|
|
|
|
|
<header class="card-head"><div><h2>基础设置</h2><p>填写考试名称、时间与班级,分值矩阵自动汇总</p></div></header>
|
|
|
|
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" class="base-form">
|
|
|
|
|
<el-form-item label="考试名称" prop="name"><el-input v-model="form.name" maxlength="20" show-word-limit :disabled="inProgress" /></el-form-item>
|
|
|
|
|
<div class="form-row">
|
|
|
|
|
<el-form-item label="开始时间" prop="startTime"><el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" :disabled="inProgress" /></el-form-item>
|
|
|
|
|
<el-form-item label="结束时间" prop="endTime"><el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" /></el-form-item>
|
|
|
|
|
<el-form-item label="结束时间" prop="endTime"><el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" @change="syncDurationFromEnd" /></el-form-item>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="form-row">
|
|
|
|
|
<el-form-item label="考试时长"><el-input-number v-model="duration" :min="1" :max="1440" @change="syncEndTime" /><span class="unit">分钟</span></el-form-item>
|
|
|
|
|
@ -32,23 +24,37 @@
|
|
|
|
|
</el-form-item>
|
|
|
|
|
</div>
|
|
|
|
|
</el-form>
|
|
|
|
|
<div class="score-cards">
|
|
|
|
|
<div v-for="item in typeOptions" :key="item.value" class="score-card">
|
|
|
|
|
<div class="score-card-bar"></div>
|
|
|
|
|
<div class="score-card-body">
|
|
|
|
|
<div class="score-card-label">{{ item.label }}</div>
|
|
|
|
|
<div class="score-card-nums"><span class="num-picked">{{ selectedByType(item.value) }}</span><span class="num-sep">/</span><span class="num-total">{{ questionCount(item.value) }}</span></div>
|
|
|
|
|
<div class="score-card-sub">{{ scoreByType(item.value) }} 分</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="score-card score-card-total">
|
|
|
|
|
<div class="score-card-bar bar-total"></div>
|
|
|
|
|
<div class="score-card-body">
|
|
|
|
|
<div class="score-card-label">总分</div>
|
|
|
|
|
<div class="score-card-nums"><span class="num-picked">{{ totalScore }}</span></div>
|
|
|
|
|
<div class="score-card-sub">自动合计</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="score-table-wrap">
|
|
|
|
|
<table class="score-table">
|
|
|
|
|
<thead><tr><th>题型</th><th>题库题目数量</th><th>选题数量</th><th>每题分值</th><th>小计</th></tr></thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr v-for="item in typeOptions" :key="item.value">
|
|
|
|
|
<td>{{ item.label }}</td>
|
|
|
|
|
<td>{{ questionCount(item.value) }}</td>
|
|
|
|
|
<td>{{ selectedByType(item.value) }}</td>
|
|
|
|
|
<td>
|
|
|
|
|
<el-input-number
|
|
|
|
|
class="type-score-input"
|
|
|
|
|
:model-value="scorePerQuestion(item.value)"
|
|
|
|
|
:min="0.5"
|
|
|
|
|
:precision="1"
|
|
|
|
|
:step="0.5"
|
|
|
|
|
controls-position="right"
|
|
|
|
|
size="small"
|
|
|
|
|
@change="setTypeScore(item.value, $event)"
|
|
|
|
|
/>
|
|
|
|
|
</td>
|
|
|
|
|
<td>{{ scoreByType(item.value) }}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="!inProgress" class="fixed-step-actions" aria-label="考试编辑步骤">
|
|
|
|
|
<button type="button" class="fixed-step-button" :class="{ 'is-active': activeStep === 1 }" @click="activeStep = 1">添加试题</button>
|
|
|
|
|
<span class="fixed-step-arrow">>>></span>
|
|
|
|
|
<button type="button" class="fixed-step-button" :class="{ 'is-active': activeStep === 2 }" @click="activeStep = 2">试卷概览</button>
|
|
|
|
|
<span class="fixed-step-arrow">>>></span>
|
|
|
|
|
<button type="button" class="fixed-step-button" :class="{ 'is-active': activeStep === 0 }" @click="activeStep = 0">考试设置</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
@ -150,7 +156,7 @@
|
|
|
|
|
|
|
|
|
|
<section v-if="!inProgress && activeStep === 2" class="editor-panel preview-stage">
|
|
|
|
|
<div class="section-card preview-summary-card">
|
|
|
|
|
<header class="section-head"><div><h2>试卷概览</h2><p>{{ form.questions.length }} 道题,共 {{ totalScore }} 分</p></div><el-button :icon="Download" @click="exportPaper" :disabled="isNew">导出 Word 试卷</el-button></header>
|
|
|
|
|
<header class="section-head"><div><h2>试卷概览</h2><p>{{ form.questions.length }} 道题,共 {{ totalScore }} 分</p></div><div class="preview-actions"><el-upload :show-file-list="false" accept=".docx" :http-request="importPaperQuestions"><el-button :icon="Upload">导入 Word 试卷</el-button></el-upload><el-button :icon="Download" @click="exportPaper" :disabled="isNew">导出 Word 试卷</el-button></div></header>
|
|
|
|
|
<div class="score-cards preview-score-cards">
|
|
|
|
|
<div v-for="item in typeOptions" :key="item.value" class="score-card" :class="{ 'score-card-zero': !selectedByType(item.value) }">
|
|
|
|
|
<div class="score-card-bar"></div>
|
|
|
|
|
@ -190,6 +196,7 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="pq-score">{{ selectedScore(question.id) }} 分</span>
|
|
|
|
|
<el-button class="pq-remove" text type="danger" @click.stop="removeQuestionFromPreview(question)">取消选择</el-button>
|
|
|
|
|
</article>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
@ -214,7 +221,8 @@ import { saveAs } from "file-saver";
|
|
|
|
|
import * as examApi from "@/api/exam";
|
|
|
|
|
|
|
|
|
|
const route = useRoute(); const router = useRouter(); const id = computed(() => route.params.id); const isNew = computed(() => id.value === "new");
|
|
|
|
|
const loading = ref(false); const activeStep = ref(1); const activeType = ref("SINGLE"); const status = ref("DRAFT"); const classes = ref([]); const bank = ref([]); const formRef = ref(); const duration = ref(60);
|
|
|
|
|
const loading = ref(false); const activeStep = ref(0); const activeType = ref("SINGLE"); const status = ref("DRAFT"); const classes = ref([]); const bank = ref([]); const formRef = ref(); const duration = ref(60);
|
|
|
|
|
const typeScores = reactive({ SINGLE: 1, MULTIPLE: 1, TRUE_FALSE: 1, SHORT_ANSWER: 1 });
|
|
|
|
|
const typeOptions = [{ label: "单选题", value: "SINGLE" }, { label: "多选题", value: "MULTIPLE" }, { label: "判断题", value: "TRUE_FALSE" }, { label: "简答题", value: "SHORT_ANSWER" }];
|
|
|
|
|
const statusMeta = { DRAFT: { label: "未发布", tone: "info" }, NOT_STARTED: { label: "未开始", tone: "" }, IN_PROGRESS: { label: "进行中", tone: "success" }, ENDED: { label: "已结束", tone: "warning" } };
|
|
|
|
|
const form = reactive({ name: "", startTime: "", endTime: "", classIds: [], questionOrderMode: "FIXED", optionOrderMode: "FIXED", allowCopyQuestion: false, allowPasteAnswer: false, allowRollback: true, multipleScoreMode: "ALL_OR_NOTHING", questions: [] });
|
|
|
|
|
@ -225,19 +233,23 @@ const totalScore = computed(() => form.questions.reduce((sum, item) => sum + Num
|
|
|
|
|
const selectedTypes = computed(() => typeOptions.filter((item) => selectedByType(item.value)));
|
|
|
|
|
|
|
|
|
|
onMounted(load);
|
|
|
|
|
async function load() { loading.value = true; try { const [classRes, questionRes] = await Promise.all([examApi.getExamClasses(), examApi.getQuestions({})]); classes.value = classRes.data || []; bank.value = questionRes.data || []; if (!isNew.value) { const { data } = await examApi.getTeacherExam(id.value); status.value = data.status; const bankIds = new Set(bank.value.map((item) => item.id)); bank.value = [...bank.value, ...data.questions.filter((item) => !bankIds.has(item.id))]; Object.assign(form, { name: data.name, startTime: formatValue(data.startTime), endTime: formatValue(data.endTime), classIds: data.classes.map((item) => item.id), questionOrderMode: data.questionOrderMode || "FIXED", optionOrderMode: data.optionOrderMode || "FIXED", allowCopyQuestion: toBoolean(data.allowCopyQuestion, false), allowPasteAnswer: toBoolean(data.allowPasteAnswer, false), allowRollback: toBoolean(data.allowRollback, true), multipleScoreMode: data.multipleScoreMode || "ALL_OR_NOTHING", questions: data.questions.map((item) => ({ questionId: item.id, score: Number(item.score) })) }); duration.value = data.durationMinutes; } else { const start = new Date(Date.now() + 3600000); form.startTime = formatValue(start); form.endTime = formatValue(new Date(start.getTime() + 3600000)); } } finally { loading.value = false; } }
|
|
|
|
|
async function load() { loading.value = true; try { const [classRes, questionRes] = await Promise.all([examApi.getExamClasses(), examApi.getQuestions({})]); classes.value = classRes.data || []; bank.value = questionRes.data || []; if (!isNew.value) { const { data } = await examApi.getTeacherExam(id.value); status.value = data.status; const bankIds = new Set(bank.value.map((item) => item.id)); bank.value = [...bank.value, ...data.questions.filter((item) => !bankIds.has(item.id))]; Object.assign(form, { name: data.name, startTime: formatValue(data.startTime), endTime: formatValue(data.endTime), classIds: data.classes.map((item) => item.id), questionOrderMode: data.questionOrderMode || "FIXED", optionOrderMode: data.optionOrderMode || "FIXED", allowCopyQuestion: toBoolean(data.allowCopyQuestion, false), allowPasteAnswer: toBoolean(data.allowPasteAnswer, false), allowRollback: toBoolean(data.allowRollback, true), multipleScoreMode: data.multipleScoreMode || "ALL_OR_NOTHING", questions: data.questions.map((item) => ({ questionId: item.id, score: Number(item.score) })) }); for (const type of typeOptions.map((item) => item.value)) { const first = data.questions.find((item) => item.type === type); if (first) typeScores[type] = Number(first.score) || 1; } duration.value = data.durationMinutes; } else { const start = new Date(Date.now() + 3600000); form.startTime = formatValue(start); form.endTime = formatValue(new Date(start.getTime() + 3600000)); } } finally { loading.value = false; } }
|
|
|
|
|
function syncEndTime() { if (!form.startTime) return; form.endTime = formatValue(new Date(new Date(form.startTime.replace(" ", "T")).getTime() + duration.value * 60000)); }
|
|
|
|
|
function syncDurationFromEnd() { if (!form.startTime || !form.endTime) return; const start = new Date(form.startTime.replace(" ", "T")); const end = new Date(form.endTime.replace(" ", "T")); if (end > start) duration.value = Math.round((end - start) / 60000); }
|
|
|
|
|
function isSelected(questionId) { return form.questions.some((item) => item.questionId === questionId); }
|
|
|
|
|
function selectedScore(questionId) { return form.questions.find((item) => item.questionId === questionId)?.score || 1; }
|
|
|
|
|
function toggleQuestion(question, checked) { if (checked) form.questions.push({ questionId: question.id, score: 1 }); else form.questions = form.questions.filter((item) => item.questionId !== question.id); }
|
|
|
|
|
function toggleQuestion(question, checked) { if (checked) form.questions.push({ questionId: question.id, score: typeScores[question.type] || 1 }); else form.questions = form.questions.filter((item) => item.questionId !== question.id); }
|
|
|
|
|
function removeQuestionFromPreview(question) { toggleQuestion(question, false); }
|
|
|
|
|
function setScore(questionId, score) { const item = form.questions.find((row) => row.questionId === questionId); if (item) item.score = Number(score); }
|
|
|
|
|
function selectedByType(type) { const ids = new Set(bank.value.filter((item) => item.type === type).map((item) => item.id)); return form.questions.filter((item) => ids.has(item.questionId)).length; }
|
|
|
|
|
function questionCount(type) { return bank.value.filter((item) => item.type === type).length; }
|
|
|
|
|
function scoreByType(type) { return selectedQuestions(type).reduce((sum, item) => sum + Number(selectedScore(item.id)), 0); }
|
|
|
|
|
function scorePerQuestion(type) { return Number(typeScores[type] || 1); }
|
|
|
|
|
function setTypeScore(type, score) { const value = Number(score); if (!Number.isFinite(value) || value < 0.5) return; typeScores[type] = value; const ids = new Set(bank.value.filter((item) => item.type === type).map((item) => item.id)); for (const item of form.questions) if (ids.has(item.questionId)) item.score = value; }
|
|
|
|
|
function selectedQuestions(type) { const selected = new Set(form.questions.map((item) => item.questionId)); return bank.value.filter((item) => item.type === type && selected.has(item.id)); }
|
|
|
|
|
async function nextStep() { if (activeStep.value === 0) { try { await formRef.value.validate(); const start = new Date(form.startTime.replace(" ", "T")); const end = new Date(form.endTime.replace(" ", "T")); if (end <= start) return ElMessage.warning("结束时间必须晚于开始时间"); duration.value = Math.round((end - start) / 60000); } catch { return; } } if (activeStep.value === 1 && !form.questions.length) return ElMessage.warning("至少选择一道试题"); activeStep.value++; }
|
|
|
|
|
async function save(publish) { if (inProgress.value) { await examApi.updateExam(id.value, form); ElMessage.success("结束时间已更新"); return router.push("/exam/index"); } try { await formRef.value?.validate(); } catch { activeStep.value = 0; return; } if (!form.questions.length) { activeStep.value = 1; return ElMessage.warning("至少选择一道试题"); } const result = isNew.value ? await examApi.createExam(form) : await examApi.updateExam(id.value, form); const examId = result.data.id; if (publish) await examApi.publishExam(examId); ElMessage.success(publish ? "考试已保存并发布" : "考试已保存"); router.push("/exam/index"); }
|
|
|
|
|
async function importPaperQuestions({ file }) { const formData = new FormData(); formData.append("file", file); const { data } = await examApi.importQuestions(formData); bank.value = [...data, ...bank.value]; for (const q of data) if (!isSelected(q.id)) form.questions.push({ questionId: q.id, score: 1 }); ElMessage.success(`导入并选中 ${data.length} 道题`); }
|
|
|
|
|
async function importPaperQuestions({ file }) { const formData = new FormData(); formData.append("file", file); const { data } = await examApi.importQuestions(formData); bank.value = [...data, ...bank.value]; for (const q of data) if (!isSelected(q.id)) form.questions.push({ questionId: q.id, score: typeScores[q.type] || 1 }); ElMessage.success(`导入并选中 ${data.length} 道题`); }
|
|
|
|
|
async function downloadTemplate() { saveAs(await examApi.downloadQuestionTemplate(), "考试题库导入模板.docx"); }
|
|
|
|
|
async function exportPaper() { saveAs(await examApi.exportExamPaper(id.value), `${form.name}试卷.docx`); }
|
|
|
|
|
function scrollToType(type) { document.getElementById(`paper-${type}`)?.scrollIntoView({ behavior: "smooth", block: "start" }); }
|
|
|
|
|
@ -346,6 +358,85 @@ function toBoolean(value, fallback) { if (value === undefined || value === null
|
|
|
|
|
:deep(.el-input-number .el-input__wrapper) { padding-right: 58px; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 原型中的固定信息区:基础字段、题型汇总和步骤入口始终保持在页面顶部。 */
|
|
|
|
|
.score-table-wrap {
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
margin-top: var(--sp-5);
|
|
|
|
|
border: 1px solid var(--border-color);
|
|
|
|
|
border-radius: var(--r-control);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.score-table {
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-width: 720px;
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
table-layout: fixed;
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
|
|
|
|
|
th,
|
|
|
|
|
td {
|
|
|
|
|
height: 42px;
|
|
|
|
|
padding: 0 var(--sp-3);
|
|
|
|
|
border-right: 1px solid var(--border-color);
|
|
|
|
|
border-bottom: 1px solid var(--border-color);
|
|
|
|
|
color: var(--text-body);
|
|
|
|
|
font-size: var(--fs-body);
|
|
|
|
|
text-align: center;
|
|
|
|
|
vertical-align: middle;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
th {
|
|
|
|
|
color: var(--text-strong);
|
|
|
|
|
background: var(--edu-primary-soft);
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
th:last-child,
|
|
|
|
|
td:last-child { border-right: 0; }
|
|
|
|
|
tbody tr:last-child td { border-bottom: 0; }
|
|
|
|
|
tbody tr:hover td { background: #fafcff; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.type-score-input { width: 112px; }
|
|
|
|
|
|
|
|
|
|
.fixed-step-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
gap: var(--sp-4);
|
|
|
|
|
margin-top: var(--sp-5);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.fixed-step-button {
|
|
|
|
|
min-width: 164px;
|
|
|
|
|
height: 40px;
|
|
|
|
|
padding: 0 var(--sp-5);
|
|
|
|
|
border: 0;
|
|
|
|
|
border-radius: var(--r-control);
|
|
|
|
|
background: var(--edu-primary);
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
font-size: var(--fs-body);
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: background 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
|
|
|
|
|
|
|
|
|
|
&:hover,
|
|
|
|
|
&.is-active {
|
|
|
|
|
background: var(--edu-primary-deep);
|
|
|
|
|
box-shadow: 0 4px 10px rgba(25, 137, 204, 0.18);
|
|
|
|
|
transform: translateY(-1px);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&:focus-visible { outline: 2px solid var(--edu-primary); outline-offset: 2px; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.fixed-step-arrow {
|
|
|
|
|
color: var(--text-strong);
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
letter-spacing: -3px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.form-row {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
@ -441,6 +532,12 @@ function toBoolean(value, fallback) { if (value === undefined || value === null
|
|
|
|
|
p { margin: var(--sp-1) 0 0; color: var(--text-body); font-size: var(--fs-body); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.preview-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.import-tools { display: grid; justify-items: end; gap: var(--sp-2); }
|
|
|
|
|
|
|
|
|
|
/* 下载模板/导入试题按钮外观归 teacher-light.scss 全局接管(ghost) */
|
|
|
|
|
@ -794,6 +891,11 @@ function toBoolean(value, fallback) { if (value === undefined || value === null
|
|
|
|
|
margin-top: var(--sp-1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.pq-remove {
|
|
|
|
|
flex: none;
|
|
|
|
|
margin-left: var(--sp-2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ===== 自定义空状态 ===== */
|
|
|
|
|
.table-empty {
|
|
|
|
|
padding: var(--sp-5) 0;
|
|
|
|
|
@ -961,6 +1063,9 @@ function toBoolean(value, fallback) { if (value === undefined || value === null
|
|
|
|
|
.base-form { :deep(.el-form-item) { margin-bottom: var(--sp-3); } }
|
|
|
|
|
.form-row { grid-template-columns: 1fr; gap: var(--sp-3); }
|
|
|
|
|
.score-cards { grid-template-columns: repeat(2, 1fr); }
|
|
|
|
|
.fixed-step-actions { flex-wrap: wrap; gap: var(--sp-2); }
|
|
|
|
|
.fixed-step-arrow { display: none; }
|
|
|
|
|
.fixed-step-button { min-width: 138px; }
|
|
|
|
|
.settings-grid { grid-template-columns: 1fr; }
|
|
|
|
|
.type-tabs { flex-wrap: wrap; }
|
|
|
|
|
.pq-card { grid-template-columns: 28px 1fr; }
|
|
|
|
|
|