优化题库管理系统:实现自动编号、动态表单和答案回显
**后端优化** - 实现题目编号自动生成机制,按题型连续编号 - 移除分页限制,返回所有题目 - 支持题型筛选和关键词搜索 - 题目按题型和编号排序 - DTO 中包含答案字段,支持编辑时回显 - 选项按字母顺序排序 **前端优化** - 移除手动输入题目ID,系统自动生成 - 实现动态表单,支持添加/删除选项和答案 - 添加题型筛选下拉框 - 添加搜索框,支持搜索题目内容和编号 - 优化答案回显逻辑,直接使用后端返回的答案数据 - 表格显示题目编号列 **修复问题** - 修复 PostgreSQL SQL 语法错误 - 修复编辑题目时答案无法正确回显的问题 - 修复题目列表不完整的问题 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9e37cf8225
commit
dd2b197516
@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -15,16 +16,7 @@ import (
|
|||||||
// GetPracticeQuestions 获取练习题目列表
|
// GetPracticeQuestions 获取练习题目列表
|
||||||
func GetPracticeQuestions(c *gin.Context) {
|
func GetPracticeQuestions(c *gin.Context) {
|
||||||
typeParam := c.Query("type")
|
typeParam := c.Query("type")
|
||||||
category := c.Query("category")
|
searchQuery := c.Query("search")
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var questions []models.PracticeQuestion
|
var questions []models.PracticeQuestion
|
||||||
@ -32,22 +24,22 @@ func GetPracticeQuestions(c *gin.Context) {
|
|||||||
|
|
||||||
query := db.Model(&models.PracticeQuestion{})
|
query := db.Model(&models.PracticeQuestion{})
|
||||||
|
|
||||||
// 根据题型过滤 - 直接使用前端传来的type
|
// 根据题型过滤
|
||||||
if typeParam != "" {
|
if typeParam != "" {
|
||||||
query = query.Where("type = ?", typeParam)
|
query = query.Where("type = ?", typeParam)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据分类过滤
|
// 根据搜索关键词过滤(搜索题目内容或题目编号)
|
||||||
if category != "" {
|
if searchQuery != "" {
|
||||||
query = query.Where("type_name = ?", category)
|
query = query.Where("question LIKE ? OR question_id LIKE ?", "%"+searchQuery+"%", "%"+searchQuery+"%")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取总数
|
// 获取总数
|
||||||
query.Count(&total)
|
query.Count(&total)
|
||||||
|
|
||||||
// 分页查询
|
// 查询所有题目 - 按题型和题目编号升序排序
|
||||||
offset := (page - 1) * pageSize
|
// 先将 question_id 转为文本,提取数字部分,再转为整数排序
|
||||||
err := query.Offset(offset).Limit(pageSize).Find(&questions).Error
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@ -337,6 +329,12 @@ func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO {
|
|||||||
Options: []models.Option{},
|
Options: []models.Option{},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解析答案数据
|
||||||
|
var answer interface{}
|
||||||
|
if err := json.Unmarshal([]byte(question.AnswerData), &answer); err == nil {
|
||||||
|
dto.Answer = answer
|
||||||
|
}
|
||||||
|
|
||||||
// 判断题自动生成选项
|
// 判断题自动生成选项
|
||||||
if question.Type == "true-false" {
|
if question.Type == "true-false" {
|
||||||
dto.Options = []models.Option{
|
dto.Options = []models.Option{
|
||||||
@ -350,11 +348,19 @@ func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO {
|
|||||||
if question.OptionsData != "" {
|
if question.OptionsData != "" {
|
||||||
var optionsMap map[string]string
|
var optionsMap map[string]string
|
||||||
if err := json.Unmarshal([]byte(question.OptionsData), &optionsMap); err == nil {
|
if err := json.Unmarshal([]byte(question.OptionsData), &optionsMap); err == nil {
|
||||||
// 将map转换为Option数组
|
// 将map转换为Option数组,并按key排序
|
||||||
for key, value := range optionsMap {
|
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{
|
dto.Options = append(dto.Options, models.Option{
|
||||||
Key: key,
|
Key: key,
|
||||||
Value: value,
|
Value: optionsMap[key],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -437,7 +443,6 @@ func GetStatistics(c *gin.Context) {
|
|||||||
// CreatePracticeQuestion 创建新的练习题目
|
// CreatePracticeQuestion 创建新的练习题目
|
||||||
func CreatePracticeQuestion(c *gin.Context) {
|
func CreatePracticeQuestion(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
QuestionID string `json:"question_id" binding:"required"`
|
|
||||||
Type string `json:"type" binding:"required"`
|
Type string `json:"type" binding:"required"`
|
||||||
TypeName string `json:"type_name"`
|
TypeName string `json:"type_name"`
|
||||||
Question string `json:"question" binding:"required"`
|
Question string `json:"question" binding:"required"`
|
||||||
@ -453,6 +458,32 @@ func CreatePracticeQuestion(c *gin.Context) {
|
|||||||
return
|
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字符串
|
// 将答案序列化为JSON字符串
|
||||||
answerData, err := json.Marshal(req.Answer)
|
answerData, err := json.Marshal(req.Answer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -478,7 +509,7 @@ func CreatePracticeQuestion(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
question := models.PracticeQuestion{
|
question := models.PracticeQuestion{
|
||||||
QuestionID: req.QuestionID,
|
QuestionID: newQuestionID,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
TypeName: req.TypeName,
|
TypeName: req.TypeName,
|
||||||
Question: req.Question,
|
Question: req.Question,
|
||||||
@ -486,7 +517,6 @@ func CreatePracticeQuestion(c *gin.Context) {
|
|||||||
OptionsData: optionsData,
|
OptionsData: optionsData,
|
||||||
}
|
}
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
if err := db.Create(&question).Error; err != nil {
|
if err := db.Create(&question).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@ -517,7 +547,6 @@ func UpdatePracticeQuestion(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
QuestionID string `json:"question_id"`
|
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
TypeName string `json:"type_name"`
|
TypeName string `json:"type_name"`
|
||||||
Question string `json:"question"`
|
Question string `json:"question"`
|
||||||
@ -545,10 +574,7 @@ func UpdatePracticeQuestion(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新字段
|
// 更新字段(注意:不允许修改 QuestionID,由系统自动生成)
|
||||||
if req.QuestionID != "" {
|
|
||||||
question.QuestionID = req.QuestionID
|
|
||||||
}
|
|
||||||
if req.Type != "" {
|
if req.Type != "" {
|
||||||
question.Type = req.Type
|
question.Type = req.Type
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,12 +20,13 @@ func (PracticeQuestion) TableName() string {
|
|||||||
|
|
||||||
// PracticeQuestionDTO 用于前端返回的数据传输对象
|
// PracticeQuestionDTO 用于前端返回的数据传输对象
|
||||||
type PracticeQuestionDTO struct {
|
type PracticeQuestionDTO struct {
|
||||||
ID uint `json:"id"` // 数据库自增ID
|
ID uint `json:"id"` // 数据库自增ID
|
||||||
QuestionID string `json:"question_id"` // 题目编号(原JSON中的id)
|
QuestionID string `json:"question_id"` // 题目编号(原JSON中的id)
|
||||||
Type string `json:"type"` // 前端使用的简化类型: single, multiple, judge, fill
|
Type string `json:"type"` // 前端使用的简化类型: single, multiple, judge, fill
|
||||||
Content string `json:"content"` // 题目内容
|
Content string `json:"content"` // 题目内容
|
||||||
Options []Option `json:"options"` // 选择题选项数组
|
Options []Option `json:"options"` // 选择题选项数组
|
||||||
Category string `json:"category"` // 题目分类
|
Category string `json:"category"` // 题目分类
|
||||||
|
Answer interface{} `json:"answer"` // 正确答案(用于题目管理编辑)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PracticeAnswerSubmit 练习题答案提交
|
// PracticeAnswerSubmit 练习题答案提交
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { request } from '../utils/request'
|
|||||||
import type { Question, SubmitAnswer, AnswerResult, Statistics, ApiResponse, WrongQuestion, WrongQuestionStats } from '../types/question'
|
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<ApiResponse<Question[]>>('/practice/questions', { params })
|
return request.get<ApiResponse<Question[]>>('/practice/questions', { params })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,7 +71,6 @@ export const clearWrongQuestions = () => {
|
|||||||
|
|
||||||
// 创建题目
|
// 创建题目
|
||||||
export const createQuestion = (data: {
|
export const createQuestion = (data: {
|
||||||
question_id: string
|
|
||||||
type: string
|
type: string
|
||||||
type_name?: string
|
type_name?: string
|
||||||
question: string
|
question: string
|
||||||
@ -83,7 +82,6 @@ export const createQuestion = (data: {
|
|||||||
|
|
||||||
// 更新题目
|
// 更新题目
|
||||||
export const updateQuestion = (id: number, data: {
|
export const updateQuestion = (id: number, data: {
|
||||||
question_id?: string
|
|
||||||
type?: string
|
type?: string
|
||||||
type_name?: string
|
type_name?: string
|
||||||
question?: string
|
question?: string
|
||||||
|
|||||||
@ -144,7 +144,7 @@ const QuestionCard: React.FC<QuestionCardProps> = ({
|
|||||||
<Card className={styles.questionCard}>
|
<Card className={styles.questionCard}>
|
||||||
<Space size="small" style={{ marginBottom: 16, alignItems: 'center' }}>
|
<Space size="small" style={{ marginBottom: 16, alignItems: 'center' }}>
|
||||||
<Title level={5} style={{ margin: 0, display: 'inline' }}>
|
<Title level={5} style={{ margin: 0, display: 'inline' }}>
|
||||||
第 {question.question_id || question.id} 题
|
第 {question.question_id} 题
|
||||||
</Title>
|
</Title>
|
||||||
<Tag color="blue">{question.category}</Tag>
|
<Tag color="blue">{question.category}</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Card,
|
Card,
|
||||||
Radio,
|
Radio,
|
||||||
InputNumber,
|
|
||||||
Divider,
|
Divider,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import {
|
import {
|
||||||
@ -21,6 +20,7 @@ import {
|
|||||||
EditOutlined,
|
EditOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
|
MinusCircleOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import * as questionApi from '../api/question'
|
import * as questionApi from '../api/question'
|
||||||
import type { Question } from '../types/question'
|
import type { Question } from '../types/question'
|
||||||
@ -46,11 +46,22 @@ const QuestionManagement: React.FC = () => {
|
|||||||
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null)
|
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null)
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
// 筛选和搜索状态
|
||||||
|
const [selectedType, setSelectedType] = useState<string>('')
|
||||||
|
const [searchText, setSearchText] = useState<string>('')
|
||||||
|
|
||||||
// 加载题目列表
|
// 加载题目列表
|
||||||
const loadQuestions = async () => {
|
const loadQuestions = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
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) {
|
if (res.success && res.data) {
|
||||||
setQuestions(res.data)
|
setQuestions(res.data)
|
||||||
}
|
}
|
||||||
@ -63,46 +74,29 @@ const QuestionManagement: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadQuestions()
|
loadQuestions()
|
||||||
}, [])
|
}, [selectedType, searchText])
|
||||||
|
|
||||||
// 打开新建/编辑弹窗
|
// 打开新建/编辑弹窗
|
||||||
const handleOpenModal = (question?: Question) => {
|
const handleOpenModal = (question?: Question) => {
|
||||||
if (question) {
|
if (question) {
|
||||||
setEditingQuestion(question)
|
setEditingQuestion(question)
|
||||||
|
|
||||||
// 解析答案
|
// 直接使用后端返回的答案数据
|
||||||
let answerValue: any
|
let answerValue: any = question.answer
|
||||||
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 optionsValue: string | undefined
|
let optionsValue: Array<{ key: string; value: string }> = []
|
||||||
if (question.options && question.options.length > 0 &&
|
if (question.options && question.options.length > 0 &&
|
||||||
(question.type === 'multiple-choice' || question.type === 'multiple-selection')) {
|
(question.type === 'multiple-choice' || question.type === 'multiple-selection')) {
|
||||||
const optionsObj = question.options.reduce((acc, opt) => {
|
optionsValue = question.options.map(opt => ({
|
||||||
acc[opt.key] = opt.value
|
key: opt.key,
|
||||||
return acc
|
value: opt.value,
|
||||||
}, {} as Record<string, string>)
|
}))
|
||||||
optionsValue = JSON.stringify(optionsObj, null, 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置表单值
|
// 设置表单值
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
question_id: question.question_id,
|
|
||||||
type: question.type,
|
type: question.type,
|
||||||
category: question.category,
|
|
||||||
content: question.content,
|
content: question.content,
|
||||||
answer: answerValue,
|
answer: answerValue,
|
||||||
options: optionsValue,
|
options: optionsValue,
|
||||||
@ -110,6 +104,11 @@ const QuestionManagement: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
setEditingQuestion(null)
|
setEditingQuestion(null)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
|
// 新建时设置默认值
|
||||||
|
form.setFieldsValue({
|
||||||
|
type: 'multiple-choice',
|
||||||
|
options: [{ key: 'A', value: '' }, { key: 'B', value: '' }],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
setModalVisible(true)
|
setModalVisible(true)
|
||||||
}
|
}
|
||||||
@ -134,10 +133,10 @@ const QuestionManagement: React.FC = () => {
|
|||||||
answer = values.answer
|
answer = values.answer
|
||||||
} else if (values.type === 'multiple-selection') {
|
} 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') {
|
} 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') {
|
} else if (values.type === 'short-answer') {
|
||||||
answer = values.answer
|
answer = values.answer
|
||||||
} else {
|
} else {
|
||||||
@ -147,19 +146,19 @@ const QuestionManagement: React.FC = () => {
|
|||||||
// 解析选项(仅选择题和多选题需要)
|
// 解析选项(仅选择题和多选题需要)
|
||||||
let options: Record<string, string> | undefined
|
let options: Record<string, string> | undefined
|
||||||
if (values.options && (values.type === 'multiple-choice' || values.type === 'multiple-selection')) {
|
if (values.options && (values.type === 'multiple-choice' || values.type === 'multiple-selection')) {
|
||||||
try {
|
// 将数组格式转换为对象格式 { "A": "选项A", "B": "选项B" }
|
||||||
options = typeof values.options === 'string' ? JSON.parse(values.options) : values.options
|
options = values.options.reduce((acc: Record<string, string>, opt: any) => {
|
||||||
} catch (e) {
|
if (opt && opt.key && opt.value) {
|
||||||
message.error('选项格式错误,请使用正确的JSON格式')
|
acc[opt.key] = opt.value
|
||||||
return
|
}
|
||||||
}
|
return acc
|
||||||
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建请求数据
|
// 构建请求数据
|
||||||
const data = {
|
const data = {
|
||||||
question_id: values.question_id,
|
|
||||||
type: values.type,
|
type: values.type,
|
||||||
type_name: values.category,
|
type_name: '', // 不再使用分类字段
|
||||||
question: values.content,
|
question: values.content,
|
||||||
answer: answer,
|
answer: answer,
|
||||||
options: options,
|
options: options,
|
||||||
@ -197,10 +196,10 @@ const QuestionManagement: React.FC = () => {
|
|||||||
// 表格列定义
|
// 表格列定义
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '题目ID',
|
title: '题目编号',
|
||||||
dataIndex: 'question_id',
|
dataIndex: 'question_id',
|
||||||
key: 'question_id',
|
key: 'question_id',
|
||||||
width: 120,
|
width: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '题型',
|
title: '题型',
|
||||||
@ -219,12 +218,6 @@ const QuestionManagement: React.FC = () => {
|
|||||||
return <Tag color={colorMap[type]}>{typeConfig?.label || type}</Tag>
|
return <Tag color={colorMap[type]}>{typeConfig?.label || type}</Tag>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '分类',
|
|
||||||
dataIndex: 'category',
|
|
||||||
key: 'category',
|
|
||||||
width: 150,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '题目内容',
|
title: '题目内容',
|
||||||
dataIndex: 'content',
|
dataIndex: 'content',
|
||||||
@ -260,8 +253,8 @@ const QuestionManagement: React.FC = () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// 根据题型动态渲染答案输入
|
// 根据题型动态渲染表单项
|
||||||
const renderAnswerInput = () => {
|
const renderFormByType = () => {
|
||||||
const type = form.getFieldValue('type')
|
const type = form.getFieldValue('type')
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@ -282,20 +275,56 @@ const QuestionManagement: React.FC = () => {
|
|||||||
case 'multiple-choice':
|
case 'multiple-choice':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.Item
|
<Form.List
|
||||||
label="选项"
|
|
||||||
name="options"
|
name="options"
|
||||||
rules={[{ required: true, message: '请输入选项' }]}
|
rules={[
|
||||||
|
{
|
||||||
|
validator: async (_, options) => {
|
||||||
|
if (!options || options.length < 2) {
|
||||||
|
return Promise.reject(new Error('至少需要2个选项'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Input.TextArea
|
{(fields, { add, remove }, { errors }) => (
|
||||||
placeholder='JSON格式,例如: {"A":"选项A","B":"选项B","C":"选项C","D":"选项D"}'
|
<>
|
||||||
rows={4}
|
<Form.Item label="选项" required>
|
||||||
/>
|
{fields.map((field, index) => (
|
||||||
</Form.Item>
|
<Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
name={[field.name, 'key']}
|
||||||
|
rules={[{ required: true, message: '请输入选项键' }]}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Input placeholder="A" style={{ width: 60 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
name={[field.name, 'value']}
|
||||||
|
rules={[{ required: true, message: '请输入选项内容' }]}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Input placeholder="选项内容" style={{ width: 400 }} />
|
||||||
|
</Form.Item>
|
||||||
|
{fields.length > 2 && (
|
||||||
|
<MinusCircleOutlined onClick={() => remove(field.name)} />
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Form.ErrorList errors={errors} />
|
||||||
|
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||||
|
添加选项
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="正确答案"
|
label="正确答案"
|
||||||
name="answer"
|
name="answer"
|
||||||
rules={[{ required: true, message: '请输入答案' }]}
|
rules={[{ required: true, message: '请选择答案' }]}
|
||||||
>
|
>
|
||||||
<Input placeholder="输入选项键,如: A" />
|
<Input placeholder="输入选项键,如: A" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@ -305,38 +334,103 @@ const QuestionManagement: React.FC = () => {
|
|||||||
case 'multiple-selection':
|
case 'multiple-selection':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.Item
|
<Form.List
|
||||||
label="选项"
|
|
||||||
name="options"
|
name="options"
|
||||||
rules={[{ required: true, message: '请输入选项' }]}
|
rules={[
|
||||||
|
{
|
||||||
|
validator: async (_, options) => {
|
||||||
|
if (!options || options.length < 2) {
|
||||||
|
return Promise.reject(new Error('至少需要2个选项'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Input.TextArea
|
{(fields, { add, remove }, { errors }) => (
|
||||||
placeholder='JSON格式,例如: {"A":"选项A","B":"选项B","C":"选项C","D":"选项D"}'
|
<>
|
||||||
rows={4}
|
<Form.Item label="选项" required>
|
||||||
/>
|
{fields.map((field, index) => (
|
||||||
</Form.Item>
|
<Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
name={[field.name, 'key']}
|
||||||
|
rules={[{ required: true, message: '请输入选项键' }]}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Input placeholder="A" style={{ width: 60 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
name={[field.name, 'value']}
|
||||||
|
rules={[{ required: true, message: '请输入选项内容' }]}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Input placeholder="选项内容" style={{ width: 400 }} />
|
||||||
|
</Form.Item>
|
||||||
|
{fields.length > 2 && (
|
||||||
|
<MinusCircleOutlined onClick={() => remove(field.name)} />
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Form.ErrorList errors={errors} />
|
||||||
|
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||||
|
添加选项
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="正确答案"
|
label="正确答案"
|
||||||
name="answer"
|
name="answer"
|
||||||
rules={[{ required: true, message: '请输入答案' }]}
|
rules={[{ required: true, message: '请选择答案' }]}
|
||||||
|
tooltip="多个答案请用逗号分隔,如: A,B,C"
|
||||||
>
|
>
|
||||||
<Input placeholder="输入选项键数组,如: A,B,C" />
|
<Select mode="tags" placeholder="选择或输入答案(可多选)" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'fill-in-blank':
|
case 'fill-in-blank':
|
||||||
return (
|
return (
|
||||||
<Form.Item
|
<Form.List
|
||||||
label="正确答案"
|
|
||||||
name="answer"
|
name="answer"
|
||||||
rules={[{ required: true, message: '请输入答案' }]}
|
rules={[
|
||||||
|
{
|
||||||
|
validator: async (_, answers) => {
|
||||||
|
if (!answers || answers.length < 1) {
|
||||||
|
return Promise.reject(new Error('至少需要1个答案'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Input.TextArea
|
{(fields, { add, remove }, { errors }) => (
|
||||||
placeholder="多个空格用逗号分隔,如: 答案1,答案2,答案3"
|
<>
|
||||||
rows={3}
|
<Form.Item label="正确答案" required>
|
||||||
/>
|
{fields.map((field, index) => (
|
||||||
</Form.Item>
|
<Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||||
|
<span>答案 {index + 1}:</span>
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
rules={[{ required: true, message: '请输入答案' }]}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Input placeholder="填空答案" style={{ width: 400 }} />
|
||||||
|
</Form.Item>
|
||||||
|
{fields.length > 1 && (
|
||||||
|
<MinusCircleOutlined onClick={() => remove(field.name)} />
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Form.ErrorList errors={errors} />
|
||||||
|
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||||
|
添加答案
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'short-answer':
|
case 'short-answer':
|
||||||
@ -346,7 +440,7 @@ const QuestionManagement: React.FC = () => {
|
|||||||
name="answer"
|
name="answer"
|
||||||
rules={[{ required: true, message: '请输入答案' }]}
|
rules={[{ required: true, message: '请输入答案' }]}
|
||||||
>
|
>
|
||||||
<Input.TextArea placeholder="输入参考答案" rows={4} />
|
<TextArea placeholder="输入参考答案" rows={4} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -390,13 +484,43 @@ const QuestionManagement: React.FC = () => {
|
|||||||
|
|
||||||
{/* 题目列表 */}
|
{/* 题目列表 */}
|
||||||
<Card className={styles.content}>
|
<Card className={styles.content}>
|
||||||
|
{/* 筛选和搜索 */}
|
||||||
|
<Space style={{ marginBottom: 16 }} size="middle">
|
||||||
|
<span>题型筛选:</span>
|
||||||
|
<Select
|
||||||
|
style={{ width: 150 }}
|
||||||
|
placeholder="全部题型"
|
||||||
|
allowClear
|
||||||
|
value={selectedType || undefined}
|
||||||
|
onChange={(value) => setSelectedType(value || '')}
|
||||||
|
>
|
||||||
|
{questionTypes.map(type => (
|
||||||
|
<Option key={type.key} value={type.key}>
|
||||||
|
{type.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input.Search
|
||||||
|
placeholder="搜索题目内容或编号"
|
||||||
|
style={{ width: 300 }}
|
||||||
|
allowClear
|
||||||
|
onSearch={(value) => setSearchText(value)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!e.target.value) {
|
||||||
|
setSearchText('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={questions}
|
dataSource={questions}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{
|
pagination={{
|
||||||
defaultPageSize: 10,
|
defaultPageSize: 20,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
showTotal: (total) => `共 ${total} 道题目`,
|
showTotal: (total) => `共 ${total} 道题目`,
|
||||||
}}
|
}}
|
||||||
@ -416,22 +540,33 @@ const QuestionManagement: React.FC = () => {
|
|||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
initialValues={{ type: 'multiple-choice' }}
|
initialValues={{
|
||||||
|
type: 'multiple-choice',
|
||||||
|
options: [{ key: 'A', value: '' }, { key: 'B', value: '' }]
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item
|
|
||||||
label="题目ID"
|
|
||||||
name="question_id"
|
|
||||||
rules={[{ required: true, message: '请输入题目ID' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="输入题目ID,如: Q001" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="题型"
|
label="题型"
|
||||||
name="type"
|
name="type"
|
||||||
rules={[{ required: true, message: '请选择题型' }]}
|
rules={[{ required: true, message: '请选择题型' }]}
|
||||||
>
|
>
|
||||||
<Select placeholder="选择题型" onChange={() => form.setFieldsValue({ answer: undefined })}>
|
<Select
|
||||||
|
placeholder="选择题型"
|
||||||
|
onChange={(value) => {
|
||||||
|
// 切换题型时重置答案和选项
|
||||||
|
form.setFieldsValue({ answer: undefined, options: undefined })
|
||||||
|
// 为单选和多选题设置默认选项
|
||||||
|
if (value === 'multiple-choice' || value === 'multiple-selection') {
|
||||||
|
form.setFieldsValue({
|
||||||
|
options: [{ key: 'A', value: '' }, { key: 'B', value: '' }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 为填空题设置默认答案数组
|
||||||
|
if (value === 'fill-in-blank') {
|
||||||
|
form.setFieldsValue({ answer: [''] })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{questionTypes.map(type => (
|
{questionTypes.map(type => (
|
||||||
<Option key={type.key} value={type.key}>
|
<Option key={type.key} value={type.key}>
|
||||||
{type.label}
|
{type.label}
|
||||||
@ -440,10 +575,6 @@ const QuestionManagement: React.FC = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="分类" name="category">
|
|
||||||
<Input placeholder="输入题目分类,如: 党史知识" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="题目内容"
|
label="题目内容"
|
||||||
name="content"
|
name="content"
|
||||||
@ -452,7 +583,7 @@ const QuestionManagement: React.FC = () => {
|
|||||||
<TextArea rows={4} placeholder="输入题目内容" />
|
<TextArea rows={4} placeholder="输入题目内容" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{renderAnswerInput()}
|
{renderFormByType()}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export interface Question {
|
|||||||
content: string
|
content: string
|
||||||
options: Option[]
|
options: Option[]
|
||||||
category: string
|
category: string
|
||||||
|
answer?: any // 正确答案(用于题目管理编辑)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交答案
|
// 提交答案
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user