添加用户管理功能
新增功能: - 用户管理页面:展示所有用户及答题统计 - 用户详情页面:查看单个用户的详细答题数据 - 管理员权限中间件:仅yanlongqi用户可访问 - 后端API接口:用户列表和详情统计 后端更新: - 新增 admin_handler.go:用户管理相关处理器 - 新增 admin.go 中间件:管理员权限验证 - 新增 user_stats.go 模型:用户统计数据结构 - 更新 main.go:注册用户管理API路由 前端更新: - 新增 UserManagement 页面:用户列表和统计卡片 - 新增 UserDetail 页面:用户详细信息和题型统计 - 更新 Home 页面:添加用户管理入口(仅管理员可见) - 更新 App.tsx:添加用户管理路由和权限保护 - 更新 API 接口:添加用户管理相关接口定义 UI优化: - 用户管理页面标题居中显示,参考错题本设计 - 统计卡片使用16px padding - 返回按钮使用绝对定位,标题居中 - 字体大小统一为18px,字重700 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e6f5bcce7b
commit
3ecc1c6a18
143
internal/handlers/admin_handler.go
Normal file
143
internal/handlers/admin_handler.go
Normal file
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
36
internal/middleware/admin.go
Normal file
36
internal/middleware/admin.go
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
16
internal/models/user_stats.go
Normal file
16
internal/models/user_stats.go
Normal file
@ -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"` // 最后答题时间
|
||||
}
|
||||
7
main.go
7
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 路由之后)
|
||||
|
||||
@ -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 = () => {
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* 用户管理页面,仅yanlongqi用户可访问 */}
|
||||
<Route path="/user-management" element={
|
||||
<ProtectedRoute>
|
||||
<AdminRoute>
|
||||
<UserManagement />
|
||||
</AdminRoute>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* 用户详情页面,仅yanlongqi用户可访问 */}
|
||||
<Route path="/user-management/:id" element={
|
||||
<ProtectedRoute>
|
||||
<AdminRoute>
|
||||
<UserDetail />
|
||||
</AdminRoute>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* 不带TabBar的页面,不需要登录保护 */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
|
||||
@ -154,3 +154,42 @@ export const deleteQuestion = (id: number) => {
|
||||
export const explainQuestion = (questionId: number) => {
|
||||
return request.post<ApiResponse<{ explanation: string }>>('/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<ApiResponse<UserStats[]>>('/admin/users')
|
||||
}
|
||||
|
||||
// 获取用户详细统计(仅yanlongqi用户可访问)
|
||||
export const getUserDetailStats = (userId: number) => {
|
||||
return request.get<ApiResponse<UserDetailStats>>(`/admin/users/${userId}`)
|
||||
}
|
||||
|
||||
@ -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' && (
|
||||
<Col xs={24} sm={24} md={12} lg={8}>
|
||||
<Card
|
||||
hoverable
|
||||
className={styles.quickCard}
|
||||
onClick={() => navigate('/question-management')}
|
||||
>
|
||||
<Space align="center" size="middle" style={{ width: '100%' }}>
|
||||
<div
|
||||
className={styles.quickIconWrapper}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #e6fffb 0%, #d6f5f0 100%)',
|
||||
borderColor: '#87e8de'
|
||||
}}
|
||||
>
|
||||
<SettingOutlined className={styles.quickIcon} style={{ color: '#36cfc9' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Title level={5} style={{ margin: 0 }}>题库管理</Title>
|
||||
<Paragraph type="secondary" style={{ margin: 0, fontSize: '13px' }}>添加、编辑和删除题目</Paragraph>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<>
|
||||
<Col xs={24} sm={24} md={12} lg={8}>
|
||||
<Card
|
||||
hoverable
|
||||
className={styles.quickCard}
|
||||
onClick={() => navigate('/question-management')}
|
||||
>
|
||||
<Space align="center" size="middle" style={{ width: '100%' }}>
|
||||
<div
|
||||
className={styles.quickIconWrapper}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #e6fffb 0%, #d6f5f0 100%)',
|
||||
borderColor: '#87e8de'
|
||||
}}
|
||||
>
|
||||
<SettingOutlined className={styles.quickIcon} style={{ color: '#36cfc9' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Title level={5} style={{ margin: 0 }}>题库管理</Title>
|
||||
<Paragraph type="secondary" style={{ margin: 0, fontSize: '13px' }}>添加、编辑和删除题目</Paragraph>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={24} md={12} lg={8}>
|
||||
<Card
|
||||
hoverable
|
||||
className={styles.quickCard}
|
||||
onClick={() => navigate('/user-management')}
|
||||
>
|
||||
<Space align="center" size="middle" style={{ width: '100%' }}>
|
||||
<div
|
||||
className={styles.quickIconWrapper}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #f0f5ff 0%, #e6edff 100%)',
|
||||
borderColor: '#adc6ff'
|
||||
}}
|
||||
>
|
||||
<TeamOutlined className={styles.quickIcon} style={{ color: '#597ef7' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Title level={5} style={{ margin: 0 }}>用户管理</Title>
|
||||
<Paragraph type="secondary" style={{ margin: 0, fontSize: '13px' }}>查看用户答题统计</Paragraph>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
92
web/src/pages/UserDetail.module.less
Normal file
92
web/src/pages/UserDetail.module.less
Normal file
@ -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%;
|
||||
}
|
||||
}
|
||||
291
web/src/pages/UserDetail.tsx
Normal file
291
web/src/pages/UserDetail.tsx
Normal file
@ -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<UserDetailStats | null>(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 (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!userDetail) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { user_info, type_stats } = userDetail
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 返回按钮 */}
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/user-management')}
|
||||
className={styles.backButton}
|
||||
>
|
||||
返回用户列表
|
||||
</Button>
|
||||
|
||||
{/* 用户信息卡片 */}
|
||||
<Card className={styles.userInfoCard}>
|
||||
<div className={styles.userHeader}>
|
||||
<Avatar
|
||||
size={80}
|
||||
src={user_info.avatar || undefined}
|
||||
icon={<UserOutlined />}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<div className={styles.userBasicInfo}>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
{user_info.nickname || user_info.username}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 16 }}>
|
||||
@{user_info.username}
|
||||
</Text>
|
||||
<Tag
|
||||
color={user_info.user_type === 'ordinary-person' ? 'blue' : 'green'}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
{getUserTypeText(user_info.user_type)}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={16} style={{ marginTop: 24 }}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card className={styles.statCard}>
|
||||
<div className={styles.statCardContent}>
|
||||
<Text type="secondary">总答题数</Text>
|
||||
<Text strong style={{ fontSize: 24 }}>
|
||||
{user_info.total_answers}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card className={styles.statCard}>
|
||||
<div className={styles.statCardContent}>
|
||||
<Text type="secondary">答对数</Text>
|
||||
<Text strong style={{ fontSize: 24, color: '#52c41a' }}>
|
||||
<CheckCircleOutlined /> {user_info.correct_count}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card className={styles.statCard}>
|
||||
<div className={styles.statCardContent}>
|
||||
<Text type="secondary">答错数</Text>
|
||||
<Text strong style={{ fontSize: 24, color: '#ff4d4f' }}>
|
||||
<CloseCircleOutlined /> {user_info.wrong_count}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card className={styles.statCard}>
|
||||
<div className={styles.statCardContent}>
|
||||
<Text type="secondary">正确率</Text>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 24,
|
||||
color:
|
||||
user_info.accuracy >= 80
|
||||
? '#52c41a'
|
||||
: user_info.accuracy >= 60
|
||||
? '#1890ff'
|
||||
: '#faad14',
|
||||
}}
|
||||
>
|
||||
{user_info.accuracy.toFixed(1)}%
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 正确率进度条 */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Text type="secondary" style={{ marginBottom: 8, display: 'block' }}>
|
||||
答题准确率
|
||||
</Text>
|
||||
<Progress
|
||||
percent={user_info.accuracy}
|
||||
strokeColor={
|
||||
user_info.accuracy >= 80
|
||||
? '#52c41a'
|
||||
: user_info.accuracy >= 60
|
||||
? '#1890ff'
|
||||
: '#faad14'
|
||||
}
|
||||
strokeWidth={12}
|
||||
format={(percent) => `${percent?.toFixed(1)}%`}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 详细信息 */}
|
||||
<Card title="详细信息" className={styles.detailCard}>
|
||||
<Descriptions bordered column={{ xs: 1, sm: 2 }}>
|
||||
<Descriptions.Item label="用户名">{user_info.username}</Descriptions.Item>
|
||||
<Descriptions.Item label="姓名">{user_info.nickname || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户类型">
|
||||
<Tag color={user_info.user_type === 'ordinary-person' ? 'blue' : 'green'}>
|
||||
{getUserTypeText(user_info.user_type)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="总答题数">{user_info.total_answers}</Descriptions.Item>
|
||||
<Descriptions.Item label="答对数">
|
||||
<Tag color="success">{user_info.correct_count}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="答错数">
|
||||
<Tag color="error">{user_info.wrong_count}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="注册时间">
|
||||
{formatDate(user_info.created_at)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最后答题">
|
||||
{formatDate(user_info.last_answer_at)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* 题型统计 */}
|
||||
{type_stats.length > 0 && (
|
||||
<Card title="题型统计" className={styles.typeStatsCard}>
|
||||
<Table
|
||||
dataSource={type_stats}
|
||||
rowKey="question_type"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: '题型',
|
||||
dataIndex: 'question_type_name',
|
||||
key: 'question_type_name',
|
||||
render: (text: string) => <Text strong>{text}</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) => <Tag color="success">{val}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '答错数',
|
||||
key: 'wrong_count',
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Tag color="error">{record.total_answers - record.correct_count}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '正确率',
|
||||
dataIndex: 'accuracy',
|
||||
key: 'accuracy',
|
||||
align: 'center',
|
||||
sorter: (a, b) => a.accuracy - b.accuracy,
|
||||
render: (val: number) => (
|
||||
<Tag color={val >= 80 ? 'success' : val >= 60 ? 'processing' : 'warning'}>
|
||||
{val.toFixed(1)}%
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserDetail
|
||||
177
web/src/pages/UserManagement.module.less
Normal file
177
web/src/pages/UserManagement.module.less
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
484
web/src/pages/UserManagement.tsx
Normal file
484
web/src/pages/UserManagement.tsx
Normal file
@ -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<UserStats[]>([])
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<UserDetailStats | null>(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 (
|
||||
<div className={styles.container}>
|
||||
{/* 页面标题和统计 */}
|
||||
<Card className={styles.headerCard}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<div className={styles.header}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
type="text"
|
||||
className={styles.backButton}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
<Title level={3} className={styles.title}>
|
||||
用户管理
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Row gutter={8}>
|
||||
<Col xs={8} sm={8}>
|
||||
<Card
|
||||
className={styles.statCard}
|
||||
style={{ padding: 0 }}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '16px',
|
||||
}
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<Statistic
|
||||
title="总用户数"
|
||||
value={totalStats.totalUsers}
|
||||
prefix={<UserOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={8}>
|
||||
<Card
|
||||
className={styles.statCard}
|
||||
style={{ padding: 0 }}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '16px',
|
||||
}
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<Statistic
|
||||
title="总答题数"
|
||||
value={totalStats.totalAnswers}
|
||||
prefix={<TrophyOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={8}>
|
||||
<Card
|
||||
className={styles.statCard}
|
||||
style={{ padding: 0 }}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '16px',
|
||||
}
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<Statistic
|
||||
title="平均正确率"
|
||||
value={totalStats.avgAccuracy.toFixed(1)}
|
||||
suffix="%"
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 用户卡片列表 */}
|
||||
<Row gutter={[8, 8]}>
|
||||
{users.map((user) => (
|
||||
<Col xs={24} sm={12} md={12} lg={8} xl={6} key={user.user_id}>
|
||||
<Card
|
||||
className={styles.userCard}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '16px',
|
||||
}
|
||||
}}
|
||||
hoverable
|
||||
loading={loading}
|
||||
onClick={() => handleViewDetail(user.user_id)}
|
||||
>
|
||||
{/* 用户基本信息 */}
|
||||
<div className={styles.userCardHeader}>
|
||||
<Avatar
|
||||
size={64}
|
||||
src={user.avatar || undefined}
|
||||
icon={<UserOutlined />}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<div className={styles.userInfo}>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
{user.nickname || user.username}
|
||||
</Title>
|
||||
<Text type="secondary">@{user.username}</Text>
|
||||
<Tag
|
||||
color={user.user_type === 'ordinary-person' ? 'blue' : 'green'}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
{getUserTypeText(user.user_type)}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<div className={styles.statsSection}>
|
||||
<div className={styles.statItem}>
|
||||
<Text type="secondary" className={styles.statLabel}>
|
||||
总答题数
|
||||
</Text>
|
||||
<Text strong className={styles.statValue}>
|
||||
{user.total_answers}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<Text type="secondary" className={styles.statLabel}>
|
||||
答对数
|
||||
</Text>
|
||||
<Text strong className={styles.statValue} style={{ color: '#52c41a' }}>
|
||||
<CheckCircleOutlined /> {user.correct_count}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<Text type="secondary" className={styles.statLabel}>
|
||||
答错数
|
||||
</Text>
|
||||
<Text strong className={styles.statValue} style={{ color: '#ff4d4f' }}>
|
||||
<CloseCircleOutlined /> {user.wrong_count}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 正确率进度条 */}
|
||||
<div className={styles.progressSection}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
正确率
|
||||
</Text>
|
||||
<Progress
|
||||
percent={user.accuracy}
|
||||
strokeColor={
|
||||
user.accuracy >= 80
|
||||
? '#52c41a'
|
||||
: user.accuracy >= 60
|
||||
? '#1890ff'
|
||||
: '#faad14'
|
||||
}
|
||||
format={(percent) => `${percent?.toFixed(1)}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 时间信息 */}
|
||||
<div className={styles.timeInfo}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
注册:{formatDate(user.created_at)}
|
||||
</Text>
|
||||
{user.last_answer_at && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
最后答题:{formatDate(user.last_answer_at)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 用户详情抽屉 */}
|
||||
<Drawer
|
||||
title="用户详细统计"
|
||||
placement="right"
|
||||
width={800}
|
||||
open={drawerVisible}
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
styles={{
|
||||
body: { paddingTop: 12 }
|
||||
}}
|
||||
>
|
||||
{detailLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '50px 0' }}>
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
) : (
|
||||
selectedUser && (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
{/* 用户基本信息 */}
|
||||
<div style={{ textAlign: 'center', paddingBottom: 24, borderBottom: '1px solid #f0f0f0' }}>
|
||||
<Avatar
|
||||
size={80}
|
||||
src={selectedUser.user_info.avatar || undefined}
|
||||
icon={<UserOutlined />}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Title level={4} style={{ margin: '0 0 8px 0' }}>
|
||||
{selectedUser.user_info.nickname || selectedUser.user_info.username}
|
||||
</Title>
|
||||
<Text type="secondary">@{selectedUser.user_info.username}</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Tag color={selectedUser.user_info.user_type === 'ordinary-person' ? 'blue' : 'green'}>
|
||||
{getUserTypeText(selectedUser.user_info.user_type)}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
padding: '16px 0',
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
总答题数
|
||||
</Text>
|
||||
<Text strong style={{ fontSize: 20, color: '#1890ff' }}>
|
||||
{selectedUser.user_info.total_answers}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
答对数
|
||||
</Text>
|
||||
<Text strong style={{ fontSize: 20, color: '#52c41a' }}>
|
||||
<CheckCircleOutlined /> {selectedUser.user_info.correct_count}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
答错数
|
||||
</Text>
|
||||
<Text strong style={{ fontSize: 20, color: '#ff4d4f' }}>
|
||||
<CloseCircleOutlined /> {selectedUser.user_info.wrong_count}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
正确率
|
||||
</Text>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: selectedUser.user_info.accuracy >= 80
|
||||
? '#52c41a'
|
||||
: selectedUser.user_info.accuracy >= 60
|
||||
? '#1890ff'
|
||||
: '#faad14'
|
||||
}}
|
||||
>
|
||||
{selectedUser.user_info.accuracy.toFixed(1)}%
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 正确率进度条 */}
|
||||
<div>
|
||||
<Text type="secondary" style={{ marginBottom: 8, display: 'block' }}>
|
||||
答题准确率
|
||||
</Text>
|
||||
<Progress
|
||||
percent={selectedUser.user_info.accuracy}
|
||||
strokeColor={
|
||||
selectedUser.user_info.accuracy >= 80
|
||||
? '#52c41a'
|
||||
: selectedUser.user_info.accuracy >= 60
|
||||
? '#1890ff'
|
||||
: '#faad14'
|
||||
}
|
||||
strokeWidth={12}
|
||||
format={(percent) => `${percent?.toFixed(1)}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 详细信息 */}
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label="用户名">
|
||||
{selectedUser.user_info.username}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="姓名">
|
||||
{selectedUser.user_info.nickname || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用户类型">
|
||||
<Tag color={selectedUser.user_info.user_type === 'ordinary-person' ? 'blue' : 'green'}>
|
||||
{getUserTypeText(selectedUser.user_info.user_type)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="注册时间">
|
||||
{formatDate(selectedUser.user_info.created_at)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最后答题">
|
||||
{formatDate(selectedUser.user_info.last_answer_at)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{/* 题型统计 */}
|
||||
{selectedUser.type_stats.length > 0 && (
|
||||
<>
|
||||
<Title level={5}>题型统计</Title>
|
||||
<Table
|
||||
dataSource={selectedUser.type_stats}
|
||||
rowKey="question_type"
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{
|
||||
title: '题型',
|
||||
dataIndex: 'question_type_name',
|
||||
key: 'question_type_name',
|
||||
render: (text: string) => <Text strong>{text}</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) => <Tag color="success">{val}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '答错数',
|
||||
key: 'wrong_count',
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Tag color="error">{record.total_answers - record.correct_count}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '正确率',
|
||||
dataIndex: 'accuracy',
|
||||
key: 'accuracy',
|
||||
align: 'center',
|
||||
sorter: (a, b) => a.accuracy - b.accuracy,
|
||||
render: (val: number) => (
|
||||
<Tag color={val >= 80 ? 'success' : val >= 60 ? 'processing' : 'warning'}>
|
||||
{val.toFixed(1)}%
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserManagement
|
||||
Loading…
x
Reference in New Issue
Block a user