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

1417 lines
50 KiB
Vue

<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="10080"
@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, onActivated, 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)))
let editorMounted = false
onMounted(load)
onActivated(() => { if (editorMounted) load(); editorMounted = true })
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 } const startMs = Number(data.startTime); const endMs = Number(data.endTime); if (endMs > startMs) duration.value = Math.round((endMs - startMs) / 60000) } 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>