优化题目代码

This commit is contained in:
李志强 2025-11-19 16:07:43 +08:00
parent 5fddba8a30
commit 7664821d88
6 changed files with 263 additions and 104 deletions

3
.gitignore vendored
View File

@ -25,6 +25,9 @@ dist-ssr
*.local
server/uploads
server/conf/
pc/dist.zip
pc/dist.7z
pc/dist
# Editor directories and files
.vscode/*

View File

@ -6,18 +6,13 @@
<el-step title="选项与答案" />
</el-steps>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-divider />
<!-- 第一步题型分值题目 -->
<div v-if="currentStep === 1" class="form-flex">
<!-- 第一步表单基础信息 -->
<el-form v-if="currentStep === 1" ref="formRefStep1" :model="form" :rules="rulesStep1" label-width="100px">
<div class="form-flex">
<el-form-item label="题型" prop="question_type">
<el-select v-model="form.question_type" placeholder="请选择题型" style="width: 100%">
<el-option
v-for="opt in typeOptions"
:key="opt.dict_value"
:label="opt.dict_label"
:value="String(opt.dict_value)"
/>
<el-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">
@ -27,49 +22,49 @@
<WangEditor v-model="form.question_title" />
</el-form-item>
</div>
</el-form>
<!-- 第二步选项答案解析 -->
<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>
<!-- 第二步表单选项答案解析 -->
<el-form v-else ref="formRefStep2" :model="form" :rules="rulesStep2" label-width="100px" style="margin-top: 20px">
<template v-if="showOptions">
<div class="options-header">
<!-- <span>选项</span> -->
<div class="op">
<el-button v-if="canCustomizeOptions" type="primary" @click="addOption">新增选项</el-button>
</div>
<div class="option-list">
<div v-for="(opt, idx) in form.options" :key="opt.key" class="option-item">
<div class="label">{{ opt.label }}</div>
<el-input v-model="opt.content" placeholder="请输入选项内容" />
<el-button v-if="canCustomizeOptions" link type="danger" @click="removeOption(idx)">删除</el-button>
</div>
</div>
<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>
</template>
</div>
</template>
<template v-if="showAnswer">
<el-divider />
<el-form-item label="正确答案" prop="answer">
<template v-if="isSingleType">
<el-select v-model="form.answer" placeholder="请选择">
<el-option v-for="opt in form.options" :key="opt.key" :label="opt.label" :value="opt.label" />
</el-select>
</template>
<template v-else-if="isMultipleType">
<el-select v-model="multiAnswer" multiple placeholder="请选择">
<el-option v-for="opt in form.options" :key="opt.key" :label="opt.label" :value="opt.label" />
</el-select>
</template>
<template v-else>
<el-input v-model="form.answer" type="textarea" :rows="2" placeholder="请输入答案" />
</template>
</el-form-item>
</template>
<el-form-item label="解析">
<WangEditor v-model="form.question_analysis" />
<template v-if="showAnswer">
<el-divider />
<el-form-item label="正确答案" prop="answer">
<template v-if="isSingleType">
<el-radio-group v-model="form.answer">
<el-radio-button v-for="opt in form.options" :key="opt.key" :label="opt.label">{{ opt.label }}</el-radio-button>
</el-radio-group>
</template>
<template v-else-if="isMultipleType">
<el-checkbox-group v-model="multiAnswer">
<el-checkbox-button v-for="opt in form.options" :key="opt.key" :label="opt.label">{{ opt.label }}</el-checkbox-button>
</el-checkbox-group>
</template>
<template v-else>
<el-input v-model="form.answer" type="textarea" :rows="2" placeholder="请输入答案" />
</template>
</el-form-item>
</template>
<el-divider />
<el-form-item label="解析">
<WangEditor v-model="form.question_analysis" />
</el-form-item>
</el-form>
<template #footer>
@ -79,13 +74,14 @@
<template v-else>
<el-button @click="handlePrev">上一步</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>
</div>
</template>
</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>
<el-table :data="duplicateList" border style="margin-top: 12px">
<el-table-column label="匹配题目">
@ -98,17 +94,17 @@
{{ row._match_rate != null ? row._match_rate + '%' : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center">
<el-table-column label="操作" width="160" align="center">
<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>
</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>
<el-button @click="duplicateDialogVisible = false">返回修改</el-button>
</div>
</template>
</el-dialog>
@ -118,7 +114,7 @@
import { ref, reactive, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getDictItemsByCode } from '@/api/dict'
import { getExamQuestions } from '@/api/exam'
import { getExamQuestions, getExamQuestionDetail, createExamQuestion } from '@/api/exam'
import '@wangeditor/editor/dist/css/style.css'
const props = defineProps({
@ -130,7 +126,8 @@ const props = defineProps({
const emit = defineEmits(['close', 'saved'])
const formRef = ref()
const formRefStep1 = ref()
const formRefStep2 = ref()
const saving = ref(false)
const currentStep = ref(1) // 1: , 2:
const typeOptions = ref([])
@ -157,18 +154,31 @@ const defaultOptions = () => ([
const form = reactive({
question_title: '',
question_type: '',
question_type: '1',
score: 2,
question_analysis: '',
options: defaultOptions(),
answer: ''
})
const rules = {
question_title: [{ required: true, message: '请输入题目', trigger: 'blur' }],
const rulesStep1 = {
question_type: [{ 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|&nbsp;|&#160;)*?/gi, '')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;|&#160;/g, ' ')
.trim()
return text.length === 0
}
function handleTitleEditorCreated(editor) {
@ -256,7 +266,9 @@ function initForm() {
}
} else {
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.question_analysis = ''
form.options = defaultOptions()
@ -279,7 +291,7 @@ async function handleNext() {
ElMessage.error('请输入分值')
return
}
if (!form.question_title) {
if (isRichTextEmpty(form.question_title)) {
ElMessage.error('请输入题目')
return
}
@ -289,8 +301,9 @@ async function handleNext() {
try {
const res = await getExamQuestions({
page: 1,
pageSize: 1,
pageSize: 10,
keyword: form.question_title,
min_rate: 50,
})
let items = []
if (res && res.code === 0 && res.data && Array.isArray(res.data.list)) {
@ -299,30 +312,18 @@ async function handleNext() {
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 }
})
// 使 _match_rate
//
const sorted = items.slice().sort((a, b) => (Number(b._match_rate || 0) - Number(a._match_rate || 0)))
duplicateList.value = sorted
duplicateDialogVisible.value = true
// 使
return
}
} catch (e) {
//
console.error('检查重复题目失败', e)
ElMessage.error('检查相似题目失败,请稍后重试')
return
}
}
@ -368,8 +369,8 @@ function handleClose() {
emit('close')
}
function handleSubmit() {
formRef.value.validate(async (ok) => {
function handleSubmit(continueNext) {
formRefStep2.value.validate(async (ok) => {
if (!ok) return
saving.value = true
try {
@ -382,7 +383,13 @@ function handleSubmit() {
answer: isMultipleType.value ? [...multiAnswer.value] : form.answer
}
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) {
ElMessage.error('保存失败')
} finally {
@ -391,10 +398,50 @@ function handleSubmit() {
})
}
function handleUseExisting(row) {
// 使
duplicateDialogVisible.value = false
emit('close')
async function handleUseExisting(row) {
try {
//
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() {
@ -430,29 +477,35 @@ onBeforeUnmount(() => {
.form-flex {
display: flex;
flex-direction: column;
gap:15px;
gap: 15px;
margin-top: 15px;
}
.options-header {
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
margin-bottom: 8px;
margin-bottom: 15px;
}
.option-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-item {
display: grid;
grid-template-columns: 48px 1fr auto;
gap: 8px;
align-items: center;
}
.label {
width: 48px;
text-align: center;
}
.dlg-actions {
display: flex;
justify-content: flex-end;
@ -465,6 +518,10 @@ onBeforeUnmount(() => {
padding: 0 8px 8px;
}
:deep(.editor-container) {
min-height: 200px !important;
}
.duplicate-tip {
font-size: 13px;
color: #666;

View File

@ -606,7 +606,7 @@ async function handleDelete(row) {
}
}
async function handleSaved(payload) {
async function handleSaved(payload, meta) {
try {
const submit = { ...payload, question_type: normalizeType(payload.question_type) }
if (props.bankId) submit.bank_id = props.bankId
@ -616,7 +616,10 @@ async function handleSaved(payload) {
await createExamQuestion(submit)
}
ElMessage.success('保存成功')
editVisible.value = false
//
if (!meta || !meta.continue) {
editVisible.value = false
}
fetchList()
emit('saved')
} catch (e) {

View File

@ -298,4 +298,8 @@ onMounted(() => {
justify-content: flex-end;
}
}
.editor-container {
min-height: 200px !important;
}
</style>

View File

@ -3,6 +3,8 @@ package controllers
import (
"encoding/json"
"strconv"
"strings"
"regexp"
"server/models"
"server/services"
@ -14,6 +16,65 @@ type ExamQuestionController struct {
beego.Controller
}
// normalizeText removes HTML tags, converts &nbsp; 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, "&nbsp;", " ")
str = strings.ReplaceAll(str, "&#160;", " ")
// 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 {
beego.Controller
}
@ -22,9 +83,10 @@ type ExamQuestionBankController struct {
// @router /exam-questions [get]
func (c *ExamQuestionController) GetList() {
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
keyword := c.GetString("keyword")
keyword := c.GetString("keyword")
typeStr := c.GetString("type")
bankId, _ := c.GetInt64("bank_id", 0)
minRate, _ := c.GetInt("min_rate", 0)
var qtype *int8
if typeStr != "" {
@ -37,32 +99,62 @@ func (c *ExamQuestionController) GetList() {
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 10)
list, total, err := services.GetExamQuestions(services.QuestionListParams{
TenantId: tenantId,
Keyword: keyword,
QuestionType: qtype,
BankId: bankId,
Page: page,
PageSize: pageSize,
})
// use normalized keyword for DB searching to improve hit rate when editor HTML is posted
normKeyword := normalizeText(keyword)
list, total, err := services.GetExamQuestions(services.QuestionListParams{
TenantId: tenantId,
Keyword: normKeyword,
QuestionType: qtype,
BankId: bankId,
Page: page,
PageSize: pageSize,
})
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "获取试题列表失败: " + err.Error(), "data": nil}
c.ServeJSON()
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))
currentTitle := normalizeText(keyword)
for _, q := range list {
if q == nil {
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,
"tenant_id": q.TenantId,
"question_type": q.QuestionType,
"question_title": q.QuestionTitle,
"score": q.Score,
})
}
if rate != nil {
item["_match_rate"] = *rate
}
items = append(items, item)
}
c.Data["json"] = map[string]interface{}{
@ -160,7 +252,7 @@ func (c *ExamQuestionController) BatchCreate() {
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
var payload struct {
BankId int64 `json:"bank_id"`
Items []struct {
Items []struct {
QuestionTitle string `json:"question_title"`
QuestionType int8 `json:"question_type"`
Score float64 `json:"score"`

View File

@ -298,7 +298,7 @@ func init() {
beego.Router("/api/knowledge/count", &controllers.KnowledgeController{}, "get:GetCount")
beego.Router("/api/knowledge/detail", &controllers.KnowledgeController{}, "get:Detail")
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/categories", &controllers.KnowledgeController{}, "get:GetCategories")
beego.Router("/api/knowledge/tags", &controllers.KnowledgeController{}, "get:GetTags")