You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/teacherEnd/exam/editor.vue

1055 lines
41 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<main class="exam-editor" v-loading="loading">
<header class="editor-head">
<nav class="head-breadcrumb"><span @click="$router.push('/exam/index')">综合考核</span><span class="bc-sep">/</span><span @click="$router.push('/exam/index')">考评管理</span><span class="bc-sep">/</span><span class="bc-current">{{ isNew ? '新建考试' : (form.name || '编辑考试') }}</span></nav>
<div class="head-title-row">
<h1>{{ isNew ? '新建考试' : (form.name || '编辑考试') }}</h1>
<el-tag v-if="!isNew" :type="statusMeta[status]?.tone" effect="light">{{ statusMeta[status]?.label }}</el-tag>
</div>
</header>
<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" @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>
<el-form-item label="考试班级" prop="classIds">
<el-select v-model="form.classIds" multiple collapse-tags placeholder="选择教师名下的教学班" :disabled="inProgress"><el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" /></el-select>
</el-form-item>
</div>
</el-form>
<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 v-model="typeScores[item.value]" :min="0" :max="100" size="small" controls-position="right" :disabled="inProgress" @change="onTypeScoreChange(item.value)" /></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"><el-icon><ArrowRight /></el-icon></span>
<button type="button" class="fixed-step-button" :class="{ 'is-active': activeStep === 2 }" @click="activeStep = 2">试卷概览</button>
<span class="fixed-step-arrow"><el-icon><ArrowRight /></el-icon></span>
<button type="button" class="fixed-step-button" :class="{ 'is-active': activeStep === 0 }" @click="activeStep = 0">考试设置</button>
</div>
</div>
</section>
<section v-if="!inProgress && activeStep === 0" class="editor-panel settings-stage">
<div class="section-card">
<header class="card-head"><div><h2>考试设置</h2><p>设置试题展示、作答行为和多选题计分方式</p></div></header>
<div class="settings-grid">
<div class="setting-card">
<div class="setting-card-body">
<span class="setting-card-label">题目乱序</span>
<span class="setting-card-desc">同一题型内打乱题目顺序</span>
</div>
<el-radio-group v-model="form.questionOrderMode"><el-radio-button label="FIXED">固定顺序</el-radio-button><el-radio-button label="RANDOM">随机打乱</el-radio-button></el-radio-group>
</div>
<div class="setting-card">
<div class="setting-card-body">
<span class="setting-card-label">选项乱序</span>
<span class="setting-card-desc">同一题型内打乱选项顺序</span>
</div>
<el-radio-group v-model="form.optionOrderMode"><el-radio-button label="FIXED">固定顺序</el-radio-button><el-radio-button label="RANDOM">随机打乱</el-radio-button></el-radio-group>
</div>
<div class="setting-card">
<div class="setting-card-body">
<span class="setting-card-label">允许复制试题</span>
<span class="setting-card-desc">考试过程中考生可复制题干</span>
</div>
<el-radio-group v-model="form.allowCopyQuestion"><el-radio-button :label="false">不允许</el-radio-button><el-radio-button :label="true">允许</el-radio-button></el-radio-group>
</div>
<div class="setting-card">
<div class="setting-card-body">
<span class="setting-card-label">允许粘贴答案</span>
<span class="setting-card-desc">考试过程中考生可粘贴内容</span>
</div>
<el-radio-group v-model="form.allowPasteAnswer"><el-radio-button :label="false">不允许</el-radio-button><el-radio-button :label="true">允许</el-radio-button></el-radio-group>
</div>
<div class="setting-card">
<div class="setting-card-body">
<span class="setting-card-label">考试过程可以回退</span>
<span class="setting-card-desc">答完一题后可返回修改</span>
</div>
<el-radio-group v-model="form.allowRollback"><el-radio-button :label="false">不允许</el-radio-button><el-radio-button :label="true">允许</el-radio-button></el-radio-group>
</div>
<div class="setting-card setting-card-wide">
<div class="setting-card-body">
<span class="setting-card-label">多选题计分方式</span>
<span class="setting-card-desc">少选、多选、错选的得分规则</span>
</div>
<el-radio-group v-model="form.multipleScoreMode"><el-radio-button label="ALL_OR_NOTHING">全对才得分</el-radio-button><el-radio-button label="PARTIAL_BY_COUNT">按选项个数计分</el-radio-button><el-radio-button label="HALF_FOR_PARTIAL">少选得一半分</el-radio-button></el-radio-group>
</div>
</div>
</div>
</section>
<section v-if="!inProgress && activeStep === 1" class="editor-panel question-stage">
<div class="section-card">
<header class="section-head"><div><h2>选择试题</h2><p>勾选题目并设置该题分值,选择结果实时同步到试卷概览</p></div>
<div class="import-tools">
<div class="import-actions"><el-button class="template-button" :icon="Download" @click="downloadTemplate">下载 Word 模板</el-button><el-upload class="word-upload" :show-file-list="false" accept=".docx" :http-request="importPaperQuestions"><el-button :icon="Upload">导入 Word 试题</el-button></el-upload></div>
<span class="import-hint">按模板填写后上传,系统会解析题干、选项、答案和解析,并自动选中题目</span>
</div>
</header>
<div class="type-tabs">
<button v-for="item in typeOptions" :key="item.value" :class="['type-tab', { active: activeType === item.value }]" @click="activeType=item.value">
<span class="type-tab-label">{{ item.label }}</span>
<span class="type-tab-count"><b>{{ selectedByType(item.value) }}</b>/<em>{{ questionCount(item.value) }}</em></span>
</button>
</div>
<div class="question-list">
<article v-for="(question, idx) in filteredQuestions" :key="question.id" :class="['q-card', { 'is-picked': isSelected(question.id) }]" @click="toggleQuestion(question, !isSelected(question.id))">
<div class="q-card-stripe"></div>
<div class="q-card-body">
<div class="q-card-head">
<span class="q-card-no">{{ idx + 1 }}</span>
<span class="q-card-type">{{ typeOptions.find(t => t.value === question.type)?.label || question.type }}</span>
</div>
<h3 class="q-card-stem">{{ question.stem }}</h3>
<div v-if="question.options?.length" class="q-options">
<div v-for="option in question.options" :key="option.label" class="q-option"><span class="q-option-label">{{ option.label }}</span><span>{{ option.content }}</span></div>
</div>
</div>
<div class="q-card-check">
<div v-if="isSelected(question.id)" class="q-check-on"><el-icon><Check /></el-icon></div>
<div v-else class="q-check-off">+</div>
</div>
</article>
<div v-if="!filteredQuestions.length" class="table-empty">
<el-icon class="empty-icon"><FolderOpened /></el-icon>
<p class="empty-title">该题型暂无题目</p>
<p class="empty-tip">可先到"题库管理"新增题目</p>
</div>
</div>
</div>
</section>
<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><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>
<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></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><span class="num-sep">分</span></div>
<div class="score-card-sub">{{ form.questions.length }} 道题</div>
</div>
</div>
</div>
</div>
<div v-if="form.questions.length" class="preview-body">
<nav class="type-tabs">
<button v-for="item in selectedTypes" :key="item.value" class="type-tab type-tab-nav" @click="scrollToType(item.value)">{{ item.label }}<span class="type-tab-count"><b>{{ selectedByType(item.value) }}</b></span></button>
</nav>
<section v-for="item in selectedTypes" :id="`paper-${item.value}`" :key="item.value" class="paper-section preview-paper-section">
<header class="paper-section-head">
<div class="paper-section-stripe"></div>
<h3>{{ item.label }}</h3>
<span class="paper-section-meta">{{ selectedByType(item.value) }} 题 · {{ scoreByType(item.value) }} 分</span>
</header>
<div class="paper-questions">
<article v-for="(question, index) in selectedQuestions(item.value)" :key="question.id" class="pq-card">
<span class="pq-no">{{ index + 1 }}</span>
<div class="pq-body">
<h4 class="pq-stem">{{ question.stem }}</h4>
<div v-if="question.options?.length" class="pq-options">
<span v-for="option in question.options" :key="option.label" class="pq-option"><b>{{ option.label }}</b> {{ option.content }}</span>
</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>
</div>
<div v-if="!form.questions.length" class="table-empty">
<el-icon class="empty-icon"><FolderOpened /></el-icon>
<p class="empty-title">尚未选择试题</p>
<p class="empty-tip">请返回上一步勾选题目</p>
</div>
</section>
<footer class="editor-footer"><el-button :icon="Close" @click="$router.push('/exam/index')">取消</el-button><el-button :icon="Check" @click="save(false)">保存考试</el-button><el-button v-if="status === 'DRAFT'" type="primary" :icon="Promotion" @click="save(true)">立即发布</el-button></footer>
</main>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { ArrowLeft, ArrowRight, Check, Close, Download, FolderOpened, Promotion, Upload } from "@element-plus/icons-vue";
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(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: [] });
const rules = { name: [{ required: true, message: "请输入考试名称" }, { pattern: /^[\p{L}\p{N}]+$/u, message: "仅支持汉字、字母和数字" }], startTime: [{ required: true, message: "请选择开始时间" }], endTime: [{ required: true, message: "请选择结束时间" }], classIds: [{ type: "array", required: true, min: 1, message: "至少选择一个班级" }] };
const inProgress = computed(() => status.value === "IN_PROGRESS");
const filteredQuestions = computed(() => bank.value.filter((item) => item.type === activeType.value));
const totalScore = computed(() => form.questions.reduce((sum, item) => sum + Number(item.score || 0), 0));
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({ scope: "school" })]); 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: typeScores[question.type] || 1 }); else form.questions = form.questions.filter((item) => item.questionId !== question.id); }
function removeQuestionFromPreview(question) { toggleQuestion(question, false); }
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 onTypeScoreChange(type) { const score = Number(typeScores[type] || 0); for (const item of form.questions) { const bankItem = bank.value.find((q) => q.id === item.questionId); if (bankItem && bankItem.type === type) item.score = score; } }
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: 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" }); }
function formatValue(value) { const date = value instanceof Date ? value : new Date(value); const pad = (n) => String(n).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; }
function toBoolean(value, fallback) { if (value === undefined || value === null || value === "") return fallback; return value === true || value === 1 || value === "1" || value === "true"; }
</script>
<style scoped lang="scss">
.exam-editor {
color: var(--text-strong);
padding-bottom: 92px;
/* ===== 标题行(左组:返回+标题;右侧:操作按钮;扁平落地画布)===== */
.editor-head {
display: grid;
gap: var(--sp-2);
margin-bottom: var(--sp-4);
padding-bottom: var(--sp-3);
border-bottom: 1px solid var(--border-color);
}
.head-breadcrumb {
display: flex;
align-items: center;
gap: var(--sp-1);
font-size: var(--fs-tag);
color: var(--text-muted);
span { cursor: pointer; transition: color 0.14s ease; }
span:hover { color: var(--edu-primary); }
.bc-sep { cursor: default; color: var(--text-muted); opacity: .5; }
.bc-sep:hover { color: var(--text-muted); }
.bc-current { cursor: default; color: var(--text-body); font-weight: 500; }
.bc-current:hover { color: var(--text-body); }
}
.head-title-row {
display: flex;
align-items: center;
gap: var(--sp-3);
h1 {
margin: 0;
color: var(--text-strong);
font-size: var(--fs-page);
font-weight: 700;
letter-spacing: -0.01em;
}
}
/* ===== 步骤条 ===== */
.steps {
max-width: 820px;
margin: var(--sp-5) auto;
:deep(.el-step__title) { color: var(--text-body); font-size: var(--fs-body); font-weight: 600; }
:deep(.el-step__title.is-process) { color: var(--text-strong); }
:deep(.el-step__title.is-finish) { color: var(--text-body); }
:deep(.el-step__head.is-process) { color: var(--edu-primary); border-color: var(--edu-primary); }
:deep(.el-step__head.is-finish) { color: var(--edu-primary); border-color: var(--edu-primary); }
:deep(.el-step__head.is-wait) { color: var(--text-muted); border-color: var(--border-color); }
:deep(.el-step__icon) { background: #ffffff; border-color: var(--border-color); }
:deep(.el-step__icon-inner) { font-weight: 600; }
:deep(.el-step__line) { background-color: var(--border-color); }
:deep(.el-step__line-inner) { border-color: var(--edu-primary); }
}
/* ===== 面板(左对齐文档流,不居中悬浮)===== */
.editor-panel {
width: 100%;
max-width: 1280px;
margin: 0;
box-sizing: border-box;
h2 {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
color: var(--text-strong);
font-size: var(--fs-page);
font-weight: 700;
&::before { width: 4px; height: 20px; border-radius: 4px; background: var(--edu-primary); content: ''; }
}
}
/* ===== 分组白卡(调研结论:逻辑分组 + 呼吸感)===== */
.section-card {
padding: var(--sp-5);
border: 1px solid var(--border-color);
border-radius: var(--r-card);
background: #ffffff;
}
.card-head {
margin-bottom: var(--sp-4);
p { margin: var(--sp-1) 0 0; color: var(--text-body); font-size: var(--fs-body); }
}
/* ===== 基础设置表单(单列 + 成对字段组,呼吸感间距)===== */
.base-form {
:deep(.el-form-item) { margin-bottom: var(--sp-4); }
:deep(.el-form-item:first-child) { margin-bottom: var(--sp-3); }
:deep(.el-form-item__label) { color: var(--text-body); font-weight: 600; }
:deep(.el-input__count-inner) { color: var(--text-muted); background: transparent; }
:deep(.el-input), :deep(.el-select), :deep(.el-date-editor) { width: 100%; }
:deep(.el-input-number) { width: 180px; }
: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: 860px;
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; }
:deep(.el-input-number) { width: 100px; }
:deep(.el-input-number .el-input__wrapper) { padding: 0 8px; }
}
.fixed-step-actions {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-3);
margin-top: var(--sp-5);
}
.fixed-step-button {
min-width: 148px;
height: 38px;
padding: 0 var(--sp-4);
border: 1px solid var(--border-color);
border-radius: var(--r-control);
background: #ffffff;
color: var(--text-body);
font: inherit;
font-size: var(--fs-body);
font-weight: 500;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
&:hover {
border-color: var(--edu-primary);
color: var(--edu-primary);
}
&.is-active {
border-color: var(--edu-primary);
background: var(--edu-primary);
color: #ffffff;
box-shadow: 0 4px 10px rgba(25, 137, 204, 0.18);
&:hover { color: #ffffff; }
}
&:focus-visible { outline: 2px solid var(--edu-primary); outline-offset: 2px; }
}
.fixed-step-arrow {
display: inline-flex;
align-items: center;
color: var(--text-muted);
font-size: 16px;
.el-icon { font-size: 16px; }
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-4) var(--sp-5);
}
.unit { margin-left: var(--sp-2); color: var(--text-body); }
/* ===== 分值卡片组(替代旧表格,彩色顶部条)===== */
.score-cards {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--sp-3);
margin-top: var(--sp-5);
padding-top: var(--sp-4);
border-top: 1px solid var(--border-color);
}
.score-card {
position: relative;
border: 1px solid var(--border-color);
border-radius: var(--r-control);
background: #ffffff;
overflow: hidden;
transition: border-color 0.16s ease;
&:hover { border-color: var(--edu-primary); }
}
.score-card-bar {
height: 3px;
background: var(--border-color);
}
.score-card-bar,
.bar-total { background: var(--edu-primary); }
.score-card-body {
padding: var(--sp-3);
display: grid;
gap: var(--sp-1);
}
.score-card-label {
font-size: var(--fs-tag);
color: var(--text-muted);
font-weight: 500;
}
.score-card-nums {
display: flex;
align-items: baseline;
gap: var(--sp-1);
}
.num-picked {
font-size: var(--fs-num);
font-weight: 700;
color: var(--text-strong);
line-height: 1;
}
.num-sep {
color: var(--text-muted);
font-size: var(--fs-card);
}
.num-total {
font-size: var(--fs-card);
color: var(--text-body);
}
.score-card-sub {
font-size: var(--fs-tag);
color: var(--text-body);
}
.score-card-total {
background: var(--edu-primary-soft);
.score-card-label { color: var(--edu-primary-deep); }
.num-picked { color: var(--edu-primary); }
.score-card-sub { color: var(--edu-primary-deep); }
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-4);
margin-bottom: var(--sp-4);
h2 { margin: 0; }
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) */
.import-actions { display: flex; gap: var(--sp-2); }
.import-hint { max-width: 420px; color: var(--text-muted); font-size: var(--fs-tag); line-height: 1.45; text-align: right; }
/* ===== 题型 Tab 栏(步骤02选择题型 + 步骤03试卷快速跳转共用)===== */
.type-tabs {
display: flex;
gap: var(--sp-2);
margin-bottom: var(--sp-4);
padding-bottom: var(--sp-3);
border-bottom: 1px solid var(--border-color);
overflow-x: auto;
}
.type-tab {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: 9px var(--sp-4);
border: 1px solid var(--border-color);
border-radius: var(--r-control);
background: #ffffff;
cursor: pointer;
font: inherit;
font-size: var(--fs-body);
font-weight: 500;
color: var(--text-body);
white-space: nowrap;
transition: all 0.16s ease;
height: 36px;
&:hover { border-color: var(--edu-primary); color: var(--edu-primary); }
&.active {
background: var(--edu-primary);
border-color: var(--edu-primary);
color: #ffffff;
.type-tab-count { background: rgba(255,255,255,0.2); }
.type-tab-count b { color: #ffffff; }
.type-tab-count em { color: rgba(255,255,255,0.7); }
}
}
.type-tab-label { font-weight: 500; }
.type-tab-count {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
padding: 2px var(--sp-2);
border-radius: var(--r-control);
background: var(--border-color);
font-size: var(--fs-tag);
b { font-weight: 600; color: var(--edu-primary); font-style: normal; }
em { color: var(--text-muted); font-style: normal; }
}
.type-tab-nav.type-tab {
border-color: var(--border-color);
background: #ffffff;
color: var(--text-body);
}
.question-list {
display: grid;
gap: var(--sp-3);
min-width: 0;
}
/* 题目卡片 */
.q-card {
position: relative;
display: flex;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--border-color);
border-radius: var(--r-control);
background: #ffffff;
cursor: pointer;
transition: all 0.18s ease;
overflow: hidden;
&:hover { border-color: var(--edu-primary); }
&.is-picked {
border-color: var(--edu-primary);
background: var(--edu-primary-soft);
}
}
.q-card-stripe {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--border-color);
transition: background 0.18s ease;
}
.q-card.is-picked .q-card-stripe {
background: var(--edu-primary);
}
.q-card-body { flex: 1; min-width: 0; padding-left: var(--sp-2); }
.q-card-head {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
}
.q-card-no {
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 6px;
background: var(--border-color);
color: var(--text-muted);
font-size: var(--fs-tag);
font-weight: 600;
line-height: 1;
}
.q-card.is-picked .q-card-no {
background: var(--edu-primary);
color: #ffffff;
}
.q-card-type {
font-size: var(--fs-body);
color: var(--text-muted);
font-weight: 500;
}
.q-card-stem {
margin: 0 0 var(--sp-2);
color: var(--text-strong);
font-size: var(--fs-body);
line-height: 1.6;
font-weight: 500;
}
.q-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-1) var(--sp-3);
}
.q-option {
display: flex;
gap: 8px;
font-size: var(--fs-tag);
color: var(--text-body);
line-height: 1.5;
}
.q-option-label {
font-weight: 600;
color: var(--text-muted);
flex-shrink: 0;
}
.q-card-check {
flex-shrink: 0;
align-self: flex-start;
margin-top: 2px;
}
.q-check-off {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid var(--text-muted);
display: grid;
place-items: center;
color: var(--text-muted);
font-size: var(--fs-card);
font-weight: 400;
transition: all 0.18s ease;
}
.q-card:hover .q-check-off {
border-color: var(--edu-primary);
color: var(--edu-primary);
}
.q-check-on {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--edu-primary);
display: grid;
place-items: center;
color: #ffffff;
transition: all 0.18s ease;
.el-icon { font-size: var(--fs-card); }
}
/* ===== 试卷概览(复用 score-cards + 新预览结构)===== */
.preview-stage { max-width: 1280px; }
.preview-score-cards { margin-top: var(--sp-4); }
.score-card-zero {
opacity: 0.5;
.num-picked { color: var(--text-muted); }
.score-card-sub { color: var(--text-muted); }
}
.preview-body { margin-top: var(--sp-5); }
.paper-section {
scroll-margin-top: 80px;
margin-bottom: var(--sp-5);
border: 1px solid var(--border-color);
border-radius: var(--r-control);
background: #ffffff;
overflow: hidden;
}
.paper-section-head {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
background: #ffffff;
border-bottom: 1px solid var(--border-color);
position: relative;
}
.paper-section-stripe {
width: 4px;
height: 20px;
border-radius: 2px;
background: var(--border-color);
}
.paper-section-head h3 {
margin: 0;
font-size: var(--fs-card);
font-weight: 600;
color: var(--text-strong);
}
.paper-section-meta {
margin-left: auto;
font-size: var(--fs-tag);
color: var(--text-muted);
font-weight: 500;
}
.paper-questions {
display: grid;
gap: var(--sp-1);
}
.pq-card {
display: grid;
grid-template-columns: 36px 1fr auto;
gap: var(--sp-3);
align-items: start;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-color);
transition: background 0.14s ease;
&:last-child { border-bottom: 0; }
&:hover { background: var(--edu-primary-soft); }
}
.pq-no {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: var(--r-control);
background: var(--border-color);
color: var(--text-muted);
font-size: var(--fs-card);
font-weight: 600;
flex-shrink: 0;
}
.pq-body { min-width: 0; }
.pq-stem {
margin: 0 0 var(--sp-2);
color: var(--text-strong);
font-size: var(--fs-body);
line-height: 1.65;
font-weight: 500;
}
.pq-options {
display: grid;
gap: var(--sp-1);
}
.pq-option {
font-size: var(--fs-tag);
color: var(--text-body);
line-height: 1.55;
b { color: var(--text-muted); font-weight: 600; margin-right: var(--sp-1); }
}
.pq-score {
display: inline-flex;
align-items: center;
padding: 2px var(--sp-3);
border-radius: var(--r-control);
background: var(--edu-primary-soft);
color: var(--edu-primary-deep);
font-size: var(--fs-tag);
font-weight: 600;
flex-shrink: 0;
margin-top: var(--sp-1);
}
.pq-remove {
flex: none;
margin-left: var(--sp-2);
}
/* ===== 自定义空状态 ===== */
.table-empty {
padding: var(--sp-5) 0;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-1);
.empty-icon {
font-size: 28px;
color: var(--text-muted);
}
.empty-title {
margin: var(--sp-1) 0 0;
font-size: var(--fs-body);
font-weight: 600;
color: var(--text-body);
}
.empty-tip {
margin: 0;
font-size: var(--fs-tag);
color: var(--text-muted);
}
}
/* ===== 底部固定操作条(白底 + 灰上边;按钮外观归 teacher-light.scss 全局接管)===== */
.editor-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 76px;
display: flex;
justify-content: center;
align-items: center;
gap: var(--sp-3);
background: #ffffff;
border-top: 1px solid var(--border-color);
box-shadow: 0 -2px 12px rgba(17, 24, 39, 0.06);
z-index: 10;
:deep(.el-button) { min-width: 112px; }
}
/* ===== 编辑器步骤条(极简文字式:数字徽标 + 文字 + 箭头,左对齐跟随内容流)===== */
.prototype-nav {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--sp-4);
margin-bottom: var(--sp-4);
.step-node {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: 6px 2px;
border: 0;
background: transparent;
color: var(--text-body);
font-size: var(--fs-body);
font-weight: 400;
cursor: pointer;
transition: color 0.16s ease;
.step-num {
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--border-color);
color: var(--text-body);
font-size: var(--fs-tag);
font-weight: 600;
transition: all 0.16s ease;
}
&:hover {
color: var(--text-strong);
.step-num {
background: var(--edu-primary-soft);
color: var(--edu-primary);
}
}
&.is-active {
color: var(--edu-primary);
font-weight: 600;
.step-num {
background: var(--edu-primary);
color: #ffffff;
}
}
}
.step-arrow {
color: var(--text-muted);
font-size: var(--fs-card);
}
}
.prototype-base-panel {
max-width: 1280px;
padding: 0 0 var(--sp-4);
}
.settings-stage { max-width: 1280px; }
/* ===== 考试设置:独立设置卡片(替代旧 setting-row 列表)===== */
.settings-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--sp-3);
}
.setting-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-4);
padding: var(--sp-4) var(--sp-4);
border: 1px solid var(--border-color);
border-radius: var(--r-control);
background: #f9fafb;
transition: all 0.16s ease;
&:hover {
border-color: var(--edu-primary);
background: #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
}
.setting-card-wide {
grid-column: 1 / -1;
}
.setting-card-body {
display: grid;
gap: 4px;
min-width: 0;
}
.setting-card-label {
font-size: var(--fs-body);
font-weight: 600;
color: var(--text-strong);
}
.setting-card-desc {
font-size: var(--fs-tag);
color: var(--text-muted);
}
@media (max-width: 760px) {
.editor-head {
display: grid;
gap: var(--sp-2);
}
.section-card { padding: var(--sp-4); }
.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; }
.pq-score { grid-column: 1 / -1; justify-self: start; }
.section-head { align-items: flex-start; flex-direction: column; gap: var(--sp-3); }
.import-tools { justify-items: start; width: 100%; }
.import-hint { max-width: none; text-align: left; }
}
}
</style>
<style lang="scss">
#app .teacherLayout .editor-footer { left: calc(var(--teacher-sidebar-width, 180px) + 1px); }
#app .teacherLayout.hideSidebar .editor-footer { left: 64px; }
#app .mobile.teacherLayout .editor-footer { left: 0; }
</style>