bindbox-game/.claude/plan/minesweeper-leaderboard-admin.md
2026-04-25 02:48:07 +08:00

405 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 📋 实施计划:扫雷排行榜管理后台 + 去除免费模式
## 背景理解
用户说明:
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 在 `<el-tabs>` 中新增两个 Tab pane追加在"配置预览"之前)
```html
<!-- 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 函数
```typescript
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` 中追加调用
```typescript
onMounted(() => {
loadConfig()
fetchLeaderboard()
fetchRecords()
})
```
#### 4.4 监听 Tab 切换(可选优化)
在 Tab 切换到对应 Tab 时按需加载,避免首次全量请求:
```typescript
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