添加用户管理功能

新增功能:
- 用户管理页面:展示所有用户及答题统计
- 用户详情页面:查看单个用户的详细答题数据
- 管理员权限中间件:仅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:
燕陇琪 2025-11-08 06:21:15 +08:00
parent e6f5bcce7b
commit 3ecc1c6a18
11 changed files with 1355 additions and 23 deletions

View 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,
},
})
}

View 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()
}
}

View 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"` // 最后答题时间
}

View File

@ -76,6 +76,13 @@ func main() {
admin.PUT("/practice/questions/:id", handlers.UpdatePracticeQuestion) // 更新题目 admin.PUT("/practice/questions/:id", handlers.UpdatePracticeQuestion) // 更新题目
admin.DELETE("/practice/questions/:id", handlers.DeletePracticeQuestion) // 删除题目 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 路由之后) // 静态文件服务(必须在 API 路由之后)

View File

@ -12,6 +12,8 @@ import About from './pages/About'
import WrongQuestions from './pages/WrongQuestions' import WrongQuestions from './pages/WrongQuestions'
import QuestionManagement from './pages/QuestionManagement' import QuestionManagement from './pages/QuestionManagement'
import QuestionList from './pages/QuestionList' import QuestionList from './pages/QuestionList'
import UserManagement from './pages/UserManagement'
import UserDetail from './pages/UserDetail'
const App: React.FC = () => { const App: React.FC = () => {
return ( return (
@ -37,6 +39,24 @@ const App: React.FC = () => {
</ProtectedRoute> </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的页面不需要登录保护 */} {/* 不带TabBar的页面不需要登录保护 */}
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/about" element={<About />} /> <Route path="/about" element={<About />} />

View File

@ -154,3 +154,42 @@ export const deleteQuestion = (id: number) => {
export const explainQuestion = (questionId: number) => { export const explainQuestion = (questionId: number) => {
return request.post<ApiResponse<{ explanation: string }>>('/practice/explain', { question_id: questionId }) 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}`)
}

View File

@ -19,6 +19,7 @@ import {
CloseCircleOutlined, CloseCircleOutlined,
FormOutlined, FormOutlined,
FileMarkdownOutlined, FileMarkdownOutlined,
TeamOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import * as questionApi from '../api/question' import * as questionApi from '../api/question'
import { fetchWithAuth } from '../utils/request' import { fetchWithAuth } from '../utils/request'
@ -526,6 +527,7 @@ const Home: React.FC = () => {
{/* 仅 yanlongqi 用户显示题库管理 */} {/* 仅 yanlongqi 用户显示题库管理 */}
{userInfo?.username === 'yanlongqi' && ( {userInfo?.username === 'yanlongqi' && (
<>
<Col xs={24} sm={24} md={12} lg={8}> <Col xs={24} sm={24} md={12} lg={8}>
<Card <Card
hoverable hoverable
@ -549,6 +551,31 @@ const Home: React.FC = () => {
</Space> </Space>
</Card> </Card>
</Col> </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> </Row>
</div> </div>

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

View 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

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

View 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