新增智能新建题目功能

This commit is contained in:
扫地僧 2025-11-17 22:55:28 +08:00
parent 0fff276e59
commit e3366af9ec
9 changed files with 834 additions and 57 deletions

106
pc/package-lock.json generated
View File

@ -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"
}
}
}
}

View File

@ -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",

View File

@ -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({

View File

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

View File

@ -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 AC
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) {
// 123
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) {

View File

@ -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配置

View File

@ -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() {

View File

@ -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")

View File

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