feat: 添加首页今日排行榜功能

- 后端新增 /api/ranking/daily 接口,支持获取今日用户刷题排行
- 排行榜按今日刷题数量和正确率进行排序
- 前端首页展示今日排行榜,显示前10名用户
- 前三名使用金银铜渐变色王冠徽章标识
- 正确率根据分数显示不同颜色(绿色≥80%、黄色≥60%、红色<60%)
- 完整支持移动端、平板端、PC端响应式布局
- 优化排行榜标题与上方内容的间距

🤖 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:36:06 +08:00
parent 3ecc1c6a18
commit 51c85b41a5
5 changed files with 428 additions and 0 deletions

View File

@ -1,7 +1,10 @@
package handlers
import (
"ankao/internal/database"
"ankao/internal/models"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
@ -20,3 +23,60 @@ func HealthCheckHandler(c *gin.Context) {
"status": "healthy",
})
}
// GetDailyRanking 获取今日排行榜
func GetDailyRanking(c *gin.Context) {
db := database.GetDB()
// 获取查询参数
limitStr := c.DefaultQuery("limit", "10")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 || limit > 100 {
limit = 10
}
// 查询今日排行榜(按答题数量和正确率排序)
var rankings []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
AND DATE(ar.answered_at) = CURRENT_DATE
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.username, u.nickname, u.avatar, u.user_type, u.created_at
HAVING COUNT(ar.id) > 0
ORDER BY total_answers DESC, accuracy DESC, correct_count DESC
LIMIT ?
`
if err := db.Raw(query, limit).Scan(&rankings).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": rankings,
})
}

View File

@ -44,6 +44,9 @@ func main() {
auth.PUT("/user/profile", handlers.UpdateProfile) // 更新用户信息
auth.PUT("/user/password", handlers.ChangePassword) // 修改密码
// 排行榜API
auth.GET("/ranking/daily", handlers.GetDailyRanking) // 获取今日排行榜
// 练习题相关API需要登录
auth.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表
auth.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目

View File

@ -193,3 +193,10 @@ export const getAllUsersWithStats = () => {
export const getUserDetailStats = (userId: number) => {
return request.get<ApiResponse<UserDetailStats>>(`/admin/users/${userId}`)
}
// ========== 排行榜相关 API ==========
// 获取今日排行榜
export const getDailyRanking = (limit: number = 10) => {
return request.get<ApiResponse<UserStats[]>>('/ranking/daily', { params: { limit } })
}

View File

@ -451,6 +451,182 @@
}
}
.rankingSection {
margin-top: 32px;
margin-bottom: 24px;
.sectionTitle {
color: #1d1d1f !important;
margin-bottom: 16px !important;
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 18px !important;
:global {
.anticon {
font-size: 20px;
color: #faad14;
}
}
}
.rankingCard {
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(30px) saturate(180%);
-webkit-backdrop-filter: blur(30px) saturate(180%);
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.05),
0 1px 4px rgba(0, 0, 0, 0.03),
0 0 0 1px rgba(0, 0, 0, 0.03);
border: 0.5px solid rgba(0, 0, 0, 0.04);
:global {
.ant-card-body {
padding: 20px;
}
}
}
.rankingList {
display: flex;
flex-direction: column;
gap: 10px;
}
.rankingItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: #fafafa;
border-radius: 12px;
transition: all 0.2s ease;
&:hover {
background: #f0f0f0;
transform: translateX(4px);
}
}
.rankingLeft {
display: flex;
align-items: center;
gap: 14px;
flex: 1;
min-width: 0;
}
.rankBadge {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
flex-shrink: 0;
&.rank1 {
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
color: #fff;
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.4);
}
&.rank2 {
background: linear-gradient(135deg, #c0c0c0 0%, #e8e8e8 100%);
color: #fff;
box-shadow: 0 4px 12px rgba(192, 192, 192, 0.4);
}
&.rank3 {
background: linear-gradient(135deg, #cd7f32 0%, #e8a87c 100%);
color: #fff;
box-shadow: 0 4px 12px rgba(205, 127, 50, 0.4);
}
}
.rankNumber {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 600;
color: #8c8c8c;
background: #f0f0f0;
flex-shrink: 0;
}
.rankAvatar {
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.rankUserInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.rankNickname {
font-size: 15px;
font-weight: 600;
color: #1d1d1f;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rankUsername {
font-size: 12px;
color: #8c8c8c;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rankingRight {
display: flex;
align-items: center;
gap: 20px;
flex-shrink: 0;
}
.rankStat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.rankStatValue {
font-size: 18px;
font-weight: 700;
color: #1890ff;
line-height: 1;
}
.rankStatLabel {
font-size: 12px;
color: #8c8c8c;
line-height: 1;
}
.rankDivider {
width: 1px;
height: 36px;
background: #e8e8e8;
}
}
// 响应式设计 - 移动端 (< 768px)
@media (max-width: 768px) {
.container {
@ -700,6 +876,88 @@
font-size: 24px;
}
}
.rankingSection {
margin-top: 24px;
margin-bottom: 16px;
.sectionTitle {
font-size: 16px !important;
margin-bottom: 12px !important;
}
.rankingCard {
border-radius: 12px;
:global {
.ant-card-body {
padding: 14px;
}
}
}
.rankingList {
gap: 8px;
}
.rankingItem {
padding: 12px 14px;
border-radius: 10px;
}
.rankingLeft {
gap: 12px;
}
.rankBadge {
width: 30px;
height: 30px;
font-size: 15px;
}
.rankNumber {
width: 30px;
height: 30px;
font-size: 13px;
}
.rankAvatar {
width: 38px !important;
height: 38px !important;
}
.rankUserInfo {
gap: 3px;
}
.rankNickname {
font-size: 13px;
}
.rankUsername {
font-size: 11px;
}
.rankingRight {
gap: 14px;
}
.rankStat {
gap: 3px;
}
.rankStatValue {
font-size: 15px;
}
.rankStatLabel {
font-size: 10px;
}
.rankDivider {
height: 30px;
}
}
}
// 响应式设计 - 平板 (769px - 1024px)
@ -747,6 +1005,14 @@
font-size: 17px !important;
}
}
.rankingSection {
margin-top: 28px;
.sectionTitle {
font-size: 17px !important;
}
}
}
// 响应式设计 - PC端宽屏 (> 1025px)
@ -836,6 +1102,15 @@
font-size: 20px !important;
}
}
.rankingSection {
margin-top: 36px;
margin-bottom: 32px;
.sectionTitle {
font-size: 20px !important;
}
}
}
// 响应式设计 - 超宽屏 (> 1600px)

View File

@ -20,6 +20,8 @@ import {
FormOutlined,
FileMarkdownOutlined,
TeamOutlined,
TrophyOutlined,
CrownOutlined,
} from '@ant-design/icons'
import * as questionApi from '../api/question'
import { fetchWithAuth } from '../utils/request'
@ -101,6 +103,10 @@ const Home: React.FC = () => {
accuracy: 0,
})
// 排行榜状态
const [dailyRanking, setDailyRanking] = useState<questionApi.UserStats[]>([])
const [rankingLoading, setRankingLoading] = useState(false)
// 答题设置状态
const [autoNext, setAutoNext] = useState(() => {
const saved = localStorage.getItem('autoNextEnabled')
@ -129,6 +135,21 @@ const Home: React.FC = () => {
}
}
// 加载排行榜数据
const loadDailyRanking = async () => {
setRankingLoading(true)
try {
const res = await questionApi.getDailyRanking(10)
if (res.success && res.data) {
setDailyRanking(res.data)
}
} catch (error) {
console.error('加载排行榜失败:', error)
} finally {
setRankingLoading(false)
}
}
// 加载用户信息
useEffect(() => {
const token = localStorage.getItem('token')
@ -151,6 +172,7 @@ const Home: React.FC = () => {
useEffect(() => {
loadStatistics()
loadDailyRanking()
}, [])
// 处理用户类型更新
@ -580,6 +602,67 @@ const Home: React.FC = () => {
</Row>
</div>
{/* 今日排行榜 */}
<div className={styles.rankingSection}>
<Title level={4} className={styles.sectionTitle}>
<TrophyOutlined />
</Title>
{rankingLoading ? (
<Card className={styles.rankingCard} loading={true} />
) : dailyRanking.length === 0 ? (
<Card className={styles.rankingCard}>
<div style={{ textAlign: 'center', padding: '40px 20px', color: '#8c8c8c' }}>
<TrophyOutlined style={{ fontSize: 48, marginBottom: 16, opacity: 0.3 }} />
<div></div>
<div style={{ fontSize: 13, marginTop: 8 }}></div>
</div>
</Card>
) : (
<Card className={styles.rankingCard}>
<div className={styles.rankingList}>
{dailyRanking.map((user, index) => (
<div key={user.user_id} className={styles.rankingItem}>
<div className={styles.rankingLeft}>
{index < 3 ? (
<div className={`${styles.rankBadge} ${styles[`rank${index + 1}`]}`}>
{index === 0 && <CrownOutlined />}
{index === 1 && <CrownOutlined />}
{index === 2 && <CrownOutlined />}
</div>
) : (
<div className={styles.rankNumber}>{index + 1}</div>
)}
<Avatar
src={user.avatar || undefined}
size={40}
icon={<UserOutlined />}
className={styles.rankAvatar}
/>
<div className={styles.rankUserInfo}>
<div className={styles.rankNickname}>{user.nickname}</div>
<div className={styles.rankUsername}>@{user.username}</div>
</div>
</div>
<div className={styles.rankingRight}>
<div className={styles.rankStat}>
<div className={styles.rankStatValue}>{user.total_answers}</div>
<div className={styles.rankStatLabel}></div>
</div>
<div className={styles.rankDivider}></div>
<div className={styles.rankStat}>
<div className={styles.rankStatValue} style={{ color: user.accuracy >= 80 ? '#52c41a' : user.accuracy >= 60 ? '#faad14' : '#ff4d4f' }}>
{user.accuracy.toFixed(0)}%
</div>
<div className={styles.rankStatLabel}></div>
</div>
</div>
</div>
))}
</div>
</Card>
)}
</div>
{/* 用户类型补充模态框 */}
<Modal
title="请选择您的身份类型"