在首页添加错题数量统计显示

主要改动:
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:
yanlongqi 2025-11-04 19:28:57 +08:00
parent de8480a328
commit 9e37cf8225
9 changed files with 803 additions and 3 deletions

View File

@ -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": "删除成功",
})
}

View File

@ -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"` // 正确率
} }

View File

@ -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) // 获取错题统计

View File

@ -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 />} />

View File

@ -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}`)
}

View File

@ -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>
) )

View 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;
}
}
}
}

View 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

View File

@ -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
} }