diff --git a/internal/handlers/admin_handler.go b/internal/handlers/admin_handler.go new file mode 100644 index 0000000..bb68408 --- /dev/null +++ b/internal/handlers/admin_handler.go @@ -0,0 +1,143 @@ +package handlers + +import ( + "ankao/internal/database" + "ankao/internal/models" + "net/http" + + "github.com/gin-gonic/gin" +) + +// GetAllUsersWithStats 获取所有用户及其答题统计(仅管理员可访问) +func GetAllUsersWithStats(c *gin.Context) { + db := database.GetDB() + + // 查询所有用户及其答题统计 + var userStats []models.UserStats + + // SQL查询:联合查询用户表和答题记录表 + query := ` + SELECT + u.id as user_id, + u.username, + u.nickname, + u.avatar, + u.user_type, + COALESCE(COUNT(ar.id), 0) as total_answers, + COALESCE(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count, + COALESCE(SUM(CASE WHEN ar.is_correct = false THEN 1 ELSE 0 END), 0) as wrong_count, + CASE + WHEN COUNT(ar.id) > 0 THEN + ROUND(CAST(CAST(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) AS FLOAT) / COUNT(ar.id) * 100 AS NUMERIC), 2) + ELSE 0 + END as accuracy, + u.created_at, + MAX(ar.answered_at) as last_answer_at + FROM users u + LEFT JOIN user_answer_records ar ON u.id = ar.user_id AND ar.deleted_at IS NULL + WHERE u.deleted_at IS NULL + GROUP BY u.id, u.username, u.nickname, u.avatar, u.user_type, u.created_at + ORDER BY total_answers DESC, accuracy DESC + ` + + if err := db.Raw(query).Scan(&userStats).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "获取用户统计数据失败", + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": userStats, + }) +} + +// GetUserDetailStats 获取指定用户的详细统计信息 +func GetUserDetailStats(c *gin.Context) { + userID := c.Param("id") + db := database.GetDB() + + // 查询用户基本信息 + var user models.User + if err := db.First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "用户不存在", + }) + return + } + + // 查询用户答题统计 + var stats models.UserStats + query := ` + SELECT + u.id as user_id, + u.username, + u.nickname, + u.avatar, + u.user_type, + COALESCE(COUNT(ar.id), 0) as total_answers, + COALESCE(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count, + COALESCE(SUM(CASE WHEN ar.is_correct = false THEN 1 ELSE 0 END), 0) as wrong_count, + CASE + WHEN COUNT(ar.id) > 0 THEN + ROUND(CAST(CAST(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) AS FLOAT) / COUNT(ar.id) * 100 AS NUMERIC), 2) + ELSE 0 + END as accuracy, + u.created_at, + MAX(ar.answered_at) as last_answer_at + FROM users u + LEFT JOIN user_answer_records ar ON u.id = ar.user_id AND ar.deleted_at IS NULL + WHERE u.id = ? AND u.deleted_at IS NULL + GROUP BY u.id, u.username, u.nickname, u.avatar, u.user_type, u.created_at + ` + + if err := db.Raw(query, userID).Scan(&stats).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "获取用户统计数据失败", + "error": err.Error(), + }) + return + } + + // 查询按题型分类的统计 + var typeStats []struct { + QuestionType string `json:"question_type"` + QuestionTypeName string `json:"question_type_name"` + TotalAnswers int `json:"total_answers"` + CorrectCount int `json:"correct_count"` + Accuracy float64 `json:"accuracy"` + } + + typeQuery := ` + SELECT + pq.type as question_type, + pq.type_name as question_type_name, + COUNT(ar.id) as total_answers, + SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) as correct_count, + CASE + WHEN COUNT(ar.id) > 0 THEN + ROUND(CAST(CAST(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) AS FLOAT) / COUNT(ar.id) * 100 AS NUMERIC), 2) + ELSE 0 + END as accuracy + FROM user_answer_records ar + JOIN practice_questions pq ON ar.question_id = pq.id + WHERE ar.user_id = ? AND ar.deleted_at IS NULL + GROUP BY pq.type, pq.type_name + ORDER BY total_answers DESC + ` + + db.Raw(typeQuery, userID).Scan(&typeStats) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "user_info": stats, + "type_stats": typeStats, + }, + }) +} diff --git a/internal/middleware/admin.go b/internal/middleware/admin.go new file mode 100644 index 0000000..88424b1 --- /dev/null +++ b/internal/middleware/admin.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// AdminOnly 管理员权限验证中间件(仅yanlongqi用户可访问) +func AdminOnly() gin.HandlerFunc { + return func(c *gin.Context) { + // 从上下文中获取用户名(需要先通过Auth中间件) + username, exists := c.Get("username") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + c.Abort() + return + } + + // 检查是否是管理员用户(仅yanlongqi) + if username != "yanlongqi" { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "无权访问,该功能仅限管理员使用", + }) + c.Abort() + return + } + + // 权限验证通过,继续处理请求 + c.Next() + } +} diff --git a/internal/models/user_stats.go b/internal/models/user_stats.go new file mode 100644 index 0000000..5b98093 --- /dev/null +++ b/internal/models/user_stats.go @@ -0,0 +1,16 @@ +package models + +// UserStats 用户统计信息 +type UserStats struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Nickname string `json:"nickname"` // 姓名 + Avatar string `json:"avatar"` // 头像 + UserType string `json:"user_type"` // 用户类型 + TotalAnswers int `json:"total_answers"` // 总答题数 + CorrectCount int `json:"correct_count"` // 答对数量 + WrongCount int `json:"wrong_count"` // 答错数量 + Accuracy float64 `json:"accuracy"` // 正确率(百分比) + CreatedAt string `json:"created_at"` // 用户创建时间 + LastAnswerAt *string `json:"last_answer_at"` // 最后答题时间 +} diff --git a/main.go b/main.go index c827148..d6aafd0 100644 --- a/main.go +++ b/main.go @@ -76,6 +76,13 @@ func main() { admin.PUT("/practice/questions/:id", handlers.UpdatePracticeQuestion) // 更新题目 admin.DELETE("/practice/questions/:id", handlers.DeletePracticeQuestion) // 删除题目 } + + // 用户管理API(仅yanlongqi用户可访问) + userAdmin := api.Group("", middleware.Auth(), middleware.AdminOnly()) + { + userAdmin.GET("/admin/users", handlers.GetAllUsersWithStats) // 获取所有用户及统计 + userAdmin.GET("/admin/users/:id", handlers.GetUserDetailStats) // 获取用户详细统计 + } } // 静态文件服务(必须在 API 路由之后) diff --git a/web/src/App.tsx b/web/src/App.tsx index 452d34c..8be9703 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,8 @@ import About from './pages/About' import WrongQuestions from './pages/WrongQuestions' import QuestionManagement from './pages/QuestionManagement' import QuestionList from './pages/QuestionList' +import UserManagement from './pages/UserManagement' +import UserDetail from './pages/UserDetail' const App: React.FC = () => { return ( @@ -37,6 +39,24 @@ const App: React.FC = () => { } /> + {/* 用户管理页面,仅yanlongqi用户可访问 */} + + + + + + } /> + + {/* 用户详情页面,仅yanlongqi用户可访问 */} + + + + + + } /> + {/* 不带TabBar的页面,不需要登录保护 */} } /> } /> diff --git a/web/src/api/question.ts b/web/src/api/question.ts index ede682a..227828a 100644 --- a/web/src/api/question.ts +++ b/web/src/api/question.ts @@ -154,3 +154,42 @@ export const deleteQuestion = (id: number) => { export const explainQuestion = (questionId: number) => { return request.post>('/practice/explain', { question_id: questionId }) } + +// ========== 用户管理相关 API(仅管理员) ========== + +// 用户统计信息 +export interface UserStats { + user_id: number + username: string + nickname: string + avatar: string + user_type: string + total_answers: number + correct_count: number + wrong_count: number + accuracy: number + created_at: string + last_answer_at?: string +} + +// 用户详细统计 +export interface UserDetailStats { + user_info: UserStats + type_stats: Array<{ + question_type: string + question_type_name: string + total_answers: number + correct_count: number + accuracy: number + }> +} + +// 获取所有用户及统计(仅yanlongqi用户可访问) +export const getAllUsersWithStats = () => { + return request.get>('/admin/users') +} + +// 获取用户详细统计(仅yanlongqi用户可访问) +export const getUserDetailStats = (userId: number) => { + return request.get>(`/admin/users/${userId}`) +} diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 289bdc8..1a8e85a 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -19,6 +19,7 @@ import { CloseCircleOutlined, FormOutlined, FileMarkdownOutlined, + TeamOutlined, } from '@ant-design/icons' import * as questionApi from '../api/question' import { fetchWithAuth } from '../utils/request' @@ -526,29 +527,55 @@ const Home: React.FC = () => { {/* 仅 yanlongqi 用户显示题库管理 */} {userInfo?.username === 'yanlongqi' && ( - - navigate('/question-management')} - > - -
- -
-
- 题库管理 - 添加、编辑和删除题目 -
-
-
- + <> + + navigate('/question-management')} + > + +
+ +
+
+ 题库管理 + 添加、编辑和删除题目 +
+
+
+ + + + navigate('/user-management')} + > + +
+ +
+
+ 用户管理 + 查看用户答题统计 +
+
+
+ + )} diff --git a/web/src/pages/UserDetail.module.less b/web/src/pages/UserDetail.module.less new file mode 100644 index 0000000..2b73b38 --- /dev/null +++ b/web/src/pages/UserDetail.module.less @@ -0,0 +1,92 @@ +.container { + padding: 20px; + max-width: 1200px; + margin: 0 auto; +} + +.loadingContainer { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; +} + +.backButton { + margin-bottom: 20px; +} + +.userInfoCard { + margin-bottom: 20px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.userHeader { + display: flex; + align-items: center; + gap: 20px; + padding-bottom: 24px; + border-bottom: 1px solid #f0f0f0; +} + +.avatar { + border: 3px solid #f0f0f0; + flex-shrink: 0; +} + +.userBasicInfo { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.statCard { + text-align: center; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + transition: all 0.3s ease; + + &:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + transform: translateY(-2px); + } +} + +.statCardContent { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 0; +} + +.detailCard { + margin-bottom: 20px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.typeStatsCard { + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +// 移动端适配 +@media (max-width: 768px) { + .container { + padding: 10px; + } + + .userHeader { + flex-direction: column; + text-align: center; + } + + .userBasicInfo { + align-items: center; + } + + .backButton { + width: 100%; + } +} diff --git a/web/src/pages/UserDetail.tsx b/web/src/pages/UserDetail.tsx new file mode 100644 index 0000000..d240b09 --- /dev/null +++ b/web/src/pages/UserDetail.tsx @@ -0,0 +1,291 @@ +import React, { useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + Card, + Button, + Typography, + Space, + message, + Tag, + Row, + Col, + Avatar, + Descriptions, + Progress, + Table, + Spin, +} from 'antd' +import { + ArrowLeftOutlined, + UserOutlined, + CheckCircleOutlined, + CloseCircleOutlined, +} from '@ant-design/icons' +import * as questionApi from '../api/question' +import type { UserDetailStats } from '../api/question' +import styles from './UserDetail.module.less' + +const { Title, Text } = Typography + +const UserDetail: React.FC = () => { + const navigate = useNavigate() + const { id } = useParams<{ id: string }>() + const [loading, setLoading] = useState(false) + const [userDetail, setUserDetail] = useState(null) + + // 加载用户详情 + const loadUserDetail = async () => { + if (!id) return + + try { + setLoading(true) + const res = await questionApi.getUserDetailStats(Number(id)) + if (res.success && res.data) { + setUserDetail(res.data) + } + } catch (error: any) { + console.error('加载用户详情失败:', error) + if (error.response?.status === 403) { + message.error('无权访问') + navigate('/user-management') + } else if (error.response?.status === 401) { + message.error('请先登录') + navigate('/login') + } else { + message.error('加载用户详情失败') + } + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadUserDetail() + }, [id]) + + // 格式化日期 + const formatDate = (dateStr?: string) => { + if (!dateStr) return '-' + return new Date(dateStr).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + } + + // 获取用户类型显示文本 + const getUserTypeText = (type?: string) => { + if (!type) return '未设置' + return type === 'ordinary-person' ? '普通涉密人员' : '保密管理人员' + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (!userDetail) { + return null + } + + const { user_info, type_stats } = userDetail + + return ( +
+ {/* 返回按钮 */} + + + {/* 用户信息卡片 */} + +
+ } + className={styles.avatar} + /> +
+ + {user_info.nickname || user_info.username} + + + @{user_info.username} + + + {getUserTypeText(user_info.user_type)} + +
+
+ + {/* 统计卡片 */} + + + +
+ 总答题数 + + {user_info.total_answers} + +
+
+ + + +
+ 答对数 + + {user_info.correct_count} + +
+
+ + + +
+ 答错数 + + {user_info.wrong_count} + +
+
+ + + +
+ 正确率 + = 80 + ? '#52c41a' + : user_info.accuracy >= 60 + ? '#1890ff' + : '#faad14', + }} + > + {user_info.accuracy.toFixed(1)}% + +
+
+ +
+ + {/* 正确率进度条 */} +
+ + 答题准确率 + + = 80 + ? '#52c41a' + : user_info.accuracy >= 60 + ? '#1890ff' + : '#faad14' + } + strokeWidth={12} + format={(percent) => `${percent?.toFixed(1)}%`} + /> +
+
+ + {/* 详细信息 */} + + + {user_info.username} + {user_info.nickname || '-'} + + + {getUserTypeText(user_info.user_type)} + + + {user_info.total_answers} + + {user_info.correct_count} + + + {user_info.wrong_count} + + + {formatDate(user_info.created_at)} + + + {formatDate(user_info.last_answer_at)} + + + + + {/* 题型统计 */} + {type_stats.length > 0 && ( + + {text}, + }, + { + title: '答题数', + dataIndex: 'total_answers', + key: 'total_answers', + align: 'center', + sorter: (a, b) => a.total_answers - b.total_answers, + }, + { + title: '答对数', + dataIndex: 'correct_count', + key: 'correct_count', + align: 'center', + render: (val: number) => {val}, + }, + { + title: '答错数', + key: 'wrong_count', + align: 'center', + render: (_, record) => ( + {record.total_answers - record.correct_count} + ), + }, + { + title: '正确率', + dataIndex: 'accuracy', + key: 'accuracy', + align: 'center', + sorter: (a, b) => a.accuracy - b.accuracy, + render: (val: number) => ( + = 80 ? 'success' : val >= 60 ? 'processing' : 'warning'}> + {val.toFixed(1)}% + + ), + }, + ]} + /> + + )} + + ) +} + +export default UserDetail diff --git a/web/src/pages/UserManagement.module.less b/web/src/pages/UserManagement.module.less new file mode 100644 index 0000000..4bcefc6 --- /dev/null +++ b/web/src/pages/UserManagement.module.less @@ -0,0 +1,177 @@ +.container { + padding: 20px; + max-width: 1400px; + margin: 0 auto; +} + +.headerCard { + margin-bottom: 20px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + + :global { + .ant-card-body { + padding: 16px !important; + } + } +} + +.header { + display: flex; + justify-content: center; + align-items: center; + position: relative; + margin-bottom: 8px; + + .backButton { + color: #007aff; + font-weight: 500; + padding: 4px 12px; + transition: all 0.3s ease; + position: absolute; + left: 0; + + &:hover { + color: #0051d5; + transform: translateX(-4px); + } + } + + .title { + color: #1d1d1f !important; + margin: 0 !important; + font-weight: 700; + font-size: 18px !important; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.02); + } +} + +.statCard { + :global { + .ant-card-body { + padding: 16px !important; + } + } +} + +.userCard { + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + transition: all 0.3s ease; + cursor: pointer; + + &:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + transform: translateY(-2px); + } +} + +.userCardHeader { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin-bottom: 16px; +} + +.avatar { + margin-bottom: 12px; + border: 3px solid #f0f0f0; +} + +.userInfo { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.statsSection { + display: flex; + justify-content: space-around; + padding: 16px 0; + border-top: 1px solid #f0f0f0; + border-bottom: 1px solid #f0f0f0; + margin-bottom: 16px; +} + +.statItem { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.statLabel { + font-size: 12px; +} + +.statValue { + font-size: 16px; +} + +.progressSection { + margin-bottom: 12px; +} + +.timeInfo { + display: flex; + flex-direction: column; + gap: 4px; + padding-top: 8px; + border-top: 1px solid #f0f0f0; +} + +// 移动端适配 +@media (max-width: 768px) { + .container { + padding: 10px 6px; + } + + .headerCard { + margin-bottom: 10px; + + :global { + .ant-space-item { + width: 100%; + } + } + } + + .userCard { + margin-bottom: 8px; + + .userCardHeader { + margin-bottom: 12px; + } + + .avatar { + width: 48px; + height: 48px; + margin-bottom: 8px; + } + } + + .statsSection { + flex-direction: row; + gap: 4px; + padding: 8px 0; + } + + .statItem { + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + } + + .statLabel { + font-size: 11px; + } + + .statValue { + font-size: 14px; + } +} diff --git a/web/src/pages/UserManagement.tsx b/web/src/pages/UserManagement.tsx new file mode 100644 index 0000000..036b94c --- /dev/null +++ b/web/src/pages/UserManagement.tsx @@ -0,0 +1,484 @@ +import React, { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Card, + Button, + Typography, + Space, + message, + Tag, + Statistic, + Row, + Col, + Avatar, + Progress, + Drawer, + Descriptions, + Table, + Spin, +} from 'antd' +import { + ArrowLeftOutlined, + UserOutlined, + TrophyOutlined, + CheckCircleOutlined, + CloseCircleOutlined, +} from '@ant-design/icons' +import * as questionApi from '../api/question' +import type { UserStats, UserDetailStats } from '../api/question' +import styles from './UserManagement.module.less' + +const { Title, Text } = Typography + +const UserManagement: React.FC = () => { + const navigate = useNavigate() + const [loading, setLoading] = useState(false) + const [users, setUsers] = useState([]) + const [drawerVisible, setDrawerVisible] = useState(false) + const [selectedUser, setSelectedUser] = useState(null) + const [detailLoading, setDetailLoading] = useState(false) + + // 加载用户列表 + const loadUsers = async () => { + try { + setLoading(true) + const res = await questionApi.getAllUsersWithStats() + if (res.success && res.data) { + setUsers(res.data) + } + } catch (error: any) { + console.error('加载用户列表失败:', error) + if (error.response?.status === 403) { + message.error('无权访问,该功能仅限管理员使用') + navigate('/') + } else if (error.response?.status === 401) { + message.error('请先登录') + navigate('/login') + } else { + message.error('加载用户列表失败') + } + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadUsers() + }, []) + + // 查看用户详情 + const handleViewDetail = async (userId: number) => { + try { + setDetailLoading(true) + setDrawerVisible(true) + const res = await questionApi.getUserDetailStats(userId) + if (res.success && res.data) { + setSelectedUser(res.data) + } + } catch (error) { + message.error('加载用户详情失败') + setDrawerVisible(false) + } finally { + setDetailLoading(false) + } + } + + // 格式化日期 + const formatDate = (dateStr?: string) => { + if (!dateStr) return '-' + return new Date(dateStr).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + } + + // 获取用户类型显示文本 + const getUserTypeText = (type?: string) => { + if (!type) return '未设置' + return type === 'ordinary-person' ? '普通涉密人员' : '保密管理人员' + } + + // 计算汇总统计 + const totalStats = { + totalUsers: users.length, + totalAnswers: users.reduce((sum, u) => sum + u.total_answers, 0), + avgAccuracy: + users.length > 0 + ? users.reduce((sum, u) => sum + u.accuracy, 0) / users.length + : 0, + } + + return ( +
+ {/* 页面标题和统计 */} + + +
+ + + 用户管理 + +
+ + +
+ + } + valueStyle={{ color: '#1890ff' }} + /> + + + + + } + valueStyle={{ color: '#52c41a' }} + /> + + + + + + + + + + + + {/* 用户卡片列表 */} + + {users.map((user) => ( + + handleViewDetail(user.user_id)} + > + {/* 用户基本信息 */} +
+ } + className={styles.avatar} + /> +
+ + {user.nickname || user.username} + + @{user.username} + + {getUserTypeText(user.user_type)} + +
+
+ + {/* 统计数据 */} +
+
+ + 总答题数 + + + {user.total_answers} + +
+
+ + 答对数 + + + {user.correct_count} + +
+
+ + 答错数 + + + {user.wrong_count} + +
+
+ + {/* 正确率进度条 */} +
+ + 正确率 + + = 80 + ? '#52c41a' + : user.accuracy >= 60 + ? '#1890ff' + : '#faad14' + } + format={(percent) => `${percent?.toFixed(1)}%`} + /> +
+ + {/* 时间信息 */} +
+ + 注册:{formatDate(user.created_at)} + + {user.last_answer_at && ( + + 最后答题:{formatDate(user.last_answer_at)} + + )} +
+
+ + ))} + + + {/* 用户详情抽屉 */} + setDrawerVisible(false)} + styles={{ + body: { paddingTop: 12 } + }} + > + {detailLoading ? ( +
+ +
+ ) : ( + selectedUser && ( + + {/* 用户基本信息 */} +
+ } + style={{ marginBottom: 16 }} + /> + + {selectedUser.user_info.nickname || selectedUser.user_info.username} + + @{selectedUser.user_info.username} +
+ + {getUserTypeText(selectedUser.user_info.user_type)} + +
+
+ + {/* 统计数据 */} +
+
+ + 总答题数 + + + {selectedUser.user_info.total_answers} + +
+
+ + 答对数 + + + {selectedUser.user_info.correct_count} + +
+
+ + 答错数 + + + {selectedUser.user_info.wrong_count} + +
+
+ + 正确率 + + = 80 + ? '#52c41a' + : selectedUser.user_info.accuracy >= 60 + ? '#1890ff' + : '#faad14' + }} + > + {selectedUser.user_info.accuracy.toFixed(1)}% + +
+
+ + {/* 正确率进度条 */} +
+ + 答题准确率 + + = 80 + ? '#52c41a' + : selectedUser.user_info.accuracy >= 60 + ? '#1890ff' + : '#faad14' + } + strokeWidth={12} + format={(percent) => `${percent?.toFixed(1)}%`} + /> +
+ + {/* 详细信息 */} + + + {selectedUser.user_info.username} + + + {selectedUser.user_info.nickname || '-'} + + + + {getUserTypeText(selectedUser.user_info.user_type)} + + + + {formatDate(selectedUser.user_info.created_at)} + + + {formatDate(selectedUser.user_info.last_answer_at)} + + + + {/* 题型统计 */} + {selectedUser.type_stats.length > 0 && ( + <> + 题型统计 +
{text}, + }, + { + title: '答题数', + dataIndex: 'total_answers', + key: 'total_answers', + align: 'center', + sorter: (a, b) => a.total_answers - b.total_answers, + }, + { + title: '答对数', + dataIndex: 'correct_count', + key: 'correct_count', + align: 'center', + render: (val: number) => {val}, + }, + { + title: '答错数', + key: 'wrong_count', + align: 'center', + render: (_, record) => ( + {record.total_answers - record.correct_count} + ), + }, + { + title: '正确率', + dataIndex: 'accuracy', + key: 'accuracy', + align: 'center', + sorter: (a, b) => a.accuracy - b.accuracy, + render: (val: number) => ( + = 80 ? 'success' : val >= 60 ? 'processing' : 'warning'}> + {val.toFixed(1)}% + + ), + }, + ]} + /> + + )} + + ) + )} + + + ) +} + +export default UserManagement