第一步选择题支持从题库选题:新增共用选题弹窗(仅单选题、按题干去重、复制快照转step_one_questions格式),教师端取本校题库(scope=school),平台端取平台题库

fix/exam-question-access-material-excel-delete-draft
陈沅 2 weeks ago
parent ca1c5d5886
commit d2de3d3890

@ -0,0 +1,135 @@
<template>
<el-dialog :model-value="visible" title="从题库选择单选题" width="min(880px, 94vw)" append-to-body
class="step-one-bank-picker" @update:model-value="close">
<div class="bank-picker-intro">
<div>
<span>QUESTION BANK</span>
<p>勾选后确认,题目会复制为当前任务的第一步选择题;仅列出题库中的单选题,与现有题目重复的不再列出。</p>
</div>
<span class="bank-picker-count">已选 {{ checkedIds.size }} 题</span>
</div>
<div v-loading="loading" class="bank-question-list">
<article v-for="(question, index) in selectable" :key="question.id" role="button" tabindex="0"
:class="['bank-question-card', { 'is-checked': checkedIds.has(question.id) }]"
@click="toggle(question)" @keydown.enter.prevent="toggle(question)">
<span class="bank-check">{{ checkedIds.has(question.id) ? '✓' : String(index + 1).padStart(2, '0') }}</span>
<div class="bank-body">
<h4>{{ question.stem }}</h4>
<div class="bank-options">
<span v-for="option in question.options" :key="option.label"><b>{{ option.label }}.</b>{{
option.content }}</span>
</div>
</div>
</article>
<el-empty v-if="!loading && !selectable.length" description="题库暂无可添加的单选题" />
</div>
<template #footer>
<el-button @click="close(false)">取消</el-button>
<el-button type="primary" :disabled="!checkedIds.size" @click="confirmSelection">
添加 {{ checkedIds.size }} 道题
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { QUESTION_OPTION_KEYS } from "@/utils/stepOneChoiceQuestions";
const props = defineProps({
visible: { type: Boolean, default: false },
// 返回题库单选题列表的异步函数(教师端/平台端各自注入接口)
loadQuestions: { type: Function, required: true },
// 当前已配置的题目,用于按题干去重
existing: { type: Array, default: () => [] },
});
const emit = defineEmits(["update:visible", "confirm"]);
const loading = ref(false);
const questions = ref([]);
const checkedIds = ref(new Set());
const existingTitles = computed(() => new Set((props.existing || []).map((q) => String(q.title || "").trim())));
// 已存在于任务配置中的题不再展示,避免重复添加
const selectable = computed(() => questions.value.filter((q) => !existingTitles.value.has(String(q.stem || "").trim())));
watch(() => props.visible, (opened) => {
if (opened) load();
}, { immediate: true });
async function load() {
checkedIds.value = new Set();
loading.value = true;
try {
questions.value = (await props.loadQuestions()) || [];
} catch {
questions.value = [];
ElMessage.error("题库加载失败,请稍后重试");
} finally {
loading.value = false;
}
}
function toggle(question) {
const next = new Set(checkedIds.value);
next.has(question.id) ? next.delete(question.id) : next.add(question.id);
checkedIds.value = next;
}
function convert(question, index) {
const options = (question.options || [])
.map((option) => ({ key: option.label, content: String(option.content || "").trim() }))
.filter((option) => option.key && option.content);
const keys = options.map((option) => option.key);
const rawAnswer = Array.isArray(question.answer) ? question.answer[0] : question.answer;
const answer = keys.includes(rawAnswer) ? rawAnswer : keys[0] || QUESTION_OPTION_KEYS[0];
return {
id: `step-one-question-${Date.now()}-${index + 1}`,
title: String(question.stem || "").trim(),
options,
answer,
};
}
function confirmSelection() {
const picked = questions.value.filter((q) => checkedIds.value.has(q.id));
if (!picked.length) return;
emit("confirm", picked.map(convert));
close();
}
function close(value = false) {
emit("update:visible", value);
}
</script>
<style lang="scss">
.step-one-bank-picker {
.el-dialog { overflow: hidden; border: 1px solid #cfe2f5; background: #ffffff; box-shadow: 0 24px 80px rgba(30, 58, 95, 0.46); }
.el-dialog__header { margin: 0; padding: 20px 24px; border-bottom: 1px solid #cfe2f5; }
.el-dialog__title { color: #1e3a5f; font-size: 18px; font-weight: 800; }
.el-dialog__body { max-height: min(64vh, 700px); overflow-y: auto; padding: 20px 24px; }
.el-dialog__footer { padding: 16px 24px; border-top: 1px solid #cfe2f5; }
}
.bank-picker-intro { display:flex; align-items:center; justify-content:space-between; gap:20px; margin-bottom:18px; padding:15px 17px; border-left:3px solid #1e88e5; background:linear-gradient(90deg,rgba(187,222,251,.5),transparent); }
.bank-picker-intro span { color:#1e88e5; font-size:12px; font-weight:800; letter-spacing:.12em; }
.bank-picker-intro p { margin:5px 0 0; color:#5a7a9a; line-height:1.6; }
.bank-picker-count { flex-shrink:0; padding:6px 14px; color:#1e88e5; font-size:13px; font-weight:800; border:1px solid #cfe2f5; border-radius:16px; background:#f0f6fd; }
.bank-question-list { display:grid; gap:12px; min-height:120px; }
.bank-question-card { display:flex; gap:12px; padding:14px 16px; border:1px solid #cfe2f5; border-radius:10px; background:#ffffff; cursor:pointer; transition:all .16s ease;
&:hover { border-color:#1e88e5; background:#f6fafe; }
&.is-checked { border-color:#1e88e5; background:#eaf3fd; }
}
.bank-check { flex:0 0 auto; display:grid; place-items:center; width:28px; height:28px; margin-top:2px; color:#1e88e5; font-size:12px; font-weight:900; border:1px solid #cfe2f5; border-radius:50%; background:#f0f6fd; }
.bank-question-card.is-checked .bank-check { color:#ffffff; background:#1e88e5; border-color:#1e88e5; }
.bank-body { min-width:0;
h4 { margin:0 0 8px; color:#1f2937; font-size:14px; line-height:1.6; }
}
.bank-options { display:grid; gap:4px;
span { color:#5a7a9a; font-size:13px; line-height:1.55; }
b { margin-right:6px; color:#1e88e5; font-weight:700; }
}
</style>

@ -408,11 +408,15 @@
</div> </div>
</div> </div>
<div class="material-ops"> <div class="material-ops">
<el-button v-if="!isReadonly" class="upload-btn" @click="bankPickerVisible = true">
从题库选题
</el-button>
<el-button v-if="!isReadonly" type="primary" class="upload-btn" @click="choiceEditorVisible = true"> <el-button v-if="!isReadonly" type="primary" class="upload-btn" @click="choiceEditorVisible = true">
设置选择题 设置选择题
</el-button> </el-button>
</div> </div>
</div> </div>
<StepOneQuestionBankPicker v-model:visible="bankPickerVisible" :load-questions="loadBankQuestions" :existing="taskForm.stepOneQuestions" @confirm="handleBankConfirm" />
<StepOneChoiceQuestionEditor v-model:visible="choiceEditorVisible" v-model="taskForm.stepOneQuestions" :task-name="taskForm.title" /> <StepOneChoiceQuestionEditor v-model:visible="choiceEditorVisible" v-model="taskForm.stepOneQuestions" :task-name="taskForm.title" />
<el-form-item label="步骤名称:"> <el-form-item label="步骤名称:">
@ -478,7 +482,9 @@ import {
uploadTrainingTaskMaterial, uploadTrainingTaskMaterial,
} from "@/api/trainingTask"; } from "@/api/trainingTask";
import StepOneChoiceQuestionEditor from "@/views/components/StepOneChoiceQuestionEditor.vue"; import StepOneChoiceQuestionEditor from "@/views/components/StepOneChoiceQuestionEditor.vue";
import StepOneQuestionBankPicker from "@/views/components/StepOneQuestionBankPicker.vue";
import { parseStepOneChoiceQuestions, supportsStepOneChoiceQuestions } from "@/utils/stepOneChoiceQuestions"; import { parseStepOneChoiceQuestions, supportsStepOneChoiceQuestions } from "@/utils/stepOneChoiceQuestions";
import { getPlatformQuestions } from "@/api/exam";
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
@ -488,6 +494,7 @@ const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const uploadingField = ref(""); const uploadingField = ref("");
const choiceEditorVisible = ref(false); const choiceEditorVisible = ref(false);
const bankPickerVisible = ref(false);
// 四类资料槽位:key 对应 taskForm 的 `${key}Name` / `${key}Url` 字段 // 四类资料槽位:key 对应 taskForm 的 `${key}Name` / `${key}Url` 字段
const materialSlots = [ const materialSlots = [
{ key: "material", label: "案例资料" }, { key: "material", label: "案例资料" },
@ -600,6 +607,17 @@ function supportsStepOneChoice(taskKey) {
return supportsStepOneChoiceQuestions(taskKey); return supportsStepOneChoiceQuestions(taskKey);
} }
// 平台题库单选题(平台管理员维护默认任务,题源为平台题库)
async function loadBankQuestions() {
const res = await getPlatformQuestions({ type: "SINGLE" });
return res?.data || [];
}
function handleBankConfirm(questions) {
taskForm.stepOneQuestions = [...(taskForm.stepOneQuestions || []), ...questions];
proxy?.$modal?.msgSuccess(`已从题库添加 ${questions.length} 道题,点击“保存配置”后生效`);
}
function hasMaterial(slotKey) { function hasMaterial(slotKey) {
return isRealMaterial(taskForm[`${slotKey}Name`], taskForm[`${slotKey}Url`]); return isRealMaterial(taskForm[`${slotKey}Name`], taskForm[`${slotKey}Url`]);
} }

@ -359,11 +359,15 @@
</div> </div>
</div> </div>
<div class="material-ops"> <div class="material-ops">
<el-button v-if="!isReadonly" class="upload-btn" @click="bankPickerVisible = true">
从题库选题
</el-button>
<el-button v-if="!isReadonly" type="primary" class="upload-btn" @click="choiceEditorVisible = true"> <el-button v-if="!isReadonly" type="primary" class="upload-btn" @click="choiceEditorVisible = true">
设置选择题 设置选择题
</el-button> </el-button>
</div> </div>
</div> </div>
<StepOneQuestionBankPicker v-model:visible="bankPickerVisible" :load-questions="loadBankQuestions" :existing="taskForm.stepOneQuestions" @confirm="handleBankConfirm" />
<StepOneChoiceQuestionEditor v-model:visible="choiceEditorVisible" v-model="taskForm.stepOneQuestions" :task-name="taskForm.title" /> <StepOneChoiceQuestionEditor v-model:visible="choiceEditorVisible" v-model="taskForm.stepOneQuestions" :task-name="taskForm.title" />
<!-- 步骤数量与顺序固定,仅允许修改名称 --> <!-- 步骤数量与顺序固定,仅允许修改名称 -->
@ -420,7 +424,9 @@ import { createStudentDemoSession } from "@/api/studentDemo"
import * as indexApi from "@/api/teacher" import * as indexApi from "@/api/teacher"
import useUserStore from "@/store/modules/user" import useUserStore from "@/store/modules/user"
import StepOneChoiceQuestionEditor from "@/views/components/StepOneChoiceQuestionEditor.vue" import StepOneChoiceQuestionEditor from "@/views/components/StepOneChoiceQuestionEditor.vue"
import StepOneQuestionBankPicker from "@/views/components/StepOneQuestionBankPicker.vue"
import { parseStepOneChoiceQuestions, supportsStepOneChoiceQuestions } from "@/utils/stepOneChoiceQuestions" import { parseStepOneChoiceQuestions, supportsStepOneChoiceQuestions } from "@/utils/stepOneChoiceQuestions"
import { getQuestions } from "@/api/exam"
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
const userStore = useUserStore() const userStore = useUserStore()
@ -433,6 +439,7 @@ const saving = ref(false)
const restoring = ref(false) const restoring = ref(false)
const uploadingField = ref("") const uploadingField = ref("")
const choiceEditorVisible = ref(false) const choiceEditorVisible = ref(false)
const bankPickerVisible = ref(false)
// 四类资料槽位:key 对应 taskForm 的 `${key}Name` / `${key}Url` 字段 // 四类资料槽位:key 对应 taskForm 的 `${key}Name` / `${key}Url` 字段
const materialSlots = [ const materialSlots = [
{ key: "material", label: "案例资料" }, { key: "material", label: "案例资料" },
@ -603,6 +610,17 @@ function supportsStepOneChoice(taskKey) {
return supportsStepOneChoiceQuestions(taskKey) return supportsStepOneChoiceQuestions(taskKey)
} }
// 本校题库单选题(平台题 + 本校教师题,按学校过滤)
async function loadBankQuestions() {
const res = await getQuestions({ scope: "school", type: "SINGLE" })
return res?.data || []
}
function handleBankConfirm(questions) {
taskForm.stepOneQuestions = [...(taskForm.stepOneQuestions || []), ...questions]
proxy?.$modal?.msgSuccess(`已从题库添加 ${questions.length} 道题,点击“保存配置”后生效`)
}
function hasMaterial(slotKey) { function hasMaterial(slotKey) {
return isRealMaterial(taskForm[`${slotKey}Name`], taskForm[`${slotKey}Url`]) return isRealMaterial(taskForm[`${slotKey}Name`], taskForm[`${slotKey}Url`])
} }

Loading…
Cancel
Save