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/platformAdmin/questionBank/index.vue

59 lines
16 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="question-bank app-container">
<header class="page-head"><div><h1>题库管理</h1><p>维护平台公共题库,供教师组卷使用</p></div><div class="actions"><el-button type="primary" :icon="Plus" @click="openEditor()">添加题目</el-button><el-button type="success" :icon="Upload" :loading="loading" @click="pickImport">批量导入</el-button><el-dropdown trigger="click" @command="downloadTemplate"><el-button type="success" :icon="Download">下载模板<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="word">Word 导入模板</el-dropdown-item><el-dropdown-item command="excel">Excel 导入模板</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></header>
<section class="toolbar"><el-select v-model="query.type" clearable placeholder="题型:请选择" @change="load"><el-option v-for="item in types" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-select v-model="query.taskKey" clearable placeholder="归属项目:请选择" @change="load"><el-option v-for="item in projects" :key="item.taskKey" :label="projectLabel(item)" :value="item.taskKey" /></el-select><el-input v-model="query.keyword" placeholder="请输入题干关键词" clearable @keyup.enter="load" /><el-button type="primary" :icon="Search" @click="load">搜索</el-button><input ref="fileInput" type="file" accept=".docx,.xlsx,.xls" hidden @change="importFile" /></section>
<el-table v-loading="loading" :data="questions" class="data-table"><el-table-column type="index" label="序号" width="78" /><el-table-column label="题干" min-width="420" show-overflow-tooltip><template #default="{ row }"><span>{{ plainHtml(row.stem) }}</span></template></el-table-column><el-table-column label="题型" width="120"><template #default="{ row }"><el-tag>{{ typeName[row.type] }}</el-tag></template></el-table-column><el-table-column label="题目来源" width="140"><template #default><el-tag type="success">平台题库</el-tag></template></el-table-column><el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEditor(row)">编辑</el-button><el-button link type="primary" @click="openEditor(row)">查看</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template></el-table-column><template #empty><el-empty description="暂无题目" /></template></el-table>
<p class="bank-note">说明:平台题库为系统默认题库,所有教师可见;教师仅可查看平台题目,编辑删除自己新增的题目。</p>
<el-dialog v-model="editorVisible" :title="form.id ? '编辑题目' : '新增题目'" width="min(1180px, 94vw)" top="4vh" class="question-editor-dialog" destroy-on-close>
<div class="editor-shell"><div class="editor-project"><span>归属项目</span><el-select v-model="form.taskKey" placeholder="请选择归属项目"><el-option v-for="item in projects" :key="item.taskKey" :label="projectLabel(item)" :value="item.taskKey" /></el-select></div>
<div class="type-switch"><span>题型</span><el-select v-model="form.type" @change="resetForType" class="type-select"><el-option v-for="item in types" :key="item.value" :label="item.label" :value="item.value" /></el-select></div>
<div class="rich-row"><label>题干</label><Editor v-model="form.stem" :min-height="154" /></div>
<template v-if="isChoice"><div class="option-block"><label>选项</label><div class="option-list"><div v-for="(option, index) in form.options" :key="index" class="option-row"><div class="option-letter"><el-radio v-if="form.type === 'SINGLE'" v-model="singleAnswer" :label="optionLabel(index)" class="answer-control" /><el-checkbox v-else v-model="answers" :label="optionLabel(index)" class="answer-control" /><span class="letter-tag">{{ optionLabel(index) }}</span></div><Editor v-model="option.content" :min-height="92" /><el-button v-if="form.options.length > 2" link type="danger" :icon="Delete" @click="removeOption(index)" class="remove-btn" /></div><el-button type="primary" plain :icon="Plus" @click="addOption" class="add-option-btn">新增选项</el-button></div></div><div class="rich-row analysis-row"><label>答案解析(选填)</label><Editor v-model="form.analysis" :min-height="112" /></div></template>
<template v-else-if="form.type === 'TRUE_FALSE'"><div class="tf-block"><label>选项</label><div class="tf-options"><el-radio-group v-model="form.answer" class="tf-radio-group"><el-radio-button :label="true" class="tf-radio">对</el-radio-button><el-radio-button :label="false" class="tf-radio">错</el-radio-button></el-radio-group></div></div><div class="rich-row analysis-row"><label>答案解析(选填)</label><Editor v-model="form.analysis" :min-height="112" /></div></template>
<template v-else><div class="material-row"><el-upload :show-file-list="false" :http-request="uploadMaterial" accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"><el-button type="primary" plain :loading="uploading">上传简答题资料</el-button></el-upload><span v-if="form.attachmentName" class="material-name">{{ form.attachmentName }}</span></div><div class="rich-row"><label>参考答案</label><Editor v-model="form.answer" :min-height="180" /></div><div class="rich-row analysis-row"><label>答案解析(选填)</label><Editor v-model="form.analysis" :min-height="112" /></div></template>
</div><template #footer><el-button @click="editorVisible=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">确定</el-button></template>
</el-dialog>
<el-dialog v-model="aiVisible" title="AI 出题" width="620px" class="question-ai-dialog"><el-form :model="aiForm" label-width="92px"><el-form-item label="归属项目"><el-select v-model="aiForm.taskKey" style="width:100%"><el-option v-for="item in projects" :key="item.taskKey" :label="projectLabel(item)" :value="item.taskKey" /></el-select></el-form-item><el-form-item label="题型"><el-select v-model="aiForm.type"><el-option v-for="item in types" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="数量"><el-input-number v-model="aiForm.count" :min="1" :max="50" /></el-form-item><el-form-item label="出题要求"><el-input v-model="aiForm.requirement" type="textarea" :rows="4" placeholder="例如:偏重实训场景,难度中等" /></el-form-item></el-form><el-alert v-if="preview.length" title="请确认预览内容后再入库" type="info" :closable="false" class="preview-alert" /><el-scrollbar v-if="preview.length" max-height="280px"><div v-for="(q, i) in preview" :key="i" class="preview-item"><b>{{ i + 1 }}. {{ plainHtml(q.stem) }}</b><div>答案:{{ displayAnswer(q.answer) }}</div></div></el-scrollbar><template #footer><el-button @click="aiVisible=false">取消</el-button><el-button v-if="!preview.length" type="primary" :loading="aiLoading" @click="previewAi">生成预览</el-button><el-button v-else type="primary" :loading="aiLoading" @click="confirmAi">确认入库</el-button></template></el-dialog>
</main>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { ArrowDown, Delete, Download, Plus, Search, Upload } from "@element-plus/icons-vue";
import { saveAs } from "file-saver";
import * as api from "@/api/exam";
const types = [{ label: "单选题", value: "SINGLE" }, { label: "多选题", value: "MULTIPLE" }, { label: "判断题", value: "TRUE_FALSE" }, { label: "简答题", value: "SHORT_ANSWER" }];
const typeName = Object.fromEntries(types.map((item) => [item.value, item.label]));
const loading = ref(false); const saving = ref(false); const uploading = ref(false); const aiLoading = ref(false); const questions = ref([]); const projects = ref([]); const editorVisible = ref(false); const aiVisible = ref(false); const preview = ref([]); const fileInput = ref();
const query = reactive({ type: "", taskKey: "", keyword: "" }); const form = reactive(emptyQuestion()); const aiForm = reactive({ taskKey: "", type: "SINGLE", count: 5, requirement: "" });
const isChoice = computed(() => ["SINGLE", "MULTIPLE"].includes(form.type)); const answers = computed({ get: () => Array.isArray(form.answer) ? form.answer : [], set: (value) => { form.answer = value; } }); const singleAnswer = computed({ get: () => Array.isArray(form.answer) ? form.answer[0] || "" : "", set: (value) => { form.answer = value ? [value] : []; } });
function emptyQuestion() { return { id: null, taskKey: "", type: "SINGLE", stem: "<p></p>", options: ["A", "B", "C", "D"].map((label) => ({ label, content: "<p></p>" })), answer: [], analysis: "<p></p>", attachmentName: "", attachmentUrl: "" }; }
function projectLabel(item) { return `${item.projectName} / ${item.taskName}`; } function plainHtml(value) { return String(value || "").replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " "); }
async function load() { loading.value = true; try { questions.value = (await api.getPlatformQuestions(query)).data || []; } finally { loading.value = false; } } async function init() { projects.value = (await api.getPlatformQuestionProjects()).data || []; await load(); } onMounted(init);
function openEditor(row) { Object.assign(form, row ? JSON.parse(JSON.stringify(row)) : emptyQuestion()); if (!Array.isArray(form.answer) && isChoice.value) form.answer = []; editorVisible.value = true; } function openAi() { aiForm.taskKey = query.taskKey || aiForm.taskKey || projects.value[0]?.taskKey || ""; preview.value = []; aiVisible.value = true; }
function optionLabel(index) { return String.fromCharCode(65 + index); }
function addOption() { if (form.options.length >= 26) return ElMessage.warning("最多支持 26 个选项"); form.options.push({ label: optionLabel(form.options.length), content: "<p></p>" }); }
function removeOption(index) { if (form.options.length <= 2) return ElMessage.warning("至少保留 2 个选项"); const removed = form.options[index]; form.options.splice(index, 1); form.options.forEach((opt, i) => { opt.label = optionLabel(i); }); if (Array.isArray(form.answer)) form.answer = form.answer.filter((a) => a !== removed.label); }
function resetForType() { form.answer = form.type === "TRUE_FALSE" ? true : form.type === "SHORT_ANSWER" ? "<p></p>" : []; if (form.type !== "SHORT_ANSWER") { form.attachmentName = ""; form.attachmentUrl = ""; } if (isChoice.value) { const count = form.options.length || 4; form.options = Array.from({ length: Math.max(count, 4) }, (_, i) => ({ label: optionLabel(i), content: "<p></p>" })); } }
async function save() { if (!form.taskKey || !plainHtml(form.stem).trim()) return ElMessage.warning("请填写归属项目和题干"); if (isChoice.value && (!form.answer.length || form.options.some((item) => !plainHtml(item.content).trim()))) return ElMessage.warning("请补全选项并设置正确答案"); if (form.type === "SHORT_ANSWER" && !plainHtml(form.answer).trim()) return ElMessage.warning("请填写参考答案"); saving.value = true; try { await (form.id ? api.updatePlatformQuestion(form.id, form) : api.createPlatformQuestion(form)); ElMessage.success("题目已保存"); editorVisible.value = false; load(); } finally { saving.value = false; } }
async function remove(row) { await ElMessageBox.confirm("删除后无法恢复,确认删除?", "删除题目", { type: "warning" }); await api.deletePlatformQuestion(row.id); ElMessage.success("已删除"); load(); } function pickImport() { if (!query.taskKey) return ElMessage.warning("请先选择归属项目,再导入题目"); fileInput.value.value = ""; fileInput.value.click(); }
async function importFile(event) { const file = event.target.files[0]; if (!file) return; if (!query.taskKey) return ElMessage.warning("请先选择归属项目"); const data = new FormData(); data.append("file", file); await api.importPlatformQuestions(data, query.taskKey); ElMessage.success("导入成功"); event.target.value = ""; load(); }
async function downloadTemplate(format) { const extension = format === "excel" ? "xlsx" : "docx"; const blob = await api.downloadPlatformQuestionTemplate(format); saveAs(blob, `考试题库${format === "excel" ? "Excel" : "Word"}导入模板.${extension}`); }
async function uploadMaterial({ file }) { uploading.value = true; try { const data = new FormData(); data.append("file", file); const result = await api.uploadPlatformQuestionAttachment(data); form.attachmentName = result.data.name; form.attachmentUrl = result.data.url; ElMessage.success("资料已上传"); } finally { uploading.value = false; } }
async function previewAi() { if (!aiForm.taskKey) return ElMessage.warning("请选择归属项目"); aiLoading.value = true; try { preview.value = (await api.previewPlatformAiQuestions(aiForm)).data.questions || []; } finally { aiLoading.value = false; } } async function confirmAi() { aiLoading.value = true; try { await api.confirmPlatformAiQuestions({ questions: preview.value }); ElMessage.success("题目已入库"); preview.value = []; aiVisible.value = false; load(); } finally { aiLoading.value = false; } }
function displayAnswer(value) { return Array.isArray(value) ? value.join("、") : value === true ? "正确" : value === false ? "错误" : value || "-"; }
</script>
<style scoped>
.question-bank { min-height: 100%; color: #1f2937; background: transparent; }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 18px; }
.page-head h1 { margin: 0; font-size: 22px; font-weight: 700; color: #1f2937; }.page-head p { margin: 7px 0 0; color: #6b7280; font-size: 13px; }.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 14px; margin-bottom: 16px; border: 1px solid #e5e7eb; border-radius: 10px; background: #ffffff; }.toolbar .el-select { width: 210px; }.toolbar .el-input { width: min(300px, 30vw); }
.data-table { background: #ffffff; border-radius: 10px; }.bank-note { margin: 14px auto 0; max-width: 1180px; color: #6b7280; font-size: 13px; line-height: 1.8; text-align: center; }
.editor-shell { padding: 4px 8px 12px; color: #1f2937; }.editor-project { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }.editor-project > span { width: 62px; color: #4b5563; }.editor-project .el-select { width: 420px; }.type-switch { display: flex; align-items: center; gap: 12px; margin: 4px 0 22px; }.type-switch > span { width: 62px; color: #4b5563; font-size: 15px; text-align: right; }.type-select { width: 200px; }.rich-row, .option-block { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; margin-bottom: 22px; align-items: start; }.rich-row > label, .option-block > label, .tf-block > label { padding-top: 10px; color: #4b5563; font-size: 15px; text-align: right; }.option-list { display: grid; gap: 12px; }.option-row { display: grid; grid-template-columns: 56px minmax(0, 1fr) 36px; align-items: start; gap: 10px; }.option-letter { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; }.option-letter .answer-control { margin-right: 0; }.letter-tag { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; background: #eff6ff; color: #2563eb; font-size: 13px; font-weight: 600; border: 1px solid #bfdbfe; }.remove-btn { padding-top: 16px; color: #9ca3af; }.add-option-btn { justify-self: start; margin-top: 4px; }.tf-block { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; margin: 18px 0 30px; align-items: center; }.tf-options { display: flex; gap: 16px; }.tf-radio-group { display: flex; gap: 16px; }.tf-radio { padding: 10px 36px; font-size: 15px; border-radius: 8px; }.material-row { display: flex; align-items: center; gap: 14px; margin: 6px 0 22px 132px; }.material-name { color: #6b7280; }.preview-alert { margin-bottom: 12px; }.preview-item { padding: 10px 4px; border-bottom: 1px solid #e5e7eb; line-height: 1.7; color: #1f2937; }.editor-shell :deep(.ql-container) { min-height: 88px; font-size: 15px; color: #1f2937; }.editor-shell :deep(.ql-editor) { min-height: 74px; color: #1f2937; }.editor-shell :deep(.ql-toolbar) { background: #fff; border-color: #e5e7eb; }.editor-shell :deep(.ql-editor.ql-blank::before) { color: #9ca3af; font-style: normal; }
</style>