477 lines
15 KiB
Vue
477 lines
15 KiB
Vue
<template>
|
||
<el-dialog :title="dialogTitle" :model-value="visible" width="720px" @close="handleClose">
|
||
<!-- 步骤指示 -->
|
||
<el-steps :active="currentStep - 1" finish-status="success" simple class="steps-bar">
|
||
<el-step title="基础信息" />
|
||
<el-step title="选项与答案" />
|
||
</el-steps>
|
||
|
||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||
<el-divider />
|
||
<!-- 第一步:题型、分值、题目 -->
|
||
<div v-if="currentStep === 1" class="form-flex">
|
||
<el-form-item label="题型" prop="question_type">
|
||
<el-select v-model="form.question_type" placeholder="请选择题型" style="width: 100%">
|
||
<el-option
|
||
v-for="opt in typeOptions"
|
||
:key="opt.dict_value"
|
||
:label="opt.dict_label"
|
||
:value="String(opt.dict_value)"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="分值" prop="score">
|
||
<el-input-number v-model="form.score" :step="1" :min="0" :max="100" :precision="0" />
|
||
</el-form-item>
|
||
<el-form-item label="题目" prop="question_title">
|
||
<WangEditor v-model="form.question_title" />
|
||
</el-form-item>
|
||
</div>
|
||
|
||
<!-- 第二步:选项、答案、解析 -->
|
||
<template v-else>
|
||
<template v-if="showOptions">
|
||
<el-divider />
|
||
<div class="options-header">
|
||
<span>选项</span>
|
||
<div class="op">
|
||
<el-button v-if="canCustomizeOptions" size="small" @click="addOption">新增选项</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="option-list">
|
||
<div v-for="(opt, idx) in form.options" :key="opt.key" class="option-item">
|
||
<div class="label">{{ opt.label }}</div>
|
||
<el-input v-model="opt.content" placeholder="请输入选项内容" />
|
||
<el-button v-if="canCustomizeOptions" link type="danger" @click="removeOption(idx)">删除</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-if="showAnswer">
|
||
<el-divider />
|
||
<el-form-item label="正确答案" prop="answer">
|
||
<template v-if="isSingleType">
|
||
<el-select v-model="form.answer" placeholder="请选择">
|
||
<el-option v-for="opt in form.options" :key="opt.key" :label="opt.label" :value="opt.label" />
|
||
</el-select>
|
||
</template>
|
||
<template v-else-if="isMultipleType">
|
||
<el-select v-model="multiAnswer" multiple placeholder="请选择">
|
||
<el-option v-for="opt in form.options" :key="opt.key" :label="opt.label" :value="opt.label" />
|
||
</el-select>
|
||
</template>
|
||
<template v-else>
|
||
<el-input v-model="form.answer" type="textarea" :rows="2" placeholder="请输入答案" />
|
||
</template>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<el-form-item label="解析">
|
||
<WangEditor v-model="form.question_analysis" />
|
||
</el-form-item>
|
||
</template>
|
||
</el-form>
|
||
|
||
<template #footer>
|
||
<div class="dlg-actions">
|
||
<el-button @click="handleClose">取消</el-button>
|
||
<el-button v-if="currentStep === 1" type="primary" @click="handleNext">下一步</el-button>
|
||
<template v-else>
|
||
<el-button @click="handlePrev">上一步</el-button>
|
||
<el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 相似题目提示弹窗:仅在新增时发现匹配题目时显示 -->
|
||
<el-dialog v-model="duplicateDialogVisible" title="发现相似题目" width="640px" append-to-body>
|
||
<div class="duplicate-tip">当前题目:{{ form.question_title }}</div>
|
||
<el-table :data="duplicateList" border style="margin-top: 12px">
|
||
<el-table-column label="匹配题目">
|
||
<template #default="{ row }">
|
||
<div v-html="highlightMatchedTitle(row.question_title)"></div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="匹配度" width="120" align="center">
|
||
<template #default="{ row }">
|
||
{{ row._match_rate != null ? row._match_rate + '%' : '-' }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="140" align="center">
|
||
<template #default="{ row }">
|
||
<el-button type="primary" link @click="handleUseExisting(row)">使用该题目</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<template #footer>
|
||
<div class="dlg-actions">
|
||
<el-button @click="duplicateDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="handleContinueCreate">继续新增</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { getDictItemsByCode } from '@/api/dict'
|
||
import { getExamQuestions } from '@/api/exam'
|
||
import '@wangeditor/editor/dist/css/style.css'
|
||
|
||
const props = defineProps({
|
||
visible: { type: Boolean, default: false },
|
||
mode: { type: String, default: 'create' },
|
||
model: { type: Object, default: null },
|
||
bankId: { type: [Number, String], default: null }
|
||
})
|
||
|
||
const emit = defineEmits(['close', 'saved'])
|
||
|
||
const formRef = ref()
|
||
const saving = ref(false)
|
||
const currentStep = ref(1) // 1: 基础信息, 2: 选项与答案
|
||
const typeOptions = ref([])
|
||
const duplicateDialogVisible = ref(false)
|
||
const duplicateList = ref([])
|
||
const dialogTitle = computed(() => (props.mode === 'edit' ? '编辑题目' : '新建题目'))
|
||
|
||
// 富文本编辑器:题目 & 解析
|
||
const titleEditorRef = ref(null)
|
||
const titleEditorHtml = ref('')
|
||
const analysisEditorRef = ref(null)
|
||
const analysisEditorHtml = ref('')
|
||
|
||
const toolbarConfig = {}
|
||
const titleEditorConfig = { placeholder: '请输入题目' }
|
||
const analysisEditorConfig = { placeholder: '可选' }
|
||
|
||
const defaultOptions = () => ([
|
||
{ key: 'A', label: 'A', content: '' },
|
||
{ key: 'B', label: 'B', content: '' },
|
||
{ key: 'C', label: 'C', content: '' },
|
||
{ key: 'D', label: 'D', content: '' }
|
||
])
|
||
|
||
const form = reactive({
|
||
question_title: '',
|
||
question_type: '',
|
||
score: 2,
|
||
question_analysis: '',
|
||
options: defaultOptions(),
|
||
answer: ''
|
||
})
|
||
|
||
const rules = {
|
||
question_title: [{ required: true, message: '请输入题目', trigger: 'blur' }],
|
||
question_type: [{ required: true, message: '请选择题型', trigger: 'change' }],
|
||
score: [{ required: true, message: '请输入分值', trigger: 'change' }],
|
||
answer: [{ validator: validateAnswer, trigger: 'change' }]
|
||
}
|
||
|
||
function handleTitleEditorCreated(editor) {
|
||
titleEditorRef.value = editor
|
||
}
|
||
|
||
function handleTitleEditorChange() {
|
||
form.question_title = titleEditorHtml.value || ''
|
||
}
|
||
|
||
function handleAnalysisEditorCreated(editor) {
|
||
analysisEditorRef.value = editor
|
||
}
|
||
|
||
function handleAnalysisEditorChange() {
|
||
form.question_analysis = analysisEditorHtml.value || ''
|
||
}
|
||
|
||
function validateAnswer(rule, value, callback) {
|
||
if (!showAnswer.value) return callback()
|
||
if (isMultipleType.value) {
|
||
if (!multiAnswer.value || multiAnswer.value.length === 0) return callback(new Error('请选择答案'))
|
||
return callback()
|
||
}
|
||
if (!form.answer) return callback(new Error('请输入/选择答案'))
|
||
callback()
|
||
}
|
||
|
||
const isSingleType = computed(() => ['single', 'judge', '1', '3'].includes(String(form.question_type)))
|
||
const isMultipleType = computed(() => ['multiple', '2'].includes(String(form.question_type)))
|
||
const isObjective = computed(() => isSingleType.value || isMultipleType.value)
|
||
const showOptions = computed(() => isObjective.value)
|
||
const showAnswer = computed(() => true)
|
||
const canCustomizeOptions = computed(() => ['single', 'multiple', '1', '2'].includes(String(form.question_type)))
|
||
const multiAnswer = ref([])
|
||
|
||
watch(() => props.visible, async (v) => {
|
||
if (v) {
|
||
currentStep.value = 1
|
||
await ensureTypeOptions()
|
||
initForm()
|
||
await nextTick()
|
||
}
|
||
})
|
||
|
||
watch(() => props.model, (/* newVal */) => {
|
||
if (props.visible) {
|
||
initForm()
|
||
}
|
||
})
|
||
|
||
watch(() => form.question_type, (t) => {
|
||
const s = String(t)
|
||
if (['judge', '3'].includes(s)) {
|
||
form.options = [
|
||
{ key: 'T', label: '对', content: '对' },
|
||
{ key: 'F', label: '错', content: '错' }
|
||
]
|
||
form.answer = ''
|
||
multiAnswer.value = []
|
||
} else if (['single', 'multiple', '1', '2'].includes(s)) {
|
||
if (!form.options || form.options.length === 0 || form.options[0].label === '对') {
|
||
form.options = defaultOptions()
|
||
}
|
||
form.answer = ''
|
||
multiAnswer.value = []
|
||
} else {
|
||
form.options = []
|
||
}
|
||
})
|
||
|
||
function initForm() {
|
||
if (props.model) {
|
||
form.question_title = props.model.question_title || ''
|
||
form.question_type = props.model.question_type != null ? String(props.model.question_type) : ''
|
||
form.score = props.model.score ?? 2
|
||
form.question_analysis = props.model.question_analysis || ''
|
||
form.options = Array.isArray(props.model.options) && props.model.options.length > 0 ? props.model.options.map((o, i) => ({ key: o.key || String.fromCharCode(65 + i), label: o.label || String.fromCharCode(65 + i), content: o.content || '' })) : (isObjective.value ? defaultOptions() : [])
|
||
if (Array.isArray(props.model.answer)) {
|
||
multiAnswer.value = props.model.answer
|
||
form.answer = ''
|
||
} else {
|
||
form.answer = props.model.answer || ''
|
||
multiAnswer.value = []
|
||
}
|
||
} else {
|
||
form.question_title = ''
|
||
form.question_type = ''
|
||
form.score = 2
|
||
form.question_analysis = ''
|
||
form.options = defaultOptions()
|
||
form.answer = ''
|
||
multiAnswer.value = []
|
||
}
|
||
|
||
// 同步富文本内容
|
||
titleEditorHtml.value = form.question_title || ''
|
||
analysisEditorHtml.value = form.question_analysis || ''
|
||
}
|
||
|
||
async function handleNext() {
|
||
// 手动校验第一步必填项,避免 validateField 阻塞跳转
|
||
if (!form.question_type) {
|
||
ElMessage.error('请选择题型')
|
||
return
|
||
}
|
||
if (form.score === null || form.score === undefined || form.score === '') {
|
||
ElMessage.error('请输入分值')
|
||
return
|
||
}
|
||
if (!form.question_title) {
|
||
ElMessage.error('请输入题目')
|
||
return
|
||
}
|
||
|
||
// 新增模式下:检查当前租户下是否已存在相同/相似题目
|
||
if (props.mode !== 'edit') {
|
||
try {
|
||
const res = await getExamQuestions({
|
||
page: 1,
|
||
pageSize: 1,
|
||
keyword: form.question_title,
|
||
})
|
||
let items = []
|
||
if (res && res.code === 0 && res.data && Array.isArray(res.data.list)) {
|
||
items = res.data.list
|
||
} else if (res && res.success && Array.isArray(res.data)) {
|
||
items = res.data
|
||
}
|
||
if (items.length > 0) {
|
||
// 前端根据题干与当前输入的相似度计算匹配度,存到 _match_rate 字段
|
||
const currentTitle = String(form.question_title || '')
|
||
duplicateList.value = items.map((it) => {
|
||
const title = String(it.question_title || '')
|
||
let rate = null
|
||
if (currentTitle && title) {
|
||
const maxLen = Math.max(currentTitle.length, title.length)
|
||
let same = 0
|
||
for (let i = 0; i < maxLen; i++) {
|
||
const c1 = currentTitle[i]
|
||
const c2 = title[i]
|
||
if (c1 && c2 && c1 === c2) same++
|
||
}
|
||
rate = maxLen > 0 ? Math.round((same / maxLen) * 100) : null
|
||
}
|
||
return { ...it, _match_rate: rate }
|
||
})
|
||
duplicateDialogVisible.value = true
|
||
// 让用户在列表中选择是否使用已存在题目或继续新增,不直接进入第二步
|
||
return
|
||
}
|
||
} catch (e) {
|
||
// 查询失败不阻塞继续操作,仅在控制台记录
|
||
console.error('检查重复题目失败', e)
|
||
}
|
||
}
|
||
|
||
currentStep.value = 2
|
||
}
|
||
|
||
function handlePrev() {
|
||
currentStep.value = 1
|
||
}
|
||
|
||
async function ensureTypeOptions() {
|
||
if (typeOptions.value && typeOptions.value.length > 0) return
|
||
try {
|
||
const res = await getDictItemsByCode('exam_question_type')
|
||
if (res && res.success && Array.isArray(res.data)) typeOptions.value = res.data
|
||
else if (Array.isArray(res)) typeOptions.value = res
|
||
else typeOptions.value = []
|
||
} catch {
|
||
typeOptions.value = []
|
||
}
|
||
}
|
||
|
||
function addOption() {
|
||
const nextIndex = form.options.length
|
||
const label = String.fromCharCode(65 + nextIndex)
|
||
form.options.push({ key: label, label, content: '' })
|
||
}
|
||
|
||
function removeOption(idx) {
|
||
form.options.splice(idx, 1)
|
||
form.options.forEach((o, i) => {
|
||
o.key = String.fromCharCode(65 + i)
|
||
o.label = o.key
|
||
})
|
||
if (isMultipleType.value) {
|
||
multiAnswer.value = multiAnswer.value.filter(l => form.options.some(o => o.label === l))
|
||
} else if (isSingleType.value) {
|
||
if (!form.options.some(o => o.label === form.answer)) form.answer = ''
|
||
}
|
||
}
|
||
|
||
function handleClose() {
|
||
emit('close')
|
||
}
|
||
|
||
function handleSubmit() {
|
||
formRef.value.validate(async (ok) => {
|
||
if (!ok) return
|
||
saving.value = true
|
||
try {
|
||
const payload = {
|
||
question_title: form.question_title,
|
||
question_type: form.question_type,
|
||
score: form.score,
|
||
question_analysis: form.question_analysis,
|
||
options: showOptions.value ? form.options.map(o => ({ key: o.key, label: o.label, content: o.content })) : [],
|
||
answer: isMultipleType.value ? [...multiAnswer.value] : form.answer
|
||
}
|
||
if (props.bankId) payload.bank_id = props.bankId
|
||
emit('saved', payload)
|
||
} catch (e) {
|
||
ElMessage.error('保存失败')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
})
|
||
}
|
||
|
||
function handleUseExisting(row) {
|
||
// 使用已存在题目:先关闭相似题目弹窗,再关闭当前新增弹窗
|
||
duplicateDialogVisible.value = false
|
||
emit('close')
|
||
}
|
||
|
||
function handleContinueCreate() {
|
||
// 继续新增,关闭弹窗并进入第二步
|
||
duplicateDialogVisible.value = false
|
||
currentStep.value = 2
|
||
}
|
||
|
||
function highlightMatchedTitle(title) {
|
||
const currentTitle = String(form.question_title || '')
|
||
const chars = String(title || '').split('')
|
||
const highlightedTitle = chars.map((c, idx) => {
|
||
const cur = currentTitle[idx]
|
||
if (cur && cur === c) {
|
||
return `<span class="match-highlight">${c}</span>`
|
||
}
|
||
return c
|
||
}).join('')
|
||
return highlightedTitle
|
||
}
|
||
|
||
onBeforeUnmount(() => {
|
||
if (titleEditorRef.value) {
|
||
titleEditorRef.value.destroy()
|
||
}
|
||
if (analysisEditorRef.value) {
|
||
analysisEditorRef.value.destroy()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.form-flex {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap:15px;
|
||
}
|
||
.options-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
.option-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.option-item {
|
||
display: grid;
|
||
grid-template-columns: 48px 1fr auto;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
.label {
|
||
width: 48px;
|
||
text-align: center;
|
||
}
|
||
.dlg-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 12px;
|
||
}
|
||
|
||
.rich-editor {
|
||
border: 1px solid #dcdfe6;
|
||
border-radius: 4px;
|
||
padding: 0 8px 8px;
|
||
}
|
||
|
||
.duplicate-tip {
|
||
font-size: 13px;
|
||
color: #666;
|
||
}
|
||
|
||
.match-highlight {
|
||
color: #f56c6c;
|
||
}
|
||
</style>
|