diff --git a/.gitignore b/.gitignore index 280b285..2615d53 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ dist-ssr *.local server/uploads server/conf/ +pc/dist.zip +pc/dist.7z +pc/dist # Editor directories and files .vscode/* diff --git a/pc/src/views/apps/exams/examinationQuestions/components/edit.vue b/pc/src/views/apps/exams/examinationQuestions/components/edit.vue index 8c5b507..d208ccb 100644 --- a/pc/src/views/apps/exams/examinationQuestions/components/edit.vue +++ b/pc/src/views/apps/exams/examinationQuestions/components/edit.vue @@ -6,18 +6,13 @@ - - - -
+ + +
- + @@ -27,49 +22,49 @@
+
- - - + @@ -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>/gi, '') + .replace(/(\s| | )*?/gi, '') + .replace(/<[^>]*>/g, '') + .replace(/ | /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; diff --git a/pc/src/views/apps/exams/examinationQuestions/components/examList.vue b/pc/src/views/apps/exams/examinationQuestions/components/examList.vue index aaa4bb7..5642395 100644 --- a/pc/src/views/apps/exams/examinationQuestions/components/examList.vue +++ b/pc/src/views/apps/exams/examinationQuestions/components/examList.vue @@ -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) { diff --git a/pc/src/views/apps/exams/examinationQuestions/index.vue b/pc/src/views/apps/exams/examinationQuestions/index.vue index 1087b7c..f9cf59e 100644 --- a/pc/src/views/apps/exams/examinationQuestions/index.vue +++ b/pc/src/views/apps/exams/examinationQuestions/index.vue @@ -298,4 +298,8 @@ onMounted(() => { justify-content: flex-end; } } + +.editor-container { + min-height: 200px !important; +} diff --git a/server/controllers/exam.go b/server/controllers/exam.go index 7bbbfc7..b3e59ab 100644 --- a/server/controllers/exam.go +++ b/server/controllers/exam.go @@ -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   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 { 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"` diff --git a/server/routers/router.go b/server/routers/router.go index 84caac3..0b3ec2b 100644 --- a/server/routers/router.go +++ b/server/routers/router.go @@ -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")