diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 984a1e0..66d230c 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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, + }) +} diff --git a/main.go b/main.go index d6aafd0..fc31ef6 100644 --- a/main.go +++ b/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) // 获取指定练习题目 diff --git a/web/src/api/question.ts b/web/src/api/question.ts index 227828a..bf5665d 100644 --- a/web/src/api/question.ts +++ b/web/src/api/question.ts @@ -193,3 +193,10 @@ export const getAllUsersWithStats = () => { export const getUserDetailStats = (userId: number) => { return request.get>(`/admin/users/${userId}`) } + +// ========== 排行榜相关 API ========== + +// 获取今日排行榜 +export const getDailyRanking = (limit: number = 10) => { + return request.get>('/ranking/daily', { params: { limit } }) +} diff --git a/web/src/pages/Home.module.less b/web/src/pages/Home.module.less index e899c74..99be647 100644 --- a/web/src/pages/Home.module.less +++ b/web/src/pages/Home.module.less @@ -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) diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 1a8e85a..edc82fe 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -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([]) + 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 = () => { + {/* 今日排行榜 */} +
+ + <TrophyOutlined /> 今日排行榜 + + {rankingLoading ? ( + + ) : dailyRanking.length === 0 ? ( + +
+ +
今日暂无排行数据
+
快去刷题吧!
+
+
+ ) : ( + +
+ {dailyRanking.map((user, index) => ( +
+
+ {index < 3 ? ( +
+ {index === 0 && } + {index === 1 && } + {index === 2 && } +
+ ) : ( +
{index + 1}
+ )} + } + className={styles.rankAvatar} + /> +
+
{user.nickname}
+
@{user.username}
+
+
+
+
+
{user.total_answers}
+
今日刷题
+
+
+
+
= 80 ? '#52c41a' : user.accuracy >= 60 ? '#faad14' : '#ff4d4f' }}> + {user.accuracy.toFixed(0)}% +
+
正确率
+
+
+
+ ))} +
+
+ )} +
+ {/* 用户类型补充模态框 */}