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:
parent
3ecc1c6a18
commit
51c85b41a5
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
3
main.go
3
main.go
@ -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) // 获取指定练习题目
|
||||
|
||||
@ -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 } })
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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="请选择您的身份类型"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user