<\/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")