From dd2b1975169214f268a56f249d63e86618895117 Mon Sep 17 00:00:00 2001 From: yanlongqi Date: Tue, 4 Nov 2025 22:00:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A2=98=E5=BA=93=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=B3=BB=E7=BB=9F=EF=BC=9A=E5=AE=9E=E7=8E=B0=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=BC=96=E5=8F=B7=E3=80=81=E5=8A=A8=E6=80=81=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E5=92=8C=E7=AD=94=E6=A1=88=E5=9B=9E=E6=98=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **后端优化** - 实现题目编号自动生成机制,按题型连续编号 - 移除分页限制,返回所有题目 - 支持题型筛选和关键词搜索 - 题目按题型和编号排序 - DTO 中包含答案字段,支持编辑时回显 - 选项按字母顺序排序 **前端优化** - 移除手动输入题目ID,系统自动生成 - 实现动态表单,支持添加/删除选项和答案 - 添加题型筛选下拉框 - 添加搜索框,支持搜索题目内容和编号 - 优化答案回显逻辑,直接使用后端返回的答案数据 - 表格显示题目编号列 **修复问题** - 修复 PostgreSQL SQL 语法错误 - 修复编辑题目时答案无法正确回显的问题 - 修复题目列表不完整的问题 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- internal/handlers/practice_handler.go | 82 ++++--- internal/models/practice_question.go | 13 +- web/src/api/question.ts | 4 +- web/src/components/QuestionCard.tsx | 2 +- web/src/pages/QuestionManagement.tsx | 315 ++++++++++++++++++-------- web/src/types/question.ts | 1 + 6 files changed, 287 insertions(+), 130 deletions(-) diff --git a/internal/handlers/practice_handler.go b/internal/handlers/practice_handler.go index 7bf0f84..0f0802b 100644 --- a/internal/handlers/practice_handler.go +++ b/internal/handlers/practice_handler.go @@ -6,6 +6,7 @@ import ( "encoding/json" "log" "net/http" + "sort" "strconv" "time" @@ -15,16 +16,7 @@ import ( // GetPracticeQuestions 获取练习题目列表 func GetPracticeQuestions(c *gin.Context) { typeParam := c.Query("type") - category := c.Query("category") - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100")) - - if page < 1 { - page = 1 - } - if pageSize < 1 || pageSize > 100 { - pageSize = 100 - } + searchQuery := c.Query("search") db := database.GetDB() var questions []models.PracticeQuestion @@ -32,22 +24,22 @@ func GetPracticeQuestions(c *gin.Context) { query := db.Model(&models.PracticeQuestion{}) - // 根据题型过滤 - 直接使用前端传来的type + // 根据题型过滤 if typeParam != "" { query = query.Where("type = ?", typeParam) } - // 根据分类过滤 - if category != "" { - query = query.Where("type_name = ?", category) + // 根据搜索关键词过滤(搜索题目内容或题目编号) + if searchQuery != "" { + query = query.Where("question LIKE ? OR question_id LIKE ?", "%"+searchQuery+"%", "%"+searchQuery+"%") } // 获取总数 query.Count(&total) - // 分页查询 - offset := (page - 1) * pageSize - err := query.Offset(offset).Limit(pageSize).Find(&questions).Error + // 查询所有题目 - 按题型和题目编号升序排序 + // 先将 question_id 转为文本,提取数字部分,再转为整数排序 + err := query.Order("type ASC, CAST(COALESCE(NULLIF(REGEXP_REPLACE(question_id::text, '[^0-9]', '', 'g'), ''), '0') AS INTEGER) ASC").Find(&questions).Error if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success": false, @@ -337,6 +329,12 @@ func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO { Options: []models.Option{}, } + // 解析答案数据 + var answer interface{} + if err := json.Unmarshal([]byte(question.AnswerData), &answer); err == nil { + dto.Answer = answer + } + // 判断题自动生成选项 if question.Type == "true-false" { dto.Options = []models.Option{ @@ -350,11 +348,19 @@ func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO { if question.OptionsData != "" { var optionsMap map[string]string if err := json.Unmarshal([]byte(question.OptionsData), &optionsMap); err == nil { - // 将map转换为Option数组 - for key, value := range optionsMap { + // 将map转换为Option数组,并按key排序 + keys := make([]string, 0, len(optionsMap)) + for key := range optionsMap { + keys = append(keys, key) + } + // 对keys进行排序 + sort.Strings(keys) + + // 按排序后的key顺序添加选项 + for _, key := range keys { dto.Options = append(dto.Options, models.Option{ Key: key, - Value: value, + Value: optionsMap[key], }) } } @@ -437,7 +443,6 @@ func GetStatistics(c *gin.Context) { // CreatePracticeQuestion 创建新的练习题目 func CreatePracticeQuestion(c *gin.Context) { var req struct { - QuestionID string `json:"question_id" binding:"required"` Type string `json:"type" binding:"required"` TypeName string `json:"type_name"` Question string `json:"question" binding:"required"` @@ -453,6 +458,32 @@ func CreatePracticeQuestion(c *gin.Context) { return } + db := database.GetDB() + + // 自动生成题目编号:找到该题型的最大编号并+1 + var maxQuestionID string + err := db.Model(&models.PracticeQuestion{}). + Where("type = ?", req.Type). + Select("question_id"). + Order("CAST(COALESCE(NULLIF(REGEXP_REPLACE(question_id::text, '[^0-9]', '', 'g'), ''), '0') AS INTEGER) DESC"). + Limit(1). + Pluck("question_id", &maxQuestionID).Error + + // 生成新的题目编号 + var newQuestionID string + if err != nil || maxQuestionID == "" { + // 没有找到该题型的题目,从1开始 + newQuestionID = "1" + } else { + // 从最大编号中提取数字并+1 + var maxNum int + _, scanErr := strconv.Atoi(maxQuestionID) + if scanErr == nil { + maxNum, _ = strconv.Atoi(maxQuestionID) + } + newQuestionID = strconv.Itoa(maxNum + 1) + } + // 将答案序列化为JSON字符串 answerData, err := json.Marshal(req.Answer) if err != nil { @@ -478,7 +509,7 @@ func CreatePracticeQuestion(c *gin.Context) { } question := models.PracticeQuestion{ - QuestionID: req.QuestionID, + QuestionID: newQuestionID, Type: req.Type, TypeName: req.TypeName, Question: req.Question, @@ -486,7 +517,6 @@ func CreatePracticeQuestion(c *gin.Context) { OptionsData: optionsData, } - db := database.GetDB() if err := db.Create(&question).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success": false, @@ -517,7 +547,6 @@ func UpdatePracticeQuestion(c *gin.Context) { } var req struct { - QuestionID string `json:"question_id"` Type string `json:"type"` TypeName string `json:"type_name"` Question string `json:"question"` @@ -545,10 +574,7 @@ func UpdatePracticeQuestion(c *gin.Context) { return } - // 更新字段 - if req.QuestionID != "" { - question.QuestionID = req.QuestionID - } + // 更新字段(注意:不允许修改 QuestionID,由系统自动生成) if req.Type != "" { question.Type = req.Type } diff --git a/internal/models/practice_question.go b/internal/models/practice_question.go index 2c7ceb0..9083a82 100644 --- a/internal/models/practice_question.go +++ b/internal/models/practice_question.go @@ -20,12 +20,13 @@ func (PracticeQuestion) TableName() string { // PracticeQuestionDTO 用于前端返回的数据传输对象 type PracticeQuestionDTO struct { - ID uint `json:"id"` // 数据库自增ID - QuestionID string `json:"question_id"` // 题目编号(原JSON中的id) - Type string `json:"type"` // 前端使用的简化类型: single, multiple, judge, fill - Content string `json:"content"` // 题目内容 - Options []Option `json:"options"` // 选择题选项数组 - Category string `json:"category"` // 题目分类 + ID uint `json:"id"` // 数据库自增ID + QuestionID string `json:"question_id"` // 题目编号(原JSON中的id) + Type string `json:"type"` // 前端使用的简化类型: single, multiple, judge, fill + Content string `json:"content"` // 题目内容 + Options []Option `json:"options"` // 选择题选项数组 + Category string `json:"category"` // 题目分类 + Answer interface{} `json:"answer"` // 正确答案(用于题目管理编辑) } // PracticeAnswerSubmit 练习题答案提交 diff --git a/web/src/api/question.ts b/web/src/api/question.ts index e911eaf..2d20178 100644 --- a/web/src/api/question.ts +++ b/web/src/api/question.ts @@ -2,7 +2,7 @@ import { request } from '../utils/request' import type { Question, SubmitAnswer, AnswerResult, Statistics, ApiResponse, WrongQuestion, WrongQuestionStats } from '../types/question' // 获取题目列表 -export const getQuestions = (params?: { type?: string; category?: string }) => { +export const getQuestions = (params?: { type?: string; search?: string }) => { return request.get>('/practice/questions', { params }) } @@ -71,7 +71,6 @@ export const clearWrongQuestions = () => { // 创建题目 export const createQuestion = (data: { - question_id: string type: string type_name?: string question: string @@ -83,7 +82,6 @@ export const createQuestion = (data: { // 更新题目 export const updateQuestion = (id: number, data: { - question_id?: string type?: string type_name?: string question?: string diff --git a/web/src/components/QuestionCard.tsx b/web/src/components/QuestionCard.tsx index 3dc353e..1560e20 100644 --- a/web/src/components/QuestionCard.tsx +++ b/web/src/components/QuestionCard.tsx @@ -144,7 +144,7 @@ const QuestionCard: React.FC = ({ - 第 {question.question_id || question.id} 题 + 第 {question.question_id} 题 {question.category} diff --git a/web/src/pages/QuestionManagement.tsx b/web/src/pages/QuestionManagement.tsx index bff342a..c0d6b7f 100644 --- a/web/src/pages/QuestionManagement.tsx +++ b/web/src/pages/QuestionManagement.tsx @@ -13,7 +13,6 @@ import { Tag, Card, Radio, - InputNumber, Divider, } from 'antd' import { @@ -21,6 +20,7 @@ import { EditOutlined, DeleteOutlined, ArrowLeftOutlined, + MinusCircleOutlined, } from '@ant-design/icons' import * as questionApi from '../api/question' import type { Question } from '../types/question' @@ -46,11 +46,22 @@ const QuestionManagement: React.FC = () => { const [editingQuestion, setEditingQuestion] = useState(null) const [form] = Form.useForm() + // 筛选和搜索状态 + const [selectedType, setSelectedType] = useState('') + const [searchText, setSearchText] = useState('') + // 加载题目列表 const loadQuestions = async () => { setLoading(true) try { - const res = await questionApi.getQuestions() + const params: any = {} + if (selectedType) { + params.type = selectedType + } + if (searchText) { + params.search = searchText + } + const res = await questionApi.getQuestions(params) if (res.success && res.data) { setQuestions(res.data) } @@ -63,46 +74,29 @@ const QuestionManagement: React.FC = () => { useEffect(() => { loadQuestions() - }, []) + }, [selectedType, searchText]) // 打开新建/编辑弹窗 const handleOpenModal = (question?: Question) => { if (question) { setEditingQuestion(question) - // 解析答案 - let answerValue: any - if (question.type === 'multiple-selection') { - // 多选题答案是数组 - answerValue = Array.isArray(question.options?.[0]?.key) - ? question.options.map(opt => opt.key).join(',') - : '' - } else if (question.type === 'fill-in-blank') { - // 填空题答案是数组 - answerValue = Array.isArray(question.options?.[0]?.key) - ? question.options.map(opt => opt.key).join(',') - : '' - } else { - // 其他题型直接取第一个选项的key - answerValue = question.options?.[0]?.key - } + // 直接使用后端返回的答案数据 + let answerValue: any = question.answer - // 解析选项 - let optionsValue: string | undefined + // 解析选项(单选题和多选题) + let optionsValue: Array<{ key: string; value: string }> = [] if (question.options && question.options.length > 0 && (question.type === 'multiple-choice' || question.type === 'multiple-selection')) { - const optionsObj = question.options.reduce((acc, opt) => { - acc[opt.key] = opt.value - return acc - }, {} as Record) - optionsValue = JSON.stringify(optionsObj, null, 2) + optionsValue = question.options.map(opt => ({ + key: opt.key, + value: opt.value, + })) } // 设置表单值 form.setFieldsValue({ - question_id: question.question_id, type: question.type, - category: question.category, content: question.content, answer: answerValue, options: optionsValue, @@ -110,6 +104,11 @@ const QuestionManagement: React.FC = () => { } else { setEditingQuestion(null) form.resetFields() + // 新建时设置默认值 + form.setFieldsValue({ + type: 'multiple-choice', + options: [{ key: 'A', value: '' }, { key: 'B', value: '' }], + }) } setModalVisible(true) } @@ -134,10 +133,10 @@ const QuestionManagement: React.FC = () => { answer = values.answer } else if (values.type === 'multiple-selection') { // 多选题答案是数组 - answer = values.answer.split(',').map((s: string) => s.trim()) + answer = values.answer } else if (values.type === 'fill-in-blank') { // 填空题答案是数组 - answer = values.answer.split(',').map((s: string) => s.trim()) + answer = values.answer } else if (values.type === 'short-answer') { answer = values.answer } else { @@ -147,19 +146,19 @@ const QuestionManagement: React.FC = () => { // 解析选项(仅选择题和多选题需要) let options: Record | undefined if (values.options && (values.type === 'multiple-choice' || values.type === 'multiple-selection')) { - try { - options = typeof values.options === 'string' ? JSON.parse(values.options) : values.options - } catch (e) { - message.error('选项格式错误,请使用正确的JSON格式') - return - } + // 将数组格式转换为对象格式 { "A": "选项A", "B": "选项B" } + options = values.options.reduce((acc: Record, opt: any) => { + if (opt && opt.key && opt.value) { + acc[opt.key] = opt.value + } + return acc + }, {}) } // 构建请求数据 const data = { - question_id: values.question_id, type: values.type, - type_name: values.category, + type_name: '', // 不再使用分类字段 question: values.content, answer: answer, options: options, @@ -197,10 +196,10 @@ const QuestionManagement: React.FC = () => { // 表格列定义 const columns = [ { - title: '题目ID', + title: '题目编号', dataIndex: 'question_id', key: 'question_id', - width: 120, + width: 100, }, { title: '题型', @@ -219,12 +218,6 @@ const QuestionManagement: React.FC = () => { return {typeConfig?.label || type} }, }, - { - title: '分类', - dataIndex: 'category', - key: 'category', - width: 150, - }, { title: '题目内容', dataIndex: 'content', @@ -260,8 +253,8 @@ const QuestionManagement: React.FC = () => { }, ] - // 根据题型动态渲染答案输入 - const renderAnswerInput = () => { + // 根据题型动态渲染表单项 + const renderFormByType = () => { const type = form.getFieldValue('type') switch (type) { @@ -282,20 +275,56 @@ const QuestionManagement: React.FC = () => { case 'multiple-choice': return ( <> - { + if (!options || options.length < 2) { + return Promise.reject(new Error('至少需要2个选项')) + } + }, + }, + ]} > - - + {(fields, { add, remove }, { errors }) => ( + <> + + {fields.map((field, index) => ( + + + + + + + + {fields.length > 2 && ( + remove(field.name)} /> + )} + + ))} + + + + + )} + @@ -305,38 +334,103 @@ const QuestionManagement: React.FC = () => { case 'multiple-selection': return ( <> - { + if (!options || options.length < 2) { + return Promise.reject(new Error('至少需要2个选项')) + } + }, + }, + ]} > - - + {(fields, { add, remove }, { errors }) => ( + <> + + {fields.map((field, index) => ( + + + + + + + + {fields.length > 2 && ( + remove(field.name)} /> + )} + + ))} + + + + + )} + - + + + {fields.length > 1 && ( + remove(field.name)} /> + )} + + ))} + + + + + )} + ) case 'short-answer': @@ -346,7 +440,7 @@ const QuestionManagement: React.FC = () => { name="answer" rules={[{ required: true, message: '请输入答案' }]} > - +