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
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"ankao/internal/database"
|
||||||
|
"ankao/internal/models"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -20,3 +23,60 @@ func HealthCheckHandler(c *gin.Context) {
|
|||||||
"status": "healthy",
|
"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/profile", handlers.UpdateProfile) // 更新用户信息
|
||||||
auth.PUT("/user/password", handlers.ChangePassword) // 修改密码
|
auth.PUT("/user/password", handlers.ChangePassword) // 修改密码
|
||||||
|
|
||||||
|
// 排行榜API
|
||||||
|
auth.GET("/ranking/daily", handlers.GetDailyRanking) // 获取今日排行榜
|
||||||
|
|
||||||
// 练习题相关API(需要登录)
|
// 练习题相关API(需要登录)
|
||||||
auth.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表
|
auth.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表
|
||||||
auth.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
|
auth.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
|
||||||
|
|||||||
@ -193,3 +193,10 @@ export const getAllUsersWithStats = () => {
|
|||||||
export const getUserDetailStats = (userId: number) => {
|
export const getUserDetailStats = (userId: number) => {
|
||||||
return request.get<ApiResponse<UserDetailStats>>(`/admin/users/${userId}`)
|
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)
|
// 响应式设计 - 移动端 (< 768px)
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.container {
|
.container {
|
||||||
@ -700,6 +876,88 @@
|
|||||||
font-size: 24px;
|
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)
|
// 响应式设计 - 平板 (769px - 1024px)
|
||||||
@ -747,6 +1005,14 @@
|
|||||||
font-size: 17px !important;
|
font-size: 17px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rankingSection {
|
||||||
|
margin-top: 28px;
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 17px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 响应式设计 - PC端宽屏 (> 1025px)
|
// 响应式设计 - PC端宽屏 (> 1025px)
|
||||||
@ -836,6 +1102,15 @@
|
|||||||
font-size: 20px !important;
|
font-size: 20px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rankingSection {
|
||||||
|
margin-top: 36px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 20px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 响应式设计 - 超宽屏 (> 1600px)
|
// 响应式设计 - 超宽屏 (> 1600px)
|
||||||
|
|||||||
@ -20,6 +20,8 @@ import {
|
|||||||
FormOutlined,
|
FormOutlined,
|
||||||
FileMarkdownOutlined,
|
FileMarkdownOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
|
TrophyOutlined,
|
||||||
|
CrownOutlined,
|
||||||
} 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'
|
||||||
@ -101,6 +103,10 @@ const Home: React.FC = () => {
|
|||||||
accuracy: 0,
|
accuracy: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 排行榜状态
|
||||||
|
const [dailyRanking, setDailyRanking] = useState<questionApi.UserStats[]>([])
|
||||||
|
const [rankingLoading, setRankingLoading] = useState(false)
|
||||||
|
|
||||||
// 答题设置状态
|
// 答题设置状态
|
||||||
const [autoNext, setAutoNext] = useState(() => {
|
const [autoNext, setAutoNext] = useState(() => {
|
||||||
const saved = localStorage.getItem('autoNextEnabled')
|
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(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
@ -151,6 +172,7 @@ const Home: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStatistics()
|
loadStatistics()
|
||||||
|
loadDailyRanking()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 处理用户类型更新
|
// 处理用户类型更新
|
||||||
@ -580,6 +602,67 @@ const Home: React.FC = () => {
|
|||||||
</Row>
|
</Row>
|
||||||
</div>
|
</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
|
<Modal
|
||||||
title="请选择您的身份类型"
|
title="请选择您的身份类型"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user