# 📋 实施计划:扫雷排行榜管理后台 + 去除免费模式 ## 背景理解 用户说明: 1. **没有免费模式**:`minesweeper_free` 这个 game_type 已废弃,前后端都要移除 2. **排行榜需要在管理后台展示**:当前排行榜只有 App 端接口,管理后台缺少排行榜 Tab 3. **"积分"含义模糊**:排行榜里的 `total_rank_points` 字段是"游戏对战分",不是平台积分(points),页面上要加说明 --- ## 任务类型 - [x] 全栈(后端 + 前端并行) --- ## 技术方案 ### 后端 在 `internal/api/game/handler.go` 新增一个 Admin 专用排行榜接口: - `GET /api/admin/games/leaderboard` — 管理后台查排行榜(分页、支持搜索用户昵称) - `GET /api/admin/games/records` — 管理后台查每局对战记录(分页、支持按用户/时间筛选) 两个接口都走读库,无需鉴权以外的特殊处理。 ### 前端 在 `web/admin/src/views/operations/minesweeper/index.vue` 新增两个 Tab: - **排行榜 Tab**:表格展示所有玩家的排行数据,含"对战分"说明 - **对战记录 Tab**:按局查每场游戏的明细 同时去掉前端中所有 `minesweeper_free` 的相关逻辑和 `game_type` 切换选项。 --- ## 实施步骤 ### Step 1 — 后端:新增 Admin 排行榜接口 **文件**: `internal/api/game/handler.go`(在现有 Admin API 区域末尾追加) ```go // GetAdminLeaderboard Admin查询扫雷排行榜 // @Router /api/admin/games/leaderboard [get] func (h *handler) GetAdminLeaderboard() core.HandlerFunc { return func(ctx core.Context) { var req struct { Page int `form:"page"` PageSize int `form:"page_size"` Nickname string `form:"nickname"` // 可选:按昵称模糊搜索 } _ = ctx.ShouldBindQuery(&req) if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 || req.PageSize > 100 { req.PageSize = 20 } offset := (req.Page - 1) * req.PageSize type row struct { UserID int64 `json:"user_id"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` TotalRankPoints int64 `json:"total_rank_points"` MatchesPlayed int `json:"matches_played"` Wins int `json:"wins"` Losses int `json:"losses"` WinRate float64 `json:"win_rate"` BestScore int `json:"best_score"` AvgScore float64 `json:"avg_score"` } query := h.db.GetDbR().Table("minesweeper_leaderboard l"). Select("l.user_id, COALESCE(u.nick_name,'') AS nickname, COALESCE(u.avatar_url,'') AS avatar, l.total_rank_points, l.matches_played, l.wins, l.losses, CAST(l.win_rate AS DECIMAL(7,4)) AS win_rate, l.best_score, CAST(l.avg_score AS DECIMAL(12,2)) AS avg_score"). Joins("LEFT JOIN users u ON u.id = l.user_id"). Where("l.game_type = ?", "minesweeper") if req.Nickname != "" { query = query.Where("u.nick_name LIKE ?", "%"+req.Nickname+"%") } var total int64 query.Count(&total) var rows []row query.Order("l.total_rank_points DESC, l.wins DESC, l.best_score DESC"). Limit(req.PageSize).Offset(offset).Scan(&rows) // 补名次 list := make([]map[string]any, 0, len(rows)) for i, r := range rows { list = append(list, map[string]any{ "rank": offset + i + 1, "user_id": r.UserID, "nickname": r.Nickname, "avatar": r.Avatar, "total_rank_points": r.TotalRankPoints, "matches_played": r.MatchesPlayed, "wins": r.Wins, "losses": r.Losses, "win_rate": r.WinRate, "best_score": r.BestScore, "avg_score": r.AvgScore, }) } ctx.Payload(map[string]any{ "total": total, "page": req.Page, "page_size": req.PageSize, "list": list, }) } } // GetAdminGameRecords Admin查询扫雷对战记录 // @Router /api/admin/games/records [get] func (h *handler) GetAdminGameRecords() core.HandlerFunc { return func(ctx core.Context) { var req struct { Page int `form:"page"` PageSize int `form:"page_size"` UserID int64 `form:"user_id"` MatchID string `form:"match_id"` } _ = ctx.ShouldBindQuery(&req) if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 || req.PageSize > 100 { req.PageSize = 20 } offset := (req.Page - 1) * req.PageSize type row struct { ID int64 `json:"id"` MatchID string `json:"match_id"` UserID int64 `json:"user_id"` Nickname string `json:"nickname"` IsWinner bool `json:"is_winner"` RankPosition int `json:"rank_position"` TotalPlayers int `json:"total_players"` Score int `json:"score"` DamageDealt int `json:"damage_dealt"` ChestsCollected int `json:"chests_collected"` RankPoints int `json:"rank_points"` SettledAt string `json:"settled_at"` } query := h.db.GetDbR().Table("minesweeper_game_records r"). Select("r.id, r.match_id, r.user_id, COALESCE(u.nick_name,'') AS nickname, r.is_winner, r.rank_position, r.total_players, r.score, r.damage_dealt, r.chests_collected, r.rank_points, r.settled_at"). Joins("LEFT JOIN users u ON u.id = r.user_id"). Where("r.game_type = ?", "minesweeper") if req.UserID > 0 { query = query.Where("r.user_id = ?", req.UserID) } if req.MatchID != "" { query = query.Where("r.match_id = ?", req.MatchID) } var total int64 query.Count(&total) var rows []row query.Order("r.settled_at DESC").Limit(req.PageSize).Offset(offset).Scan(&rows) ctx.Payload(map[string]any{ "total": total, "page": req.Page, "page_size": req.PageSize, "list": rows, }) } } ``` ### Step 2 — 后端:注册新路由 **文件**: `internal/router/router.go` 在 admin 认证路由区域找到 game 相关路由,追加: ```go adminAuthApiRouter.GET("/games/leaderboard", gameHandler.GetAdminLeaderboard()) adminAuthApiRouter.GET("/games/records", gameHandler.GetAdminGameRecords()) ``` ### Step 3 — 后端:SettleGame 去掉免费模式分支 **文件**: `internal/api/game/handler.go` `isFreeMode` 判断相关逻辑仍可保留(对 `minesweeper` 类型无影响),但移除文档/注释中所有 `minesweeper_free` 提及。 实际上后端逻辑本身没问题,如果 Nakama 不再发送 `minesweeper_free` 类型就不会触发,无需修改业务逻辑。 ### Step 4 — 前端:在 index.vue 新增"排行榜"Tab **文件**: `web/admin/src/views/operations/minesweeper/index.vue` #### 4.1 在 `` 中新增两个 Tab pane(追加在"配置预览"之前) ```html
刷新
刷新
``` #### 4.2 在 `