优化题目代码
This commit is contained in:
parent
5fddba8a30
commit
7664821d88
3
.gitignore
vendored
3
.gitignore
vendored
@ -25,6 +25,9 @@ dist-ssr
|
|||||||
*.local
|
*.local
|
||||||
server/uploads
|
server/uploads
|
||||||
server/conf/
|
server/conf/
|
||||||
|
pc/dist.zip
|
||||||
|
pc/dist.7z
|
||||||
|
pc/dist
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@ -6,18 +6,13 @@
|
|||||||
<el-step title="选项与答案" />
|
<el-step title="选项与答案" />
|
||||||
</el-steps>
|
</el-steps>
|
||||||
|
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
<!-- 第一步表单:基础信息 -->
|
||||||
<el-divider />
|
<el-form v-if="currentStep === 1" ref="formRefStep1" :model="form" :rules="rulesStep1" label-width="100px">
|
||||||
<!-- 第一步:题型、分值、题目 -->
|
<div class="form-flex">
|
||||||
<div v-if="currentStep === 1" class="form-flex">
|
|
||||||
<el-form-item label="题型" prop="question_type">
|
<el-form-item label="题型" prop="question_type">
|
||||||
<el-select v-model="form.question_type" placeholder="请选择题型" style="width: 100%">
|
<el-select v-model="form.question_type" placeholder="请选择题型" style="width: 100%">
|
||||||
<el-option
|
<el-option v-for="opt in typeOptions" :key="opt.dict_value" :label="opt.dict_label"
|
||||||
v-for="opt in typeOptions"
|
:value="String(opt.dict_value)" />
|
||||||
:key="opt.dict_value"
|
|
||||||
:label="opt.dict_label"
|
|
||||||
:value="String(opt.dict_value)"
|
|
||||||
/>
|
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="分值" prop="score">
|
<el-form-item label="分值" prop="score">
|
||||||
@ -27,49 +22,49 @@
|
|||||||
<WangEditor v-model="form.question_title" />
|
<WangEditor v-model="form.question_title" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
<!-- 第二步:选项、答案、解析 -->
|
<!-- 第二步表单:选项、答案、解析 -->
|
||||||
<template v-else>
|
<el-form v-else ref="formRefStep2" :model="form" :rules="rulesStep2" label-width="100px" style="margin-top: 20px">
|
||||||
<template v-if="showOptions">
|
<template v-if="showOptions">
|
||||||
<el-divider />
|
<div class="options-header">
|
||||||
<div class="options-header">
|
<!-- <span>选项</span> -->
|
||||||
<span>选项</span>
|
<div class="op">
|
||||||
<div class="op">
|
<el-button v-if="canCustomizeOptions" type="primary" @click="addOption">新增选项</el-button>
|
||||||
<el-button v-if="canCustomizeOptions" size="small" @click="addOption">新增选项</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="option-list">
|
</div>
|
||||||
<div v-for="(opt, idx) in form.options" :key="opt.key" class="option-item">
|
<div class="option-list">
|
||||||
<div class="label">{{ opt.label }}</div>
|
<div v-for="(opt, idx) in form.options" :key="opt.key" class="option-item">
|
||||||
<el-input v-model="opt.content" placeholder="请输入选项内容" />
|
<div class="label">{{ opt.label }}</div>
|
||||||
<el-button v-if="canCustomizeOptions" link type="danger" @click="removeOption(idx)">删除</el-button>
|
<el-input v-model="opt.content" placeholder="请输入选项内容" />
|
||||||
</div>
|
<el-button v-if="canCustomizeOptions" link type="danger" @click="removeOption(idx)">删除</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-if="showAnswer">
|
<template v-if="showAnswer">
|
||||||
<el-divider />
|
<el-divider />
|
||||||
<el-form-item label="正确答案" prop="answer">
|
<el-form-item label="正确答案" prop="answer">
|
||||||
<template v-if="isSingleType">
|
<template v-if="isSingleType">
|
||||||
<el-select v-model="form.answer" placeholder="请选择">
|
<el-radio-group v-model="form.answer">
|
||||||
<el-option v-for="opt in form.options" :key="opt.key" :label="opt.label" :value="opt.label" />
|
<el-radio-button v-for="opt in form.options" :key="opt.key" :label="opt.label">{{ opt.label }}</el-radio-button>
|
||||||
</el-select>
|
</el-radio-group>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="isMultipleType">
|
<template v-else-if="isMultipleType">
|
||||||
<el-select v-model="multiAnswer" multiple placeholder="请选择">
|
<el-checkbox-group v-model="multiAnswer">
|
||||||
<el-option v-for="opt in form.options" :key="opt.key" :label="opt.label" :value="opt.label" />
|
<el-checkbox-button v-for="opt in form.options" :key="opt.key" :label="opt.label">{{ opt.label }}</el-checkbox-button>
|
||||||
</el-select>
|
</el-checkbox-group>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<el-input v-model="form.answer" type="textarea" :rows="2" placeholder="请输入答案" />
|
<el-input v-model="form.answer" type="textarea" :rows="2" placeholder="请输入答案" />
|
||||||
</template>
|
</template>
|
||||||
</el-form-item>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<el-form-item label="解析">
|
|
||||||
<WangEditor v-model="form.question_analysis" />
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<el-divider />
|
||||||
|
<el-form-item label="解析">
|
||||||
|
<WangEditor v-model="form.question_analysis" />
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@ -79,13 +74,14 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<el-button @click="handlePrev">上一步</el-button>
|
<el-button @click="handlePrev">上一步</el-button>
|
||||||
<el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
|
<el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
|
||||||
|
<el-button type="primary" plain :loading="saving" @click="() => handleSubmit(true)">保存并继续</el-button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 相似题目提示弹窗:仅在新增时发现匹配题目时显示 -->
|
<!-- 相似题目提示弹窗:仅在新增时发现匹配题目时显示 -->
|
||||||
<el-dialog v-model="duplicateDialogVisible" title="发现相似题目" width="640px" append-to-body>
|
<el-dialog v-model="duplicateDialogVisible" title="发现相似题目" width="800px;" append-to-body>
|
||||||
<div class="duplicate-tip">当前题目:{{ form.question_title }}</div>
|
<div class="duplicate-tip">当前题目:{{ form.question_title }}</div>
|
||||||
<el-table :data="duplicateList" border style="margin-top: 12px">
|
<el-table :data="duplicateList" border style="margin-top: 12px">
|
||||||
<el-table-column label="匹配题目">
|
<el-table-column label="匹配题目">
|
||||||
@ -98,17 +94,17 @@
|
|||||||
{{ row._match_rate != null ? row._match_rate + '%' : '-' }}
|
{{ row._match_rate != null ? row._match_rate + '%' : '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="140" align="center">
|
<el-table-column label="操作" width="160" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link @click="handleUseExisting(row)">使用该题目</el-button>
|
<el-button type="primary" link @click="handleUseExisting(row)">拉取题目</el-button>
|
||||||
|
<el-button type="primary" link @click="handleContinueCreate">继续新增</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dlg-actions">
|
<div class="dlg-actions">
|
||||||
<el-button @click="duplicateDialogVisible = false">取消</el-button>
|
<el-button @click="duplicateDialogVisible = false">返回修改</el-button>
|
||||||
<el-button type="primary" @click="handleContinueCreate">继续新增</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@ -118,7 +114,7 @@
|
|||||||
import { ref, reactive, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
import { ref, reactive, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { getDictItemsByCode } from '@/api/dict'
|
import { getDictItemsByCode } from '@/api/dict'
|
||||||
import { getExamQuestions } from '@/api/exam'
|
import { getExamQuestions, getExamQuestionDetail, createExamQuestion } from '@/api/exam'
|
||||||
import '@wangeditor/editor/dist/css/style.css'
|
import '@wangeditor/editor/dist/css/style.css'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -130,7 +126,8 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['close', 'saved'])
|
const emit = defineEmits(['close', 'saved'])
|
||||||
|
|
||||||
const formRef = ref()
|
const formRefStep1 = ref()
|
||||||
|
const formRefStep2 = ref()
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const currentStep = ref(1) // 1: 基础信息, 2: 选项与答案
|
const currentStep = ref(1) // 1: 基础信息, 2: 选项与答案
|
||||||
const typeOptions = ref([])
|
const typeOptions = ref([])
|
||||||
@ -157,18 +154,31 @@ const defaultOptions = () => ([
|
|||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
question_title: '',
|
question_title: '',
|
||||||
question_type: '',
|
question_type: '1',
|
||||||
score: 2,
|
score: 2,
|
||||||
question_analysis: '',
|
question_analysis: '',
|
||||||
options: defaultOptions(),
|
options: defaultOptions(),
|
||||||
answer: ''
|
answer: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const rules = {
|
const rulesStep1 = {
|
||||||
question_title: [{ required: true, message: '请输入题目', trigger: 'blur' }],
|
|
||||||
question_type: [{ required: true, message: '请选择题型', trigger: 'change' }],
|
question_type: [{ required: true, message: '请选择题型', trigger: 'change' }],
|
||||||
score: [{ required: true, message: '请输入分值', trigger: 'change' }],
|
score: [{ required: true, message: '请输入分值', trigger: 'change' }],
|
||||||
answer: [{ validator: validateAnswer, trigger: 'change' }]
|
question_title: [{ required: true, message: '请输入题目', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
const rulesStep2 = {
|
||||||
|
answer: [{ validator: validateAnswer, trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRichTextEmpty(html) {
|
||||||
|
const s = String(html || '')
|
||||||
|
const text = s
|
||||||
|
.replace(/<p><br\/?><\/p>/gi, '')
|
||||||
|
.replace(/<br\s*\/?>(\s| | )*?/gi, '')
|
||||||
|
.replace(/<[^>]*>/g, '')
|
||||||
|
.replace(/ | /g, ' ')
|
||||||
|
.trim()
|
||||||
|
return text.length === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTitleEditorCreated(editor) {
|
function handleTitleEditorCreated(editor) {
|
||||||
@ -256,7 +266,9 @@ function initForm() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
form.question_title = ''
|
form.question_title = ''
|
||||||
form.question_type = ''
|
// 默认从字典中选择“单选题”(dict_value==='1'),若无则取第一项;再退回 '1'
|
||||||
|
const preferred = typeOptions.value.find(o => String(o.dict_value) === '1') || typeOptions.value[0]
|
||||||
|
form.question_type = String(preferred?.dict_value ?? '1')
|
||||||
form.score = 2
|
form.score = 2
|
||||||
form.question_analysis = ''
|
form.question_analysis = ''
|
||||||
form.options = defaultOptions()
|
form.options = defaultOptions()
|
||||||
@ -279,7 +291,7 @@ async function handleNext() {
|
|||||||
ElMessage.error('请输入分值')
|
ElMessage.error('请输入分值')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!form.question_title) {
|
if (isRichTextEmpty(form.question_title)) {
|
||||||
ElMessage.error('请输入题目')
|
ElMessage.error('请输入题目')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -289,8 +301,9 @@ async function handleNext() {
|
|||||||
try {
|
try {
|
||||||
const res = await getExamQuestions({
|
const res = await getExamQuestions({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 1,
|
pageSize: 10,
|
||||||
keyword: form.question_title,
|
keyword: form.question_title,
|
||||||
|
min_rate: 50,
|
||||||
})
|
})
|
||||||
let items = []
|
let items = []
|
||||||
if (res && res.code === 0 && res.data && Array.isArray(res.data.list)) {
|
if (res && res.code === 0 && res.data && Array.isArray(res.data.list)) {
|
||||||
@ -299,30 +312,18 @@ async function handleNext() {
|
|||||||
items = res.data
|
items = res.data
|
||||||
}
|
}
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
// 前端根据题干与当前输入的相似度计算匹配度,存到 _match_rate 字段
|
// 使用后端返回的匹配结果(包含 _match_rate)
|
||||||
const currentTitle = String(form.question_title || '')
|
// 可选:按匹配度排序
|
||||||
duplicateList.value = items.map((it) => {
|
const sorted = items.slice().sort((a, b) => (Number(b._match_rate || 0) - Number(a._match_rate || 0)))
|
||||||
const title = String(it.question_title || '')
|
duplicateList.value = sorted
|
||||||
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
|
duplicateDialogVisible.value = true
|
||||||
// 让用户在列表中选择是否使用已存在题目或继续新增,不直接进入第二步
|
// 让用户在列表中选择是否使用已存在题目或继续新增,不直接进入第二步
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 查询失败不阻塞继续操作,仅在控制台记录
|
|
||||||
console.error('检查重复题目失败', e)
|
console.error('检查重复题目失败', e)
|
||||||
|
ElMessage.error('检查相似题目失败,请稍后重试')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -368,8 +369,8 @@ function handleClose() {
|
|||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit(continueNext) {
|
||||||
formRef.value.validate(async (ok) => {
|
formRefStep2.value.validate(async (ok) => {
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
@ -382,7 +383,13 @@ function handleSubmit() {
|
|||||||
answer: isMultipleType.value ? [...multiAnswer.value] : form.answer
|
answer: isMultipleType.value ? [...multiAnswer.value] : form.answer
|
||||||
}
|
}
|
||||||
if (props.bankId) payload.bank_id = props.bankId
|
if (props.bankId) payload.bank_id = props.bankId
|
||||||
emit('saved', payload)
|
emit('saved', payload, { continue: !!continueNext })
|
||||||
|
if (continueNext) {
|
||||||
|
initForm()
|
||||||
|
currentStep.value = 1
|
||||||
|
ElMessage.success('已保存,继续新增')
|
||||||
|
return
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('保存失败')
|
ElMessage.error('保存失败')
|
||||||
} finally {
|
} finally {
|
||||||
@ -391,10 +398,50 @@ function handleSubmit() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUseExisting(row) {
|
async function handleUseExisting(row) {
|
||||||
// 使用已存在题目:先关闭相似题目弹窗,再关闭当前新增弹窗
|
try {
|
||||||
duplicateDialogVisible.value = false
|
// 拉取题目详情
|
||||||
emit('close')
|
const res = await getExamQuestionDetail(row.id)
|
||||||
|
let data = null
|
||||||
|
if (res && res.code === 0 && res.data) data = res.data
|
||||||
|
else if (res && res.success && res.data) data = res.data
|
||||||
|
else data = row
|
||||||
|
|
||||||
|
const src = data || {}
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
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 || 0,
|
||||||
|
question_analysis: q.question_analysis || q.QuestionAnalysis || row.question_analysis || '',
|
||||||
|
options: Array.isArray(options)
|
||||||
|
? options.map((o, idx) => ({
|
||||||
|
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: o.content || o.option_content || o.OptionContent || '',
|
||||||
|
}))
|
||||||
|
: (row.options || []),
|
||||||
|
answer: answer !== null && answer !== undefined ? answer : (row.answer ?? ''),
|
||||||
|
}
|
||||||
|
if (props.bankId) payload.bank_id = props.bankId
|
||||||
|
|
||||||
|
await createExamQuestion(payload)
|
||||||
|
ElMessage.success('已拉取到当前题库')
|
||||||
|
duplicateDialogVisible.value = false
|
||||||
|
emit('close')
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('拉取失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContinueCreate() {
|
function handleContinueCreate() {
|
||||||
@ -430,29 +477,35 @@ onBeforeUnmount(() => {
|
|||||||
.form-flex {
|
.form-flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap:15px;
|
gap: 15px;
|
||||||
|
margin-top: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.options-header {
|
.options-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-list {
|
.option-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-item {
|
.option-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 48px 1fr auto;
|
grid-template-columns: 48px 1fr auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dlg-actions {
|
.dlg-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@ -465,6 +518,10 @@ onBeforeUnmount(() => {
|
|||||||
padding: 0 8px 8px;
|
padding: 0 8px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.editor-container) {
|
||||||
|
min-height: 200px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.duplicate-tip {
|
.duplicate-tip {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #666;
|
color: #666;
|
||||||
|
|||||||
@ -606,7 +606,7 @@ async function handleDelete(row) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaved(payload) {
|
async function handleSaved(payload, meta) {
|
||||||
try {
|
try {
|
||||||
const submit = { ...payload, question_type: normalizeType(payload.question_type) }
|
const submit = { ...payload, question_type: normalizeType(payload.question_type) }
|
||||||
if (props.bankId) submit.bank_id = props.bankId
|
if (props.bankId) submit.bank_id = props.bankId
|
||||||
@ -616,7 +616,10 @@ async function handleSaved(payload) {
|
|||||||
await createExamQuestion(submit)
|
await createExamQuestion(submit)
|
||||||
}
|
}
|
||||||
ElMessage.success('保存成功')
|
ElMessage.success('保存成功')
|
||||||
editVisible.value = false
|
// 继续新增时不关闭弹窗
|
||||||
|
if (!meta || !meta.continue) {
|
||||||
|
editVisible.value = false
|
||||||
|
}
|
||||||
fetchList()
|
fetchList()
|
||||||
emit('saved')
|
emit('saved')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -298,4 +298,8 @@ onMounted(() => {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-container {
|
||||||
|
min-height: 200px !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package controllers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"server/models"
|
"server/models"
|
||||||
"server/services"
|
"server/services"
|
||||||
@ -14,6 +16,65 @@ type ExamQuestionController struct {
|
|||||||
beego.Controller
|
beego.Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeText removes HTML tags, converts to space, and collapses whitespace
|
||||||
|
func normalizeText(s string) string {
|
||||||
|
str := strings.TrimSpace(s)
|
||||||
|
if str == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// remove HTML tags
|
||||||
|
re := regexp.MustCompile("<[^>]*>")
|
||||||
|
str = re.ReplaceAllString(str, "")
|
||||||
|
// decode common entities
|
||||||
|
str = strings.ReplaceAll(str, " ", " ")
|
||||||
|
str = strings.ReplaceAll(str, " ", " ")
|
||||||
|
// collapse whitespace
|
||||||
|
reSpace := regexp.MustCompile(`\s+`)
|
||||||
|
str = reSpace.ReplaceAllString(str, " ")
|
||||||
|
return strings.TrimSpace(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeMatchRate returns a simple similarity percentage between two strings based on
|
||||||
|
// position-wise identical characters over the max length, rounded to integer 0-100.
|
||||||
|
func computeMatchRate(a, b string) int {
|
||||||
|
s1 := normalizeText(a)
|
||||||
|
s2 := normalizeText(b)
|
||||||
|
if s1 == "" && s2 == "" {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
maxLen := len([]rune(s1))
|
||||||
|
if l := len([]rune(s2)); l > maxLen {
|
||||||
|
maxLen = l
|
||||||
|
}
|
||||||
|
if maxLen == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
r1 := []rune(s1)
|
||||||
|
r2 := []rune(s2)
|
||||||
|
same := 0
|
||||||
|
for i := 0; i < maxLen; i++ {
|
||||||
|
var c1, c2 rune
|
||||||
|
if i < len(r1) {
|
||||||
|
c1 = r1[i]
|
||||||
|
}
|
||||||
|
if i < len(r2) {
|
||||||
|
c2 = r2[i]
|
||||||
|
}
|
||||||
|
if c1 != 0 && c2 != 0 && c1 == c2 {
|
||||||
|
same++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// round to nearest int
|
||||||
|
rate := int(float64(same)/float64(maxLen)*100.0 + 0.5)
|
||||||
|
if rate < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if rate > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return rate
|
||||||
|
}
|
||||||
|
|
||||||
type ExamQuestionBankController struct {
|
type ExamQuestionBankController struct {
|
||||||
beego.Controller
|
beego.Controller
|
||||||
}
|
}
|
||||||
@ -22,9 +83,10 @@ type ExamQuestionBankController struct {
|
|||||||
// @router /exam-questions [get]
|
// @router /exam-questions [get]
|
||||||
func (c *ExamQuestionController) GetList() {
|
func (c *ExamQuestionController) GetList() {
|
||||||
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
|
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
|
||||||
keyword := c.GetString("keyword")
|
keyword := c.GetString("keyword")
|
||||||
typeStr := c.GetString("type")
|
typeStr := c.GetString("type")
|
||||||
bankId, _ := c.GetInt64("bank_id", 0)
|
bankId, _ := c.GetInt64("bank_id", 0)
|
||||||
|
minRate, _ := c.GetInt("min_rate", 0)
|
||||||
|
|
||||||
var qtype *int8
|
var qtype *int8
|
||||||
if typeStr != "" {
|
if typeStr != "" {
|
||||||
@ -37,32 +99,62 @@ func (c *ExamQuestionController) GetList() {
|
|||||||
page, _ := c.GetInt("page", 1)
|
page, _ := c.GetInt("page", 1)
|
||||||
pageSize, _ := c.GetInt("pageSize", 10)
|
pageSize, _ := c.GetInt("pageSize", 10)
|
||||||
|
|
||||||
list, total, err := services.GetExamQuestions(services.QuestionListParams{
|
// use normalized keyword for DB searching to improve hit rate when editor HTML is posted
|
||||||
TenantId: tenantId,
|
normKeyword := normalizeText(keyword)
|
||||||
Keyword: keyword,
|
list, total, err := services.GetExamQuestions(services.QuestionListParams{
|
||||||
QuestionType: qtype,
|
TenantId: tenantId,
|
||||||
BankId: bankId,
|
Keyword: normKeyword,
|
||||||
Page: page,
|
QuestionType: qtype,
|
||||||
PageSize: pageSize,
|
BankId: bankId,
|
||||||
})
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Data["json"] = map[string]interface{}{"code": 1, "message": "获取试题列表失败: " + err.Error(), "data": nil}
|
c.Data["json"] = map[string]interface{}{"code": 1, "message": "获取试题列表失败: " + err.Error(), "data": nil}
|
||||||
c.ServeJSON()
|
c.ServeJSON()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 当启用相似度筛选且按关键字无结果时,回退到不带关键字的最近记录中做相似度匹配
|
||||||
|
if minRate > 0 && strings.TrimSpace(normKeyword) != "" && len(list) == 0 {
|
||||||
|
fbPageSize := 50
|
||||||
|
fbList, _, fbErr := services.GetExamQuestions(services.QuestionListParams{
|
||||||
|
TenantId: tenantId,
|
||||||
|
Keyword: "",
|
||||||
|
BankId: bankId,
|
||||||
|
Page: 1,
|
||||||
|
PageSize: fbPageSize,
|
||||||
|
})
|
||||||
|
if fbErr == nil {
|
||||||
|
list = fbList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
items := make([]map[string]interface{}, 0, len(list))
|
items := make([]map[string]interface{}, 0, len(list))
|
||||||
|
currentTitle := normalizeText(keyword)
|
||||||
for _, q := range list {
|
for _, q := range list {
|
||||||
if q == nil {
|
if q == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
items = append(items, map[string]interface{}{
|
// 可选相似度计算与过滤
|
||||||
|
var rate *int
|
||||||
|
if currentTitle != "" {
|
||||||
|
v := computeMatchRate(currentTitle, q.QuestionTitle)
|
||||||
|
rate = &v
|
||||||
|
if minRate > 0 && v < minRate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item := map[string]interface{}{
|
||||||
"id": q.Id,
|
"id": q.Id,
|
||||||
"tenant_id": q.TenantId,
|
"tenant_id": q.TenantId,
|
||||||
"question_type": q.QuestionType,
|
"question_type": q.QuestionType,
|
||||||
"question_title": q.QuestionTitle,
|
"question_title": q.QuestionTitle,
|
||||||
"score": q.Score,
|
"score": q.Score,
|
||||||
})
|
}
|
||||||
|
if rate != nil {
|
||||||
|
item["_match_rate"] = *rate
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Data["json"] = map[string]interface{}{
|
c.Data["json"] = map[string]interface{}{
|
||||||
@ -160,7 +252,7 @@ func (c *ExamQuestionController) BatchCreate() {
|
|||||||
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
|
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
|
||||||
var payload struct {
|
var payload struct {
|
||||||
BankId int64 `json:"bank_id"`
|
BankId int64 `json:"bank_id"`
|
||||||
Items []struct {
|
Items []struct {
|
||||||
QuestionTitle string `json:"question_title"`
|
QuestionTitle string `json:"question_title"`
|
||||||
QuestionType int8 `json:"question_type"`
|
QuestionType int8 `json:"question_type"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
|
|||||||
@ -298,7 +298,7 @@ func init() {
|
|||||||
beego.Router("/api/knowledge/count", &controllers.KnowledgeController{}, "get:GetCount")
|
beego.Router("/api/knowledge/count", &controllers.KnowledgeController{}, "get:GetCount")
|
||||||
beego.Router("/api/knowledge/detail", &controllers.KnowledgeController{}, "get:Detail")
|
beego.Router("/api/knowledge/detail", &controllers.KnowledgeController{}, "get:Detail")
|
||||||
beego.Router("/api/knowledge/create", &controllers.KnowledgeController{}, "post:Create")
|
beego.Router("/api/knowledge/create", &controllers.KnowledgeController{}, "post:Create")
|
||||||
// ...
|
beego.Router("/api/knowledge/update", &controllers.KnowledgeController{}, "post:Update")
|
||||||
beego.Router("/api/knowledge/delete", &controllers.KnowledgeController{}, "post:Delete")
|
beego.Router("/api/knowledge/delete", &controllers.KnowledgeController{}, "post:Delete")
|
||||||
beego.Router("/api/knowledge/categories", &controllers.KnowledgeController{}, "get:GetCategories")
|
beego.Router("/api/knowledge/categories", &controllers.KnowledgeController{}, "get:GetCategories")
|
||||||
beego.Router("/api/knowledge/tags", &controllers.KnowledgeController{}, "get:GetTags")
|
beego.Router("/api/knowledge/tags", &controllers.KnowledgeController{}, "get:GetTags")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user