新增功能: - 用户管理页面:展示所有用户及答题统计 - 用户详情页面:查看单个用户的详细答题数据 - 管理员权限中间件:仅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>
292 lines
8.9 KiB
TypeScript
292 lines
8.9 KiB
TypeScript
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
|