新增智能新建题目功能
This commit is contained in:
parent
0fff276e59
commit
e3366af9ec
106
pc/package-lock.json
generated
106
pc/package-lock.json
generated
@ -21,7 +21,8 @@
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3",
|
||||
"vue3-pdf-app": "^1.0.3"
|
||||
"vue3-pdf-app": "^1.0.3",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.9.2",
|
||||
@ -1784,6 +1785,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/array-buffer-byte-length": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
|
||||
@ -1945,6 +1955,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chart": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/chart/-/chart-0.1.2.tgz",
|
||||
@ -1983,6 +2006,15 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-0.2.1.tgz",
|
||||
@ -2041,6 +2073,18 @@
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz",
|
||||
@ -2633,6 +2677,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@ -4796,6 +4849,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ssr-window": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/ssr-window/-/ssr-window-3.0.0.tgz",
|
||||
@ -5523,6 +5588,45 @@
|
||||
"resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz",
|
||||
"integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,7 +22,8 @@
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3",
|
||||
"vue3-pdf-app": "^1.0.3"
|
||||
"vue3-pdf-app": "^1.0.3",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.9.2",
|
||||
|
||||
@ -122,6 +122,15 @@ export function createExamQuestion(data) {
|
||||
})
|
||||
}
|
||||
|
||||
//题目批量导入
|
||||
export function batchImportExamQuestions(data) {
|
||||
return request({
|
||||
url: '/api/exam-questions/batch',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 更新题目
|
||||
export function updateExamQuestion(id, data) {
|
||||
return request({
|
||||
|
||||
@ -1,10 +1,23 @@
|
||||
<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">
|
||||
<div class="form-flex">
|
||||
<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="opt.dict_value" />
|
||||
<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">
|
||||
@ -15,51 +28,87 @@
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<!-- 第二步:选项、答案、解析 -->
|
||||
<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>
|
||||
<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 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>
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
<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="解析">
|
||||
<el-input v-model="form.question_analysis" type="textarea" :rows="3" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-form-item label="解析">
|
||||
<el-input v-model="form.question_analysis" type="textarea" :rows="3" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="dlg-actions">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSubmit">保存</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>
|
||||
@ -67,8 +116,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getDictItemsByCode } from '@/api/dict'
|
||||
import { getExamQuestions } from '@/api/exam'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
@ -80,7 +130,10 @@ 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 defaultOptions = () => ([
|
||||
@ -93,7 +146,7 @@ const defaultOptions = () => ([
|
||||
const form = reactive({
|
||||
question_title: '',
|
||||
question_type: '',
|
||||
score: 5,
|
||||
score: 2,
|
||||
question_analysis: '',
|
||||
options: defaultOptions(),
|
||||
answer: ''
|
||||
@ -126,6 +179,7 @@ const multiAnswer = ref([])
|
||||
|
||||
watch(() => props.visible, async (v) => {
|
||||
if (v) {
|
||||
currentStep.value = 1
|
||||
await ensureTypeOptions()
|
||||
initForm()
|
||||
await nextTick()
|
||||
@ -161,7 +215,7 @@ watch(() => form.question_type, (t) => {
|
||||
function initForm() {
|
||||
if (props.model) {
|
||||
form.question_title = props.model.question_title || ''
|
||||
form.question_type = props.model.question_type || ''
|
||||
form.question_type = props.model.question_type != null ? String(props.model.question_type) : ''
|
||||
form.score = props.model.score ?? 5
|
||||
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() : [])
|
||||
@ -183,6 +237,70 @@ function initForm() {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -239,6 +357,31 @@ function handleSubmit() {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@ -273,4 +416,13 @@ function handleSubmit() {
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.duplicate-tip {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.match-highlight {
|
||||
color: #f56c6c;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -34,8 +34,11 @@
|
||||
<!-- 操作栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :icon="Plus" @click="handleCreate">新建题目</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="handleSmartCreate">智能新建</el-button>
|
||||
<div style="flex:1" />
|
||||
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
|
||||
<el-button :icon="Upload" @click="handleBatchImport">批量导入</el-button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -49,9 +52,9 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="score" label="分值" width="100" align="center" />
|
||||
<el-table-column prop="create_time" label="创建时间" width="180" align="center">
|
||||
<!-- <el-table-column prop="create_time" label="创建时间" width="180" align="center">
|
||||
<template #default="{ row }">{{ formatTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
</el-table-column> -->
|
||||
<el-table-column label="操作" width="220" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleView(row)">查看</el-button>
|
||||
@ -72,6 +75,68 @@
|
||||
<EditDialog :visible="editVisible" :mode="editMode" :model="editModel" @close="editVisible = false"
|
||||
@saved="handleSaved" />
|
||||
<DetailDialog :visible="viewVisible" :model="viewModel" @close="viewVisible = false" />
|
||||
|
||||
<!-- 批量导入弹窗:仅支持上传 Excel(.xls/.xlsx) -->
|
||||
<el-dialog v-model="importDialogVisible" title="批量导入题目" width="640px">
|
||||
<p>请下载模板填写题目内容,再上传 Excel 文件完成批量导入。</p>
|
||||
|
||||
<div style="margin-bottom: 12px;">
|
||||
<el-button type="success" @click="handleDownloadTemplate">下载模板</el-button>
|
||||
<span style="margin-left: 12px; color:#888; font-size:12px;">模板为 .xlsx 文件,可直接在 Excel 中编辑。</span>
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left">上传 Excel 文件</el-divider>
|
||||
<el-upload
|
||||
class="upload-block"
|
||||
:show-file-list="false"
|
||||
accept=".xls,.xlsx"
|
||||
:auto-upload="false"
|
||||
:on-change="handleImportFileChange"
|
||||
>
|
||||
<el-button type="primary" :loading="importing">选择 Excel 文件</el-button>
|
||||
<span style="margin-left: 12px; color:#888; font-size:12px;">表头需包含:question_title, question_type, score, question_analysis, option_a, option_b, ... , answer</span>
|
||||
</el-upload>
|
||||
|
||||
<template #footer>
|
||||
<div class="dlg-actions">
|
||||
<el-button @click="importDialogVisible = false">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 智能新建弹窗:先选择题型,再粘贴文本,自动解析为题目 -->
|
||||
<el-dialog v-model="smartDialogVisible" title="智能新建题目" width="640px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="题型">
|
||||
<el-select v-model="smartType" placeholder="请选择题型" style="width: 260px;">
|
||||
<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>
|
||||
|
||||
<p style="margin: 4px 0 8px; font-size: 13px; color: #666;">
|
||||
请粘贴完整的题目文本,包含题干、选项、答案和解析,例如:
|
||||
</p>
|
||||
<el-input
|
||||
v-model="smartRawText"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="在这里粘贴题目文本..."
|
||||
style="margin-top: 12px;"
|
||||
/>
|
||||
|
||||
<template #footer>
|
||||
<div class="dlg-actions">
|
||||
<el-button @click="smartDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSmartParse">解析并新建</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -79,10 +144,11 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Plus, Refresh, Back } from '@element-plus/icons-vue'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { Search, Plus, Refresh, Back, Upload } from '@element-plus/icons-vue'
|
||||
import { getDictItemsByCode } from '@/api/dict'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getExamQuestions, getExamQuestionDetail, createExamQuestion, updateExamQuestion, deleteExamQuestion } from '@/api/exam'
|
||||
import { getExamQuestions, getExamQuestionDetail, createExamQuestion, updateExamQuestion, deleteExamQuestion, batchImportExamQuestions } from '@/api/exam'
|
||||
import EditDialog from './edit.vue'
|
||||
import DetailDialog from './detail.vue'
|
||||
|
||||
@ -106,6 +172,33 @@ const typeDictMap = computed(() => {
|
||||
for (const it of typeOptions.value || []) m[String(it.dict_value)] = it.dict_label
|
||||
return m
|
||||
})
|
||||
|
||||
// 模板
|
||||
function handleDownloadTemplate() {
|
||||
// 英文字段名(程序使用)
|
||||
const headers = ['question_title', 'question_type', 'score', 'question_analysis', 'option_a', 'option_b', 'option_c', 'option_d', 'answer']
|
||||
// 中文说明行(第一行,给使用者看的)
|
||||
const headerTips = ['题目', '题型(1-单选,2-多选,3-判断,4-填空,5-简答)', '分值', '解析', '选项A', '选项B', '选项C', '选项D', '答案']
|
||||
// 示例数据从第三行开始
|
||||
const sample1 = ['1+1=?', 1, 2, '示例:单选题解析', '1', '2', '3', '4', 'B']
|
||||
const wb = XLSX.utils.book_new()
|
||||
// 第一行:中文说明;第二行:英文字段;第三行开始:示例数据
|
||||
const ws = XLSX.utils.aoa_to_sheet([headerTips, headers, sample1])
|
||||
// 每列自定义宽度(单位为字符数)
|
||||
ws['!cols'] = [
|
||||
{ wch: 40 }, // 题目 / question_title
|
||||
{ wch: 60 }, // 题型 / question_type
|
||||
{ wch: 8 }, // 分值 / score
|
||||
{ wch: 40 }, // 解析 / question_analysis
|
||||
{ wch: 18 }, // 选项A / option_a
|
||||
{ wch: 18 }, // 选项B / option_b
|
||||
{ wch: 18 }, // 选项C / option_c
|
||||
{ wch: 18 }, // 选项D / option_d
|
||||
{ wch: 12 }, // 答案 / answer
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, ws, '题目模板')
|
||||
XLSX.writeFile(wb, '题目批量导入模板.xlsx')
|
||||
}
|
||||
const getTypeLabel = (type) => typeDictMap.value[String(type)] || String(type || '')
|
||||
|
||||
const editVisible = ref(false)
|
||||
@ -113,6 +206,13 @@ const editMode = ref('create')
|
||||
const editModel = ref(null)
|
||||
const viewVisible = ref(false)
|
||||
const viewModel = ref(null)
|
||||
const importDialogVisible = ref(false)
|
||||
const importing = ref(false)
|
||||
|
||||
// 智能新建
|
||||
const smartDialogVisible = ref(false)
|
||||
const smartRawText = ref('')
|
||||
const smartType = ref('')
|
||||
|
||||
function normalizeType(val) {
|
||||
if (val === null || val === undefined || val === '') return undefined
|
||||
@ -170,6 +270,263 @@ function handleCreate() {
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
function handleSmartCreate() {
|
||||
smartRawText.value = ''
|
||||
smartType.value = ''
|
||||
smartDialogVisible.value = true
|
||||
}
|
||||
|
||||
function handleBatchImport() {
|
||||
importDialogVisible.value = true
|
||||
}
|
||||
|
||||
function parseCsvToItems(text) {
|
||||
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(l => l)
|
||||
if (lines.length <= 1) return []
|
||||
const headers = lines[0].split(',').map(h => h.trim())
|
||||
const getIndex = (name) => headers.findIndex(h => h.toLowerCase() === name.toLowerCase())
|
||||
const idxTitle = getIndex('question_title')
|
||||
const idxType = getIndex('question_type')
|
||||
const idxScore = getIndex('score')
|
||||
const idxAnalysis = getIndex('question_analysis')
|
||||
const idxAnswer = getIndex('answer')
|
||||
const optionIdxMap = {}
|
||||
headers.forEach((h, i) => {
|
||||
const m = h.toLowerCase().match(/^option_([a-z])$/)
|
||||
if (m) optionIdxMap[m[1].toUpperCase()] = i
|
||||
})
|
||||
const items = []
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cols = lines[i].split(',')
|
||||
if (!cols.some(c => c.trim())) continue
|
||||
const title = idxTitle >= 0 ? cols[idxTitle].trim() : ''
|
||||
if (!title) continue
|
||||
const typeRaw = idxType >= 0 ? cols[idxType].trim() : ''
|
||||
const scoreRaw = idxScore >= 0 ? cols[idxScore].trim() : ''
|
||||
const analysis = idxAnalysis >= 0 ? cols[idxAnalysis].trim() : ''
|
||||
const answerRaw = idxAnswer >= 0 ? cols[idxAnswer].trim() : ''
|
||||
const options = Object.entries(optionIdxMap).map(([label, idx]) => ({
|
||||
label,
|
||||
content: cols[idx] != null ? cols[idx].trim() : ''
|
||||
})).filter(o => o.content)
|
||||
let answer = ''
|
||||
if (answerRaw) {
|
||||
if (answerRaw.includes(',')) {
|
||||
answer = answerRaw.split(',').map(s => s.trim()).filter(Boolean)
|
||||
} else {
|
||||
answer = answerRaw
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
question_title: title,
|
||||
question_type: typeRaw ? Number(typeRaw) : undefined,
|
||||
score: scoreRaw ? Number(scoreRaw) : 0,
|
||||
question_analysis: analysis,
|
||||
options,
|
||||
answer,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
function handleSmartParse() {
|
||||
const raw = (smartRawText.value || '').trim()
|
||||
if (!smartType.value) {
|
||||
ElMessage.error('请先选择题型')
|
||||
return
|
||||
}
|
||||
if (!raw) {
|
||||
ElMessage.error('请先粘贴题目文本')
|
||||
return
|
||||
}
|
||||
const lines = raw.split(/\r?\n/).map(l => l.trim()).filter(l => l)
|
||||
if (!lines.length) {
|
||||
ElMessage.error('内容为空')
|
||||
return
|
||||
}
|
||||
|
||||
// 查找答案和解析行
|
||||
let answerLineIndex = -1
|
||||
let parseLineIndex = -1
|
||||
lines.forEach((l, idx) => {
|
||||
if (answerLineIndex === -1 && /^答案\s*[::]/.test(l)) answerLineIndex = idx
|
||||
if (parseLineIndex === -1 && /^解析\s*[::]/.test(l)) parseLineIndex = idx
|
||||
})
|
||||
|
||||
// 识别选项行(A./B./C./D.)
|
||||
const optionRegex = /^([A-Za-z])[\..、]\s*(.+)$/
|
||||
let firstOptionIndex = -1
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (optionRegex.test(lines[i])) {
|
||||
firstOptionIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (firstOptionIndex === -1 || answerLineIndex === -1) {
|
||||
ElMessage.error('未能识别选项或答案,请检查格式')
|
||||
return
|
||||
}
|
||||
|
||||
const questionLines = lines.slice(0, firstOptionIndex)
|
||||
const questionTitle = questionLines.join('\n').trim()
|
||||
if (!questionTitle) {
|
||||
ElMessage.error('未能识别题目内容')
|
||||
return
|
||||
}
|
||||
|
||||
const options = []
|
||||
const endOfOptions = answerLineIndex === -1 ? lines.length : answerLineIndex
|
||||
for (let i = firstOptionIndex; i < endOfOptions; i++) {
|
||||
const m = lines[i].match(optionRegex)
|
||||
if (!m) continue
|
||||
const label = m[1].toUpperCase()
|
||||
const content = m[2].trim()
|
||||
if (!content) continue
|
||||
options.push({ key: label, label, content })
|
||||
}
|
||||
if (!options.length) {
|
||||
ElMessage.error('未能识别选项内容')
|
||||
return
|
||||
}
|
||||
|
||||
// 提取答案
|
||||
const answerLine = lines[answerLineIndex] || ''
|
||||
let answerRaw = answerLine.replace(/^答案\s*[::]/, '').trim()
|
||||
answerRaw = answerRaw.replace(/^选项/, '').trim()
|
||||
let answer
|
||||
if (!answerRaw) {
|
||||
answer = ''
|
||||
} else if (/[,,]/.test(answerRaw)) {
|
||||
// 多选:A,C 或 A,C
|
||||
answer = answerRaw.split(/[,,]/).map(s => s.trim()).filter(Boolean)
|
||||
} else {
|
||||
answer = answerRaw
|
||||
}
|
||||
|
||||
// 提取解析
|
||||
let analysis = ''
|
||||
if (parseLineIndex !== -1) {
|
||||
const parseLine = lines[parseLineIndex] || ''
|
||||
analysis = parseLine.replace(/^解析\s*[::]/, '').trim()
|
||||
}
|
||||
|
||||
// 预填到创建表单
|
||||
editMode.value = 'create'
|
||||
editModel.value = {
|
||||
question_title: questionTitle,
|
||||
question_type: smartType.value,
|
||||
score: 2,
|
||||
question_analysis: analysis,
|
||||
options,
|
||||
answer,
|
||||
}
|
||||
smartDialogVisible.value = false
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
function handleImportFileChange(file) {
|
||||
const raw = file.raw || file
|
||||
if (!raw) return
|
||||
const name = (raw.name || file.name || '').toLowerCase()
|
||||
const isExcel = name.endsWith('.xls') || name.endsWith('.xlsx')
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
let items = []
|
||||
if (isExcel) {
|
||||
const data = new Uint8Array(e.target.result)
|
||||
const workbook = XLSX.read(data, { type: 'array' })
|
||||
const sheetName = workbook.SheetNames[0]
|
||||
const sheet = workbook.Sheets[sheetName]
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 })
|
||||
items = parseExcelToItems(rows)
|
||||
} else {
|
||||
const text = String(e.target.result || '')
|
||||
items = parseCsvToItems(text)
|
||||
}
|
||||
if (!items || items.length === 0) {
|
||||
ElMessage.error('文件内容为空或格式无法识别')
|
||||
return
|
||||
}
|
||||
importing.value = true
|
||||
const res = await batchImportExamQuestions({ items })
|
||||
if (res && res.code === 0) {
|
||||
const data = res.data || {}
|
||||
ElMessage.success(`导入完成:成功 ${data.success ?? 0} 条,失败 ${data.failed ?? 0} 条`)
|
||||
importDialogVisible.value = false
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res?.message || '导入失败')
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error('导入失败')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = () => {
|
||||
ElMessage.error('读取文件失败')
|
||||
}
|
||||
|
||||
if (isExcel) {
|
||||
reader.readAsArrayBuffer(raw)
|
||||
} else {
|
||||
reader.readAsText(raw, 'utf-8')
|
||||
}
|
||||
}
|
||||
|
||||
function parseExcelToItems(rows) {
|
||||
// 期望格式:第1行为中文说明,第2行为英文字段名,从第3行开始为数据
|
||||
if (!Array.isArray(rows) || rows.length <= 2) return []
|
||||
const headers = (rows[1] || []).map((h) => String(h || '').trim())
|
||||
const getIndex = (name) => headers.findIndex(h => h.toLowerCase() === name.toLowerCase())
|
||||
const idxTitle = getIndex('question_title')
|
||||
const idxType = getIndex('question_type')
|
||||
const idxScore = getIndex('score')
|
||||
const idxAnalysis = getIndex('question_analysis')
|
||||
const idxAnswer = getIndex('answer')
|
||||
const optionIdxMap = {}
|
||||
headers.forEach((h, i) => {
|
||||
const m = h.toLowerCase().match(/^option_([a-z])$/)
|
||||
if (m) optionIdxMap[m[1].toUpperCase()] = i
|
||||
})
|
||||
const items = []
|
||||
for (let i = 2; i < rows.length; i++) {
|
||||
const cols = rows[i] || []
|
||||
if (!cols.some(c => String(c || '').trim())) continue
|
||||
const title = idxTitle >= 0 ? String(cols[idxTitle] || '').trim() : ''
|
||||
if (!title) continue
|
||||
const typeRaw = idxType >= 0 ? String(cols[idxType] || '').trim() : ''
|
||||
const scoreRaw = idxScore >= 0 ? String(cols[idxScore] || '').trim() : ''
|
||||
const analysis = idxAnalysis >= 0 ? String(cols[idxAnalysis] || '').trim() : ''
|
||||
const answerRaw = idxAnswer >= 0 ? String(cols[idxAnswer] || '').trim() : ''
|
||||
const options = Object.entries(optionIdxMap).map(([label, idx]) => ({
|
||||
label,
|
||||
content: cols[idx] != null ? String(cols[idx]).trim() : ''
|
||||
})).filter(o => o.content)
|
||||
let answer = ''
|
||||
if (answerRaw) {
|
||||
if (answerRaw.includes(',')) {
|
||||
answer = answerRaw.split(',').map(s => s.trim()).filter(Boolean)
|
||||
} else {
|
||||
answer = answerRaw
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
question_title: title,
|
||||
question_type: typeRaw ? Number(typeRaw) : undefined,
|
||||
score: scoreRaw ? Number(scoreRaw) : 0,
|
||||
question_analysis: analysis,
|
||||
options,
|
||||
answer,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
async function handleView(row) {
|
||||
try {
|
||||
const res = await getExamQuestionDetail(row.id)
|
||||
@ -188,16 +545,63 @@ async function handleView(row) {
|
||||
|
||||
function handleEdit(row) {
|
||||
editMode.value = 'edit'
|
||||
editModel.value = {
|
||||
id: row.id,
|
||||
question_title: row.question_title,
|
||||
question_type: row.question_type,
|
||||
score: row.score,
|
||||
question_analysis: row.question_analysis,
|
||||
options: row.options || [],
|
||||
answer: row.answer,
|
||||
}
|
||||
editVisible.value = true
|
||||
// 为了在编辑时带上选项和答案,这里同查看逻辑一样先拉取详情
|
||||
// 列表接口只返回基础字段,不包含 options/answer
|
||||
getExamQuestionDetail(row.id).then((res) => {
|
||||
let data = null
|
||||
if (res && res.code === 0 && res.data) {
|
||||
data = res.data
|
||||
} else if (res && res.success && res.data) {
|
||||
data = res.data
|
||||
}
|
||||
const src = data || row
|
||||
// 详情接口返回结构为 QuestionWithMeta,需要从中提取题目信息和选项/答案
|
||||
// 这里尽量兼容两种结构:
|
||||
// 1) src.question/question_title 等字段直接在根上
|
||||
// 2) src.Question / src.Options / src.Answer 等驼峰命名
|
||||
const q = src.question || src.Question || src
|
||||
const options = src.options || src.Options || []
|
||||
let answer = src.answer || src.Answer || src.answer_content || src.AnswerContent || null
|
||||
// 如果 Answer 为对象且有 answer_content/AnswerContent,则直接使用该字段
|
||||
if (answer && typeof answer === 'object') {
|
||||
if ('answer_content' in answer && answer.answer_content) {
|
||||
answer = answer.answer_content
|
||||
} else if ('AnswerContent' in answer && answer.AnswerContent) {
|
||||
answer = answer.AnswerContent
|
||||
}
|
||||
}
|
||||
editModel.value = {
|
||||
id: q.id || q.Id || row.id,
|
||||
question_title: q.question_title || q.QuestionTitle || row.question_title,
|
||||
question_type: q.question_type || q.QuestionType || row.question_type,
|
||||
score: q.score || q.Score || row.score,
|
||||
question_analysis: q.question_analysis || q.QuestionAnalysis || row.question_analysis,
|
||||
// 后端选项模型为 ExamQuestionOption,需要映射为前端 editDialog 期望的 { key,label,content }
|
||||
options: Array.isArray(options)
|
||||
? options.map((o, idx) => ({
|
||||
// key/label 兼容多种命名:key / label / option_label / OptionLabel
|
||||
key: o.key || o.label || o.option_label || o.OptionLabel || String.fromCharCode(65 + idx),
|
||||
label: o.label || o.option_label || o.OptionLabel || String.fromCharCode(65 + idx),
|
||||
// content 兼容 content / option_content / OptionContent
|
||||
content: o.content || o.option_content || o.OptionContent || '',
|
||||
}))
|
||||
: (row.options || []),
|
||||
answer: answer !== null && answer !== undefined ? answer : (row.answer ?? ''),
|
||||
}
|
||||
editVisible.value = true
|
||||
}).catch(() => {
|
||||
// 回退到只用列表行的基础信息,至少可编辑基础字段
|
||||
editModel.value = {
|
||||
id: row.id,
|
||||
question_title: row.question_title,
|
||||
question_type: row.question_type,
|
||||
score: row.score,
|
||||
question_analysis: row.question_analysis,
|
||||
options: row.options || [],
|
||||
answer: row.answer,
|
||||
}
|
||||
editVisible.value = true
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
|
||||
@ -6,7 +6,7 @@ runmode = dev
|
||||
# MySQL - 远程连接配置
|
||||
mysqluser = gotest
|
||||
mysqlpass = 2nZhRdMPCNZrdzsd
|
||||
mysqlurls = 212.64.112.158:3388
|
||||
mysqlurls = 192.168.31.10:3306
|
||||
mysqldb = gotest
|
||||
|
||||
# ORM配置
|
||||
|
||||
@ -144,6 +144,96 @@ func (c *ExamQuestionController) Create() {
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
// BatchCreate
|
||||
// @router /exam-questions/batch [post]
|
||||
func (c *ExamQuestionController) BatchCreate() {
|
||||
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
|
||||
var payload struct {
|
||||
Items []struct {
|
||||
QuestionTitle string `json:"question_title"`
|
||||
QuestionType int8 `json:"question_type"`
|
||||
Score float64 `json:"score"`
|
||||
QuestionAnalysis string `json:"question_analysis"`
|
||||
Options []map[string]string `json:"options"`
|
||||
Answer interface{} `json:"answer"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &payload); err != nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 1, "message": "请求参数错误: " + err.Error(), "data": nil}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
if len(payload.Items) == 0 {
|
||||
c.Data["json"] = map[string]interface{}{"code": 1, "message": "导入数据为空", "data": nil}
|
||||
c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
success := 0
|
||||
fails := 0
|
||||
createdIds := make([]int64, 0, len(payload.Items))
|
||||
updatedIds := make([]int64, 0, len(payload.Items))
|
||||
for _, item := range payload.Items {
|
||||
q := &models.ExamQuestion{
|
||||
TenantId: tenantId,
|
||||
QuestionType: item.QuestionType,
|
||||
QuestionTitle: item.QuestionTitle,
|
||||
QuestionAnalysis: item.QuestionAnalysis,
|
||||
Score: item.Score,
|
||||
Status: 1,
|
||||
}
|
||||
var opts []models.ExamQuestionOption
|
||||
for _, o := range item.Options {
|
||||
opts = append(opts, models.ExamQuestionOption{OptionLabel: o["label"], OptionContent: o["content"]})
|
||||
}
|
||||
answerContent := ""
|
||||
switch v := item.Answer.(type) {
|
||||
case string:
|
||||
answerContent = v
|
||||
case []interface{}:
|
||||
for i, it := range v {
|
||||
if s, ok := it.(string); ok {
|
||||
if i == 0 {
|
||||
answerContent = s
|
||||
} else {
|
||||
answerContent += "," + s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果存在完全匹配的题目标题,则覆盖更新;否则新增
|
||||
if existing, err := services.FindExamQuestionByTitle(tenantId, item.QuestionTitle); err == nil && existing != nil {
|
||||
if err := services.UpdateExamQuestion(tenantId, existing.Id, q, opts, answerContent); err != nil {
|
||||
fails++
|
||||
continue
|
||||
}
|
||||
updatedIds = append(updatedIds, existing.Id)
|
||||
success++
|
||||
} else {
|
||||
id, err := services.CreateExamQuestion(tenantId, q, opts, answerContent)
|
||||
if err != nil {
|
||||
fails++
|
||||
continue
|
||||
}
|
||||
createdIds = append(createdIds, id)
|
||||
success++
|
||||
}
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 0,
|
||||
"message": "批量导入完成",
|
||||
"data": map[string]interface{}{
|
||||
"success": success,
|
||||
"failed": fails,
|
||||
"created_ids": createdIds,
|
||||
"updated_ids": updatedIds,
|
||||
},
|
||||
}
|
||||
c.ServeJSON()
|
||||
}
|
||||
|
||||
// Update
|
||||
// @router /exam-questions/:id [put]
|
||||
func (c *ExamQuestionController) Update() {
|
||||
|
||||
@ -286,6 +286,8 @@ func init() {
|
||||
// 考试题目路由
|
||||
beego.Router("/api/exam-questions", &controllers.ExamQuestionController{}, "get:GetList;post:Create")
|
||||
beego.Router("/api/exam-questions/:id", &controllers.ExamQuestionController{}, "get:GetDetail;put:Update;delete:Delete")
|
||||
// 题目批量导入路由
|
||||
beego.Router("/api/exam-questions/batch", &controllers.ExamQuestionController{}, "post:BatchCreate")
|
||||
|
||||
// 考试题库路由
|
||||
beego.Router("/api/exam-question-banks", &controllers.ExamQuestionBankController{}, "get:GetBankList;post:CreateBank")
|
||||
|
||||
@ -74,6 +74,21 @@ func GetExamQuestionDetail(tenantId int, id int64) (*QuestionWithMeta, error) {
|
||||
return &QuestionWithMeta{Question: q, Options: opts, Answer: a}, nil
|
||||
}
|
||||
|
||||
// FindExamQuestionByTitle 在指定租户下根据题目标题精确查找试题
|
||||
// 用于批量导入时判断是否存在完全相同题目,以便执行覆盖更新
|
||||
func FindExamQuestionByTitle(tenantId int, title string) (*models.ExamQuestion, error) {
|
||||
o := orm.NewOrm()
|
||||
q := new(models.ExamQuestion)
|
||||
qs := o.QueryTable(q).Filter("delete_time__isnull", true).Filter("question_title", title)
|
||||
if tenantId > 0 {
|
||||
qs = qs.Filter("tenant_id", tenantId)
|
||||
}
|
||||
if err := qs.One(q); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func CreateExamQuestion(tenantId int, q *models.ExamQuestion, options []models.ExamQuestionOption, answerContent string) (int64, error) {
|
||||
o := orm.NewOrm()
|
||||
if tenantId > 0 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user