yunzer_go/pc/src/views/apps/exams/examinationQuestions/components/edit.vue
2025-11-18 17:11:18 +08:00

477 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>