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

14 KiB
Raw Blame History

📋 实施计划:扫雷排行榜管理后台 + 去除免费模式

背景理解

用户说明:

  1. 没有免费模式minesweeper_free 这个 game_type 已废弃,前后端都要移除
  2. 排行榜需要在管理后台展示:当前排行榜只有 App 端接口,管理后台缺少排行榜 Tab
  3. "积分"含义模糊:排行榜里的 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