|
|
|
@ -1,12 +1,12 @@
|
|
|
|
<template>
|
|
|
|
<template>
|
|
|
|
<main class="question-bank app-container">
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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-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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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="editor-shell">
|
|
|
|
<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="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>
|
|
|
|
<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-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>
|
|
|
|
@ -15,7 +15,7 @@
|
|
|
|
</div><template #footer><el-button @click="editorVisible=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">确定</el-button></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>
|
|
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
<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.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>
|
|
|
|
</main>
|
|
|
|
</template>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
@ -28,23 +28,23 @@ import * as api from "@/api/exam";
|
|
|
|
|
|
|
|
|
|
|
|
const types = [{ label: "单选题", value: "SINGLE" }, { label: "多选题", value: "MULTIPLE" }, { label: "判断题", value: "TRUE_FALSE" }, { label: "简答题", value: "SHORT_ANSWER" }];
|
|
|
|
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 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 loading = ref(false); const saving = ref(false); const uploading = ref(false); const aiLoading = ref(false); const questions = 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 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] : []; } });
|
|
|
|
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 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(/ /g, " "); }
|
|
|
|
function plainHtml(value) { return String(value || "").replace(/<[^>]+>/g, "").replace(/ /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);
|
|
|
|
async function load() { loading.value = true; try { questions.value = (await api.getPlatformQuestions(query)).data || []; } finally { loading.value = false; } } async function init() { 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 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 = ""; preview.value = []; aiVisible.value = true; }
|
|
|
|
function optionLabel(index) { return String.fromCharCode(65 + index); }
|
|
|
|
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 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 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>" })); } }
|
|
|
|
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 save() { if (!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 remove(row) { await ElMessageBox.confirm("删除后无法恢复,确认删除?", "删除题目", { type: "warning" }); await api.deletePlatformQuestion(row.id); ElMessage.success("已删除"); load(); } function pickImport() { 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 importFile(event) { const file = event.target.files[0]; if (!file) return; 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 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 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; } }
|
|
|
|
async function previewAi() { 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 || "-"; }
|
|
|
|
function displayAnswer(value) { return Array.isArray(value) ? value.join("、") : value === true ? "正确" : value === false ? "错误" : value || "-"; }
|
|
|
|
</script>
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
|