概述
为配合前台游戏展示页面,在管理后台系统中新增了 游戏管理 功能模块,支持游戏的增删改查、封面图片上传、数据持久化到 TOML 文件。本文详解后端 Go API 和前端 Vue SPA 的完整实现方案。
架构概览
1┌─ 前端 (Vue 3 + Element Plus) ─────────────────┐
2│ admin/frontend/src/views/Game/Index.vue │
3│ admin/frontend/src/api/index.js │
4│ admin/frontend/src/router/index.js │
5│ admin/frontend/src/layouts/MainLayout.vue │
6└────────────────────┬───────────────────────────┘
7 │ HTTP (Bearer Token 鉴权)
8 ▼
9┌─ 后端 (Go + Gin) ─────────────────────────────┐
10│ admin/backend/internal/handler/game.go │
11│ admin/backend/internal/handler/router.go │
12└────────────────────┬───────────────────────────┘
13 │ TOML 序列化 / 反序列化
14 ▼
15 data/games.toml
API 端点注册在 Gin 路由组 /api/games 下,由 JWT 认证中间件保护。
Go 后端实现
数据结构
1type Game struct {
2 Title string `json:"title" toml:"title"`
3 Cover string `json:"cover,omitempty" toml:"cover,omitempty"`
4 Status string `json:"status" toml:"status"`
5 Rating float64 `json:"rating,omitempty" toml:"rating,omitempty"`
6 Platform string `json:"platform,omitempty" toml:"platform,omitempty"`
7 Developer string `json:"developer,omitempty" toml:"developer,omitempty"`
8 Publisher string `json:"publisher,omitempty" toml:"publisher,omitempty"`
9 ReleaseYear int `json:"releaseYear,omitempty" toml:"releaseYear,omitempty"`
10 Genre []string `json:"genre,omitempty" toml:"genre,omitempty"`
11 Desc string `json:"desc,omitempty" toml:"desc,omitempty"`
12 Links []GameLink `json:"links,omitempty" toml:"links"`
13}
每个字段同时标记 json 和 toml tag,实现 JSON ↔ 结构体 ↔ TOML 的双向转换。omitempty 确保未填的可选字段不会写入文件。
API 端点
| 方法 | 路径 | 功能 |
|---|---|---|
GET | /api/games | 读取 data/games.toml,返回游戏数组 |
PUT | /api/games | 全量替换游戏列表,写入 TOML 文件 |
采用 全量读写 策略(而非 PATCH 单条更新):前端维护完整的游戏数组,每次修改后发送完整数据覆盖文件。这种方式简化了并发控制逻辑,适合数据量较小的场景。
TOML 序列化
writeGameToml 函数不依赖第三方 TOML 库的 Marshal 功能,而是手动拼接 TOML 格式字符串。这样做的优势:
- 可读性:生成的 TOML 文件带注释和缩进,可直接手动编辑
- 字段控制:精确控制每个字段的写入顺序和格式
- 可选字段:
omitempty字段不写入,保持文件简洁
1sb.WriteString("[[games]]\n")
2sb.WriteString(fmt.Sprintf(" title = %s\n", strconvQuote(g.Title)))
3sb.WriteString(fmt.Sprintf(" status = %s\n", strconvQuote(g.Status)))
4if g.Rating > 0 {
5 sb.WriteString(fmt.Sprintf(" rating = %.1f\n", g.Rating))
6}
路由注册
在 router.go 中将游戏路由插入到图书和音乐之间,保持管理功能按逻辑分组:
1gamesGroup := api.Group("/games")
2{
3 gamesGroup.GET("", getGames(cfg))
4 gamesGroup.PUT("", updateGames(cfg))
5}
登录路由修复
在实现过程中发现原有 login 和 health 路由被错误地放在 RequireAuth() 中间件组内,导致"需要登录才能登录"的死循环。将其移出认证组:
1r.POST("/api/auth/login", middleware.LoginRateLimit(), login(cfg))
2r.GET("/api/health", healthCheck)
3
4api := r.Group("/api")
5api.Use(authMW.RequireAuth())
6{
7 // 受保护的 API 路由
8}
Vue 前端实现
组件结构
views/Game/Index.vue 是一个自包含的单文件组件(SFC),包含模板、逻辑和样式三个部分,约 520 行代码。
核心状态管理
1const games = ref([]) // 游戏数据数组
2const dialogVisible = ref(false) // 弹窗开关
3const editIndex = ref(-1) // 编辑索引(-1 表示新增)
4const form = ref({
5 title: '', status: 'planned', cover: '', rating: 0,
6 platform: '', developer: '', publisher: '',
7 releaseYear: 0, genre: [], desc: '', links: []
8})
操作流程
- 加载:
onMounted→getGames()→ 渲染卡片网格 - 新增:点击"添加游戏" → 弹出表单弹窗 → 填写后
games.push→saveAll - 编辑:点击卡片编辑图标 → 回填表单 → 修改后
games[index] = form→saveAll - 删除:点击删除图标 →
ElPopconfirm确认 →games.splice→saveAll - 保存:
saveAll()→saveGames({ games })→ 后端 PUT/api/games
封面管理
提供四种封面来源,参考了电影管理模块的实现模式:
| 方式 | 实现 | 适用场景 |
|---|---|---|
| 上传图片 | el-upload + uploadImage() | 本地临时图片 |
| 本地路径 | 弹窗输入 /images/games/xxx.webp | 已部署的静态资源 |
| 图床链接 | 弹窗输入 URL | 外部 CDN 图片 |
| 上传到图床 | el-upload + uploadImageToHosting() | 上传并自动获取 CDN 链接 |
1<el-upload :http-request="handleHostingUpload" :before-upload="beforeCoverUpload">
2 <div class="card-inner">
3 <div class="card-icon hosting-icon-wrap"><el-icon><UploadFilled /></el-icon></div>
4 <div class="card-title">上传到图床</div>
5 </div>
6</el-upload>
平台与类型
提供预设选项的同时支持 filterable + allow-create,用户可以输入自定义值:
1<el-select v-model="form.platform" filterable allow-create default-first-option>
2 <el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
3</el-select>
预设平台:PC, PS5, PS4, Xbox Series X/S, Xbox One, Switch, iOS, Android, Steam Deck
预设类型:动作RPG, 开放世界, 策略, CRPG, Roguelike, 类银河城, 独立游戏 等 20+ 种
API 接口层
1// admin/frontend/src/api/index.js
2export function getGames() {
3 return request.get('/games')
4}
5
6export function saveGames(data) {
7 return request.put('/games', data)
8}
请求自动附带 Authorization: Bearer <token> 头(由 axios 拦截器注入)。
路由与菜单
1// router/index.js
2{
3 path: 'games',
4 name: 'Games',
5 component: () => import('@/views/Game/Index.vue'),
6 meta: { title: '游戏管理' }
7}
侧边栏菜单使用 Monitor 图标(Element Plus 中与游戏最相近的可用图标),避免与前台 fa-gamepad 的直接复用。
暗黑模式样式
Vue 组件使用 [data-theme="dark"] 选择器覆盖所有关键元素的背景、文字和边框颜色:
1[data-theme="dark"] {
2 .game-card {
3 background: rgba(255, 255, 255, 0.03);
4 border-color: rgba(255, 255, 255, 0.06);
5 }
6 .cover-options .option-card {
7 background: #1e1e32;
8 border-color: rgba(255, 255, 255, 0.08);
9 }
10}
涉及的所有文件
| 层 | 文件 | 操作 |
|---|---|---|
| 后端 | admin/backend/internal/handler/game.go | 新建 |
| 后端 | admin/backend/internal/handler/router.go | 修改(添加路由 + 修复登录) |
| 前端 | admin/frontend/src/views/Game/Index.vue | 新建 |
| 前端 | admin/frontend/src/api/index.js | 修改(添加 API 函数) |
| 前端 | admin/frontend/src/router/index.js | 修改(添加路由) |
| 前端 | admin/frontend/src/layouts/MainLayout.vue | 修改(添加菜单) |
| 前端 | admin/frontend/src/views/Login.vue | 修改(修复 redirect) |
| 前端 | admin/frontend/vite.config.js | 修改(修复代理端口) |
技术要点总结
- 全量读写 适合小数据量的管理页面,实现简单、无需 ID 管理
- 手动 TOML 拼接 在可读性和格式控制上优于自动化序列化
- Vue 响应式数组 操作(push/splice/索引赋值)配合批量保存,用户体验流畅
- Element Plus 组件 的
filterable+allow-create模式兼顾了便捷输入和规范性 - 封面管理四合一 设计覆盖了从临时上传到 CDN 部署的完整场景
留言评论
期待你的想法评论加载中