14 KiB
14 KiB
📋 实施计划:扫雷排行榜管理后台 + 去除免费模式
背景理解
用户说明:
- 没有免费模式:
minesweeper_free这个 game_type 已废弃,前后端都要移除 - 排行榜需要在管理后台展示:当前排行榜只有 App 端接口,管理后台缺少排行榜 Tab
- "积分"含义模糊:排行榜里的
total_rank_points字段是"游戏对战分",不是平台积分(points),页面上要加说明
任务类型
- 全栈(后端 + 前端并行)
技术方案
后端
在 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 区域末尾追加)
// 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 相关路由,追加:
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 在 <el-tabs> 中新增两个 Tab pane(追加在"配置预览"之前)
<!-- 5. 排行榜 -->
<el-tab-pane label="排行榜" name="leaderboard">
<div class="tab-content">
<el-alert
title="对战分说明:对战分是游戏内部的排名积分,与平台积分(商城积分/兑换积分)无关。赢得对局可获得更多对战分,用于在此排行榜中排名。"
type="info"
:closable="false"
class="mb-4"
show-icon
/>
<div class="flex gap-2 mb-4">
<el-input
v-model="lbSearch"
placeholder="搜索玩家昵称"
clearable
style="width: 240px"
@change="fetchLeaderboard"
/>
<el-button @click="fetchLeaderboard">刷新</el-button>
</div>
<el-table :data="lbList" border stripe v-loading="lbLoading">
<el-table-column label="排名" width="70" align="center">
<template #default="scope">
<span :class="scope.row.rank <= 3 ? 'font-bold text-yellow-600' : ''">
{{ scope.row.rank }}
</span>
</template>
</el-table-column>
<el-table-column label="玩家" min-width="140">
<template #default="scope">
<div class="flex items-center gap-2">
<el-avatar :src="scope.row.avatar" :size="28" v-if="scope.row.avatar" />
<span>{{ scope.row.nickname || scope.row.user_id }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="对战分" prop="total_rank_points" width="100" align="right" sortable />
<el-table-column label="场次" prop="matches_played" width="80" align="center" />
<el-table-column label="胜场" prop="wins" width="70" align="center" />
<el-table-column label="胜率" width="80" align="center">
<template #default="scope">{{ (scope.row.win_rate * 100).toFixed(1) }}%</template>
</el-table-column>
<el-table-column label="最高分" prop="best_score" width="90" align="right" />
<el-table-column label="平均分" prop="avg_score" width="90" align="right">
<template #default="scope">{{ Number(scope.row.avg_score).toFixed(1) }}</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-3">
<el-pagination
v-model:current-page="lbPage"
v-model:page-size="lbPageSize"
:total="lbTotal"
layout="total, prev, pager, next"
@current-change="fetchLeaderboard"
/>
</div>
</div>
</el-tab-pane>
<!-- 6. 对战记录 -->
<el-tab-pane label="对战记录" name="records">
<div class="tab-content">
<div class="flex gap-2 mb-4">
<el-input
v-model="recUserID"
placeholder="按用户ID筛选"
clearable
style="width: 180px"
@change="fetchRecords"
/>
<el-input
v-model="recMatchID"
placeholder="按局ID筛选"
clearable
style="width: 260px"
@change="fetchRecords"
/>
<el-button @click="fetchRecords">刷新</el-button>
</div>
<el-table :data="recList" border stripe v-loading="recLoading" size="small">
<el-table-column label="局ID" prop="match_id" min-width="200" show-overflow-tooltip />
<el-table-column label="玩家" min-width="120">
<template #default="scope">{{ scope.row.nickname || scope.row.user_id }}</template>
</el-table-column>
<el-table-column label="结果" width="70" align="center">
<template #default="scope">
<el-tag :type="scope.row.is_winner ? 'success' : 'info'" size="small">
{{ scope.row.is_winner ? '胜' : '败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="名次" prop="rank_position" width="65" align="center" />
<el-table-column label="总人数" prop="total_players" width="75" align="center" />
<el-table-column label="得分" prop="score" width="80" align="right" />
<el-table-column label="对战分" prop="rank_points" width="85" align="right" />
<el-table-column label="结算时间" prop="settled_at" width="160" />
</el-table>
<div class="flex justify-end mt-3">
<el-pagination
v-model:current-page="recPage"
v-model:page-size="recPageSize"
:total="recTotal"
layout="total, prev, pager, next"
@current-change="fetchRecords"
/>
</div>
</div>
</el-tab-pane>
4.2 在 <script setup> 中追加响应式数据和 API 函数
import request from '@/utils/http'
// 排行榜
const lbSearch = ref('')
const lbLoading = ref(false)
const lbList = ref<any[]>([])
const lbTotal = ref(0)
const lbPage = ref(1)
const lbPageSize = ref(20)
const fetchLeaderboard = async () => {
lbLoading.value = true
try {
const res = await request.get('/admin/games/leaderboard', {
params: { page: lbPage.value, page_size: lbPageSize.value, nickname: lbSearch.value }
})
lbList.value = res.list || []
lbTotal.value = res.total || 0
} finally {
lbLoading.value = false
}
}
// 对战记录
const recUserID = ref('')
const recMatchID = ref('')
const recLoading = ref(false)
const recList = ref<any[]>([])
const recTotal = ref(0)
const recPage = ref(1)
const recPageSize = ref(20)
const fetchRecords = async () => {
recLoading.value = true
try {
const res = await request.get('/admin/games/records', {
params: {
page: recPage.value,
page_size: recPageSize.value,
user_id: recUserID.value || undefined,
match_id: recMatchID.value || undefined,
}
})
recList.value = res.list || []
recTotal.value = res.total || 0
} finally {
recLoading.value = false
}
}
4.3 在 onMounted 中追加调用
onMounted(() => {
loadConfig()
fetchLeaderboard()
fetchRecords()
})
4.4 监听 Tab 切换(可选优化)
在 Tab 切换到对应 Tab 时按需加载,避免首次全量请求:
watch(activeTab, (val) => {
if (val === 'leaderboard') fetchLeaderboard()
if (val === 'records') fetchRecords()
})
关键文件汇总
| 文件 | 操作 | 说明 |
|---|---|---|
internal/api/game/handler.go |
修改 | 新增 GetAdminLeaderboard() 和 GetAdminGameRecords() 两个函数 |
internal/router/router.go |
修改 | 注册两条新路由 |
web/admin/src/views/operations/minesweeper/index.vue |
修改 | 新增排行榜和对战记录两个 Tab + script 逻辑 |
注意事项
- "积分"含义:页面上
total_rank_points显示为"对战分",并加 Alert 说明,避免与平台积分(points/兑换币)混淆 - 不需要新建文件,全部在现有文件中追加
- 后端无需修改任何免费模式的业务逻辑;前端 Tab 中不再展示 game_type 切换选项即可
activeTab初始值改为'board'(已经是),排行榜 Tab 不作为默认 Tab- 分页默认每页 20 条
SESSION_ID
- CODEX_SESSION: N/A(本次直接由 Claude 规划,无外部模型调用)
- GEMINI_SESSION: N/A