在首页添加错题数量统计显示
主要改动: 1. 后端修改: - 在 UserStatistics 模型中添加 wrong_questions 字段 - 在 GetStatistics 接口中查询并返回错题总数(包括已掌握和未掌握) 2. 前端修改: - 在 Statistics 接口中添加 wrong_questions 字段 - 在首页统计卡片中新增"错题数量"显示 - 调整布局为4列展示(题库总数、已答题数、错题数量、正确率) 3. UI优化: - 错题数量使用红色显示(#ff4d4f) - 响应式布局:移动端每行2个,PC端每行4个 - 与错题本页面的统计数据保持一致 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
de8480a328
commit
9e37cf8225
@ -403,6 +403,12 @@ func GetStatistics(c *gin.Context) {
|
|||||||
Where("user_id = ? AND is_correct = ?", uid, true).
|
Where("user_id = ? AND is_correct = ?", uid, true).
|
||||||
Count(&correctAnswers)
|
Count(&correctAnswers)
|
||||||
|
|
||||||
|
// 获取用户错题数量(所有错题,包括已掌握和未掌握的)
|
||||||
|
var wrongQuestions int64
|
||||||
|
db.Model(&models.WrongQuestion{}).
|
||||||
|
Where("user_id = ?", uid).
|
||||||
|
Count(&wrongQuestions)
|
||||||
|
|
||||||
// 计算正确率
|
// 计算正确率
|
||||||
var accuracy float64
|
var accuracy float64
|
||||||
if answeredQuestions > 0 {
|
if answeredQuestions > 0 {
|
||||||
@ -418,6 +424,7 @@ func GetStatistics(c *gin.Context) {
|
|||||||
TotalQuestions: int(totalQuestions),
|
TotalQuestions: int(totalQuestions),
|
||||||
AnsweredQuestions: int(answeredQuestions),
|
AnsweredQuestions: int(answeredQuestions),
|
||||||
CorrectAnswers: int(correctAnswers),
|
CorrectAnswers: int(correctAnswers),
|
||||||
|
WrongQuestions: int(wrongQuestions),
|
||||||
Accuracy: accuracy,
|
Accuracy: accuracy,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -426,3 +433,207 @@ func GetStatistics(c *gin.Context) {
|
|||||||
"data": stats,
|
"data": stats,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
Answer interface{} `json:"answer" binding:"required"`
|
||||||
|
Options map[string]string `json:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "请求参数错误: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将答案序列化为JSON字符串
|
||||||
|
answerData, err := json.Marshal(req.Answer)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "答案格式错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将选项序列化为JSON字符串
|
||||||
|
var optionsData string
|
||||||
|
if req.Options != nil && len(req.Options) > 0 {
|
||||||
|
optionsBytes, err := json.Marshal(req.Options)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "选项格式错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
optionsData = string(optionsBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
question := models.PracticeQuestion{
|
||||||
|
QuestionID: req.QuestionID,
|
||||||
|
Type: req.Type,
|
||||||
|
TypeName: req.TypeName,
|
||||||
|
Question: req.Question,
|
||||||
|
AnswerData: string(answerData),
|
||||||
|
OptionsData: optionsData,
|
||||||
|
}
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
if err := db.Create(&question).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "创建题目失败",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回创建的题目
|
||||||
|
dto := convertToDTO(question)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": dto,
|
||||||
|
"message": "创建成功",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePracticeQuestion 更新练习题目
|
||||||
|
func UpdatePracticeQuestion(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无效的题目ID",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
QuestionID string `json:"question_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
TypeName string `json:"type_name"`
|
||||||
|
Question string `json:"question"`
|
||||||
|
Answer interface{} `json:"answer"`
|
||||||
|
Options map[string]string `json:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "请求参数错误: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
var question models.PracticeQuestion
|
||||||
|
|
||||||
|
// 查找题目是否存在
|
||||||
|
if err := db.First(&question, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "题目不存在",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新字段
|
||||||
|
if req.QuestionID != "" {
|
||||||
|
question.QuestionID = req.QuestionID
|
||||||
|
}
|
||||||
|
if req.Type != "" {
|
||||||
|
question.Type = req.Type
|
||||||
|
}
|
||||||
|
if req.TypeName != "" {
|
||||||
|
question.TypeName = req.TypeName
|
||||||
|
}
|
||||||
|
if req.Question != "" {
|
||||||
|
question.Question = req.Question
|
||||||
|
}
|
||||||
|
if req.Answer != nil {
|
||||||
|
answerData, err := json.Marshal(req.Answer)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "答案格式错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
question.AnswerData = string(answerData)
|
||||||
|
}
|
||||||
|
if req.Options != nil {
|
||||||
|
optionsBytes, err := json.Marshal(req.Options)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "选项格式错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
question.OptionsData = string(optionsBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存更新
|
||||||
|
if err := db.Save(&question).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "更新题目失败",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回更新后的题目
|
||||||
|
dto := convertToDTO(question)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": dto,
|
||||||
|
"message": "更新成功",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePracticeQuestion 删除练习题目
|
||||||
|
func DeletePracticeQuestion(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无效的题目ID",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
// 检查题目是否存在
|
||||||
|
var question models.PracticeQuestion
|
||||||
|
if err := db.First(&question, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "题目不存在",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除题目
|
||||||
|
if err := db.Delete(&question).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "删除题目失败",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "删除成功",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -25,5 +25,6 @@ type UserStatistics struct {
|
|||||||
TotalQuestions int `json:"total_questions"` // 题库总数
|
TotalQuestions int `json:"total_questions"` // 题库总数
|
||||||
AnsweredQuestions int `json:"answered_questions"` // 已答题数
|
AnsweredQuestions int `json:"answered_questions"` // 已答题数
|
||||||
CorrectAnswers int `json:"correct_answers"` // 答对题数
|
CorrectAnswers int `json:"correct_answers"` // 答对题数
|
||||||
|
WrongQuestions int `json:"wrong_questions"` // 错题数量
|
||||||
Accuracy float64 `json:"accuracy"` // 正确率
|
Accuracy float64 `json:"accuracy"` // 正确率
|
||||||
}
|
}
|
||||||
|
|||||||
5
main.go
5
main.go
@ -50,6 +50,11 @@ func main() {
|
|||||||
auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
|
auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
|
||||||
auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据
|
auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据
|
||||||
|
|
||||||
|
// 题库管理API(需要认证)
|
||||||
|
auth.POST("/practice/questions", handlers.CreatePracticeQuestion) // 创建题目
|
||||||
|
auth.PUT("/practice/questions/:id", handlers.UpdatePracticeQuestion) // 更新题目
|
||||||
|
auth.DELETE("/practice/questions/:id", handlers.DeletePracticeQuestion) // 删除题目
|
||||||
|
|
||||||
// 错题本相关API
|
// 错题本相关API
|
||||||
auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表
|
auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表
|
||||||
auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计
|
auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import Login from './pages/Login'
|
|||||||
import Home from './pages/Home'
|
import Home from './pages/Home'
|
||||||
import About from './pages/About'
|
import About from './pages/About'
|
||||||
import WrongQuestions from './pages/WrongQuestions'
|
import WrongQuestions from './pages/WrongQuestions'
|
||||||
|
import QuestionManagement from './pages/QuestionManagement'
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
@ -20,6 +21,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
{/* 不带TabBar的页面,但需要登录保护 */}
|
{/* 不带TabBar的页面,但需要登录保护 */}
|
||||||
<Route path="/wrong-questions" element={<ProtectedRoute><WrongQuestions /></ProtectedRoute>} />
|
<Route path="/wrong-questions" element={<ProtectedRoute><WrongQuestions /></ProtectedRoute>} />
|
||||||
|
<Route path="/question-management" element={<ProtectedRoute><QuestionManagement /></ProtectedRoute>} />
|
||||||
|
|
||||||
{/* 不带TabBar的页面,不需要登录保护 */}
|
{/* 不带TabBar的页面,不需要登录保护 */}
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
|||||||
@ -66,3 +66,34 @@ export const deleteWrongQuestion = (id: number) => {
|
|||||||
export const clearWrongQuestions = () => {
|
export const clearWrongQuestions = () => {
|
||||||
return request.delete<ApiResponse<null>>('/wrong-questions')
|
return request.delete<ApiResponse<null>>('/wrong-questions')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 题库管理相关 API ==========
|
||||||
|
|
||||||
|
// 创建题目
|
||||||
|
export const createQuestion = (data: {
|
||||||
|
question_id: string
|
||||||
|
type: string
|
||||||
|
type_name?: string
|
||||||
|
question: string
|
||||||
|
answer: any
|
||||||
|
options?: Record<string, string>
|
||||||
|
}) => {
|
||||||
|
return request.post<ApiResponse<Question>>('/practice/questions', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新题目
|
||||||
|
export const updateQuestion = (id: number, data: {
|
||||||
|
question_id?: string
|
||||||
|
type?: string
|
||||||
|
type_name?: string
|
||||||
|
question?: string
|
||||||
|
answer?: any
|
||||||
|
options?: Record<string, string>
|
||||||
|
}) => {
|
||||||
|
return request.put<ApiResponse<Question>>(`/practice/questions/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除题目
|
||||||
|
export const deleteQuestion = (id: number) => {
|
||||||
|
return request.delete<ApiResponse<null>>(`/practice/questions/${id}`)
|
||||||
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
BookOutlined,
|
BookOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
|
SettingOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import * as questionApi from '../api/question'
|
import * as questionApi from '../api/question'
|
||||||
import type { Statistics } from '../types/question'
|
import type { Statistics } from '../types/question'
|
||||||
@ -69,6 +70,7 @@ const Home: React.FC = () => {
|
|||||||
total_questions: 0,
|
total_questions: 0,
|
||||||
answered_questions: 0,
|
answered_questions: 0,
|
||||||
correct_answers: 0,
|
correct_answers: 0,
|
||||||
|
wrong_questions: 0,
|
||||||
accuracy: 0,
|
accuracy: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -169,21 +171,28 @@ const Home: React.FC = () => {
|
|||||||
{/* 统计卡片 */}
|
{/* 统计卡片 */}
|
||||||
<Card className={styles.statsCard}>
|
<Card className={styles.statsCard}>
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={8} sm={8} md={8}>
|
<Col xs={12} sm={12} md={6}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="题库总数"
|
title="题库总数"
|
||||||
value={statistics.total_questions}
|
value={statistics.total_questions}
|
||||||
valueStyle={{ color: '#1677ff', fontSize: '24px' }}
|
valueStyle={{ color: '#1677ff', fontSize: '24px' }}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={8} sm={8} md={8}>
|
<Col xs={12} sm={12} md={6}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="已答题数"
|
title="已答题数"
|
||||||
value={statistics.answered_questions}
|
value={statistics.answered_questions}
|
||||||
valueStyle={{ color: '#52c41a', fontSize: '24px' }}
|
valueStyle={{ color: '#52c41a', fontSize: '24px' }}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={8} sm={8} md={8}>
|
<Col xs={12} sm={12} md={6}>
|
||||||
|
<Statistic
|
||||||
|
title="错题数量"
|
||||||
|
value={statistics.wrong_questions}
|
||||||
|
valueStyle={{ color: '#ff4d4f', fontSize: '24px' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={12} md={6}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="正确率"
|
title="正确率"
|
||||||
value={statistics.accuracy.toFixed(0)}
|
value={statistics.accuracy.toFixed(0)}
|
||||||
@ -258,6 +267,23 @@ const Home: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
className={styles.quickCard}
|
||||||
|
onClick={() => navigate('/question-management')}
|
||||||
|
style={{ marginTop: '16px' }}
|
||||||
|
>
|
||||||
|
<Space align="center" size="large">
|
||||||
|
<div className={styles.quickIcon}>
|
||||||
|
<SettingOutlined style={{ fontSize: '32px', color: '#13c2c2' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Title level={5} style={{ margin: 0 }}>题库管理</Title>
|
||||||
|
<Paragraph type="secondary" style={{ margin: 0 }}>添加、编辑和删除题目</Paragraph>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
61
web/src/pages/QuestionManagement.module.less
Normal file
61
web/src/pages/QuestionManagement.module.less
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
@media (min-width: 1025px) {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.headerContent {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
:global {
|
||||||
|
.ant-table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
.headerContent {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
:global {
|
||||||
|
.ant-table {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
462
web/src/pages/QuestionManagement.tsx
Normal file
462
web/src/pages/QuestionManagement.tsx
Normal file
@ -0,0 +1,462 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
message,
|
||||||
|
Popconfirm,
|
||||||
|
Tag,
|
||||||
|
Card,
|
||||||
|
Radio,
|
||||||
|
InputNumber,
|
||||||
|
Divider,
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import * as questionApi from '../api/question'
|
||||||
|
import type { Question } from '../types/question'
|
||||||
|
import styles from './QuestionManagement.module.less'
|
||||||
|
|
||||||
|
const { Option } = Select
|
||||||
|
const { TextArea } = Input
|
||||||
|
|
||||||
|
// 题型配置
|
||||||
|
const questionTypes = [
|
||||||
|
{ key: 'multiple-choice', label: '单选题' },
|
||||||
|
{ key: 'multiple-selection', label: '多选题' },
|
||||||
|
{ key: 'true-false', label: '判断题' },
|
||||||
|
{ key: 'fill-in-blank', label: '填空题' },
|
||||||
|
{ key: 'short-answer', label: '简答题' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const QuestionManagement: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [questions, setQuestions] = useState<Question[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
|
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
// 加载题目列表
|
||||||
|
const loadQuestions = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await questionApi.getQuestions()
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setQuestions(res.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('加载题目失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadQuestions()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 打开新建/编辑弹窗
|
||||||
|
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 optionsValue: string | undefined
|
||||||
|
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<string, string>)
|
||||||
|
optionsValue = JSON.stringify(optionsObj, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置表单值
|
||||||
|
form.setFieldsValue({
|
||||||
|
question_id: question.question_id,
|
||||||
|
type: question.type,
|
||||||
|
category: question.category,
|
||||||
|
content: question.content,
|
||||||
|
answer: answerValue,
|
||||||
|
options: optionsValue,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setEditingQuestion(null)
|
||||||
|
form.resetFields()
|
||||||
|
}
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭弹窗
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setModalVisible(false)
|
||||||
|
setEditingQuestion(null)
|
||||||
|
form.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存题目
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
|
||||||
|
// 解析答案
|
||||||
|
let answer: any
|
||||||
|
if (values.type === 'true-false') {
|
||||||
|
answer = values.answer
|
||||||
|
} else if (values.type === 'multiple-choice') {
|
||||||
|
answer = values.answer
|
||||||
|
} else if (values.type === 'multiple-selection') {
|
||||||
|
// 多选题答案是数组
|
||||||
|
answer = values.answer.split(',').map((s: string) => s.trim())
|
||||||
|
} else if (values.type === 'fill-in-blank') {
|
||||||
|
// 填空题答案是数组
|
||||||
|
answer = values.answer.split(',').map((s: string) => s.trim())
|
||||||
|
} else if (values.type === 'short-answer') {
|
||||||
|
answer = values.answer
|
||||||
|
} else {
|
||||||
|
answer = values.answer
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析选项(仅选择题和多选题需要)
|
||||||
|
let options: Record<string, string> | 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建请求数据
|
||||||
|
const data = {
|
||||||
|
question_id: values.question_id,
|
||||||
|
type: values.type,
|
||||||
|
type_name: values.category,
|
||||||
|
question: values.content,
|
||||||
|
answer: answer,
|
||||||
|
options: options,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingQuestion) {
|
||||||
|
// 更新
|
||||||
|
await questionApi.updateQuestion(editingQuestion.id, data)
|
||||||
|
message.success('更新成功')
|
||||||
|
} else {
|
||||||
|
// 创建
|
||||||
|
await questionApi.createQuestion(data)
|
||||||
|
message.success('创建成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCloseModal()
|
||||||
|
loadQuestions()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存失败:', error)
|
||||||
|
message.error('保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除题目
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await questionApi.deleteQuestion(id)
|
||||||
|
message.success('删除成功')
|
||||||
|
loadQuestions()
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格列定义
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '题目ID',
|
||||||
|
dataIndex: 'question_id',
|
||||||
|
key: 'question_id',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '题型',
|
||||||
|
dataIndex: 'type',
|
||||||
|
key: 'type',
|
||||||
|
width: 120,
|
||||||
|
render: (type: string) => {
|
||||||
|
const typeConfig = questionTypes.find(t => t.key === type)
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
'multiple-choice': 'blue',
|
||||||
|
'multiple-selection': 'green',
|
||||||
|
'true-false': 'orange',
|
||||||
|
'fill-in-blank': 'purple',
|
||||||
|
'short-answer': 'magenta',
|
||||||
|
}
|
||||||
|
return <Tag color={colorMap[type]}>{typeConfig?.label || type}</Tag>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '分类',
|
||||||
|
dataIndex: 'category',
|
||||||
|
key: 'category',
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '题目内容',
|
||||||
|
dataIndex: 'content',
|
||||||
|
key: 'content',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 150,
|
||||||
|
render: (_: any, record: Question) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleOpenModal(record)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除这道题目吗?"
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 根据题型动态渲染答案输入
|
||||||
|
const renderAnswerInput = () => {
|
||||||
|
const type = form.getFieldValue('type')
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'true-false':
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
label="正确答案"
|
||||||
|
name="answer"
|
||||||
|
rules={[{ required: true, message: '请选择答案' }]}
|
||||||
|
>
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio value={true}>正确</Radio>
|
||||||
|
<Radio value={false}>错误</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'multiple-choice':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
label="选项"
|
||||||
|
name="options"
|
||||||
|
rules={[{ required: true, message: '请输入选项' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder='JSON格式,例如: {"A":"选项A","B":"选项B","C":"选项C","D":"选项D"}'
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="正确答案"
|
||||||
|
name="answer"
|
||||||
|
rules={[{ required: true, message: '请输入答案' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="输入选项键,如: A" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'multiple-selection':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
label="选项"
|
||||||
|
name="options"
|
||||||
|
rules={[{ required: true, message: '请输入选项' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder='JSON格式,例如: {"A":"选项A","B":"选项B","C":"选项C","D":"选项D"}'
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="正确答案"
|
||||||
|
name="answer"
|
||||||
|
rules={[{ required: true, message: '请输入答案' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="输入选项键数组,如: A,B,C" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'fill-in-blank':
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
label="正确答案"
|
||||||
|
name="answer"
|
||||||
|
rules={[{ required: true, message: '请输入答案' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder="多个空格用逗号分隔,如: 答案1,答案2,答案3"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'short-answer':
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
label="参考答案"
|
||||||
|
name="answer"
|
||||||
|
rules={[{ required: true, message: '请输入答案' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea placeholder="输入参考答案" rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
label="答案"
|
||||||
|
name="answer"
|
||||||
|
rules={[{ required: true, message: '请输入答案' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="输入答案" />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<Card className={styles.header}>
|
||||||
|
<div className={styles.headerContent}>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
<Divider type="vertical" />
|
||||||
|
<h2 className={styles.title}>题库管理</h2>
|
||||||
|
</Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => handleOpenModal()}
|
||||||
|
>
|
||||||
|
新建题目
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 题目列表 */}
|
||||||
|
<Card className={styles.content}>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={questions}
|
||||||
|
loading={loading}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={{
|
||||||
|
defaultPageSize: 10,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 道题目`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 新建/编辑弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={editingQuestion ? '编辑题目' : '新建题目'}
|
||||||
|
open={modalVisible}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={handleCloseModal}
|
||||||
|
width={700}
|
||||||
|
okText="保存"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{ type: 'multiple-choice' }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="题目ID"
|
||||||
|
name="question_id"
|
||||||
|
rules={[{ required: true, message: '请输入题目ID' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="输入题目ID,如: Q001" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="题型"
|
||||||
|
name="type"
|
||||||
|
rules={[{ required: true, message: '请选择题型' }]}
|
||||||
|
>
|
||||||
|
<Select placeholder="选择题型" onChange={() => form.setFieldsValue({ answer: undefined })}>
|
||||||
|
{questionTypes.map(type => (
|
||||||
|
<Option key={type.key} value={type.key}>
|
||||||
|
{type.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="分类" name="category">
|
||||||
|
<Input placeholder="输入题目分类,如: 党史知识" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="题目内容"
|
||||||
|
name="content"
|
||||||
|
rules={[{ required: true, message: '请输入题目内容' }]}
|
||||||
|
>
|
||||||
|
<TextArea rows={4} placeholder="输入题目内容" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{renderAnswerInput()}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuestionManagement
|
||||||
@ -35,6 +35,7 @@ export interface Statistics {
|
|||||||
total_questions: number
|
total_questions: number
|
||||||
answered_questions: number
|
answered_questions: number
|
||||||
correct_answers: number
|
correct_answers: number
|
||||||
|
wrong_questions: number
|
||||||
accuracy: number
|
accuracy: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user