405 lines
14 KiB
Markdown
405 lines
14 KiB
Markdown
# 📋 实施计划:扫雷排行榜管理后台 + 去除免费模式
|
||
|
||
## 背景理解
|
||
|
||
用户说明:
|
||
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
|