feat: add game library CRUD/import/export/favorites/comments, fix team creation

- Game library: add/delete games per group, JSON/CSV import/export, favorites, star ratings & comments
- Fix team session creation: add creator to members array, handle null currentGroup
- Fix image loading: rename SVG files from .png to .svg extensions
- Add PocketBase migrations for game_comments and game_favorites collections
- Remove seed data script

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-17 21:03:20 +08:00
parent 802712c662
commit 4b97c99e56
23 changed files with 996 additions and 348 deletions
+150 -132
View File
@@ -1,9 +1,15 @@
<!-- src/views/GamesLibrary.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getGames, getAllPlatforms } from '@/api/games'
import { ref, onMounted, computed } from 'vue'
import { useGroupStore } from '@/stores/group'
import { getGroupGames, deleteGame, exportGames, getAllPlatforms } from '@/api/games'
import type { Game, GamePlatform } from '@/types'
import GameDetailDialog from '@/components/game/GameDetailDialog.vue'
import AddGameDialog from '@/components/game/AddGameDialog.vue'
import ImportGamesDialog from '@/components/game/ImportGamesDialog.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const groupStore = useGroupStore()
const games = ref<Game[]>([])
const loading = ref(false)
@@ -12,16 +18,23 @@ const selectedPlatform = ref<GamePlatform | ''>('')
const platforms = getAllPlatforms()
const selectedGame = ref<Game | null>(null)
const showDetail = ref(false)
const showAddGame = ref(false)
const showImport = ref(false)
const currentGroupId = computed(() => groupStore.currentGroupId)
onMounted(async () => {
await loadGames()
if (currentGroupId.value) {
await loadGames()
}
})
async function loadGames() {
if (!currentGroupId.value) return
try {
loading.value = true
const result = await getGames({
limit: 50,
const result = await getGroupGames(currentGroupId.value, {
limit: 100,
search: searchQuery.value || undefined,
platform: selectedPlatform.value || undefined
})
@@ -33,174 +46,179 @@ async function loadGames() {
}
}
function handleSearch() {
loadGames()
}
function openGameDetail(game: Game) {
selectedGame.value = game
showDetail.value = true
}
function handleCreateTeam(_gameName: string) {
// TODO: 导航到群组页面触发组队流程
async function handleDeleteGame(game: Game) {
try {
await ElMessageBox.confirm(`确定要删除「${game.name}」吗?`, '确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await deleteGame(game.id)
ElMessage.success('删除成功')
await loadGames()
} catch { /* 用户取消 */ }
}
function handleExport() {
if (!currentGroupId.value) return
exportGames(currentGroupId.value).then(data => {
const json = JSON.stringify(data.map(g => ({
name: g.name,
platform: g.platform,
tags: g.tags,
cover: g.cover
})), null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `games-export-${currentGroupId.value}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${data.length} 个游戏`)
})
}
async function handleGameAdded() {
showAddGame.value = false
await loadGames()
}
async function handleImportComplete() {
showImport.value = false
await loadGames()
}
</script>
<template>
<div class="games-library">
<div class="page-header">
<h1>游戏库</h1>
<div class="filters">
<el-input
v-model="searchQuery"
placeholder="搜索游戏..."
clearable
@input="handleSearch"
/>
<el-select v-model="selectedPlatform" placeholder="选择平台" clearable @change="loadGames">
<el-option
v-for="platform in platforms"
:key="platform"
:label="platform"
:value="platform"
<!-- 无群组提示 -->
<div v-if="!currentGroupId" class="no-group">
<p class="no-group-text">请先选择一个群组</p>
<p class="no-group-hint">在左侧选择或创建群组后即可管理游戏库</p>
</div>
<template v-else>
<div class="page-header">
<h1>游戏库</h1>
<div class="toolbar">
<el-input
v-model="searchQuery"
placeholder="搜索游戏..."
clearable
style="width: 240px"
@input="loadGames"
/>
</el-select>
</div>
</div>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="games.length === 0" class="empty">
<p>暂无游戏</p>
</div>
<div v-else class="games-grid">
<div v-for="game in games" :key="game.id" class="game-card" @click="openGameDetail(game)">
<img
:src="game.cover || '/game-placeholder.png'"
:alt="game.name"
class="game-cover"
/>
<div class="game-info">
<h3 class="game-name">{{ game.name }}</h3>
<p class="game-platform">{{ game.platform }}</p>
<div class="game-tags">
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<el-select v-model="selectedPlatform" placeholder="平台" clearable style="width: 120px" @change="loadGames">
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
</el-select>
<button class="tool-btn primary" @click="showAddGame = true">添加游戏</button>
<button class="tool-btn" @click="showImport = true">导入</button>
<button class="tool-btn" @click="handleExport">导出</button>
</div>
</div>
</div>
<GameDetailDialog
v-model="showDetail"
:game="selectedGame"
@create-team="handleCreateTeam"
/>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="games.length === 0" class="empty">
<p class="empty-text">暂无游戏</p>
<p class="empty-hint">点击添加游戏导入开始管理游戏库</p>
</div>
<div v-else class="games-grid">
<div v-for="game in games" :key="game.id" class="game-card" @click="openGameDetail(game)">
<img :src="game.cover || '/game-placeholder.svg'" :alt="game.name" class="game-cover" />
<div class="game-info">
<h3 class="game-name">{{ game.name }}</h3>
<p class="game-platform">{{ game.platform }}</p>
<div class="game-tags">
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
</div>
<button class="delete-btn" @click.stop="handleDeleteGame(game)" title="删除"></button>
</div>
</div>
<GameDetailDialog v-model="showDetail" :game="selectedGame" :group-id="currentGroupId" @deleted="loadGames" />
<AddGameDialog v-model="showAddGame" :group-id="currentGroupId" @created="handleGameAdded" />
<ImportGamesDialog v-model="showImport" :group-id="currentGroupId" @imported="handleImportComplete" />
</template>
</div>
</template>
<style scoped>
.games-library {
width: 100%;
}
.games-library { width: 100%; }
.page-header {
margin-bottom: 28px;
.no-group {
text-align: center;
padding: 120px 32px;
}
.no-group-text { font-size: 18px; font-weight: 600; color: var(--gg-text-secondary); margin: 0 0 8px; }
.no-group-hint { font-size: 14px; color: var(--gg-text-muted); margin: 0; }
.page-header { margin-bottom: 24px; }
.page-header h1 {
font-size: 28px;
font-weight: 700;
margin: 0 0 16px;
background: var(--gg-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-size: 28px; font-weight: 700; margin: 0 0 16px;
background: var(--gg-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.filters {
display: flex;
gap: 12px;
}
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.filters .el-input {
width: 300px;
.tool-btn {
padding: 8px 18px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
background: var(--gg-bg-card); color: var(--gg-text-secondary); font-size: 13px; font-weight: 500;
cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.tool-btn:hover { border-color: var(--gg-primary); color: var(--gg-primary); }
.tool-btn.primary {
background: var(--gg-gradient-green); border-color: transparent; color: white;
}
.tool-btn.primary:hover { opacity: 0.9; }
.loading {
text-align: center;
padding: 80px 32px;
color: var(--gg-text-muted);
font-size: 16px;
}
.loading { text-align: center; padding: 80px 32px; color: var(--gg-text-muted); }
.empty {
text-align: center;
padding: 80px 32px;
color: var(--gg-text-muted);
font-size: 15px;
}
.empty { text-align: center; padding: 80px 32px; }
.empty-text { font-size: 16px; font-weight: 500; color: var(--gg-text-secondary); margin: 0 0 8px; }
.empty-hint { font-size: 14px; color: var(--gg-text-muted); margin: 0; }
.games-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px;
}
.game-card {
border-radius: var(--gg-radius-md);
overflow: hidden;
cursor: pointer;
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
position: relative; border-radius: var(--gg-radius-md); overflow: hidden; cursor: pointer;
background: var(--gg-bg-card); border: 1px solid var(--gg-border);
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
}
.game-card:hover { transform: translateY(-4px); border-color: var(--gg-primary); box-shadow: 0 4px 20px rgba(5, 150, 105, 0.15); }
.game-card:hover {
transform: translateY(-4px);
border-color: var(--gg-primary);
box-shadow: 0 4px 20px rgba(5, 150, 105, 0.15);
}
.game-cover { width: 100%; aspect-ratio: 3/4; object-fit: cover; }
.game-cover {
width: 100%;
aspect-ratio: 3/4;
object-fit: cover;
}
.game-info {
padding: 14px;
}
.game-info { padding: 14px; }
.game-name {
font-size: 16px;
font-weight: 700;
margin: 0 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--gg-text);
}
.game-platform {
font-size: 13px;
color: var(--gg-text-secondary);
margin: 0 0 8px;
}
.game-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
font-size: 16px; font-weight: 700; margin: 0 0 4px; color: var(--gg-text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.game-platform { font-size: 13px; color: var(--gg-text-secondary); margin: 0 0 8px; }
.game-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.tag {
padding: 3px 10px;
background: var(--gg-bg-elevated);
border-radius: 6px;
font-size: 11px;
color: var(--gg-primary);
font-weight: 500;
padding: 3px 10px; background: var(--gg-bg-elevated); border-radius: 6px;
font-size: 11px; color: var(--gg-primary); font-weight: 500;
}
.delete-btn {
position: absolute; top: 8px; right: 8px; width: 28px; height: 28px;
border: none; border-radius: 50%; background: rgba(0,0,0,0.5); color: white;
font-size: 12px; cursor: pointer; opacity: 0; transition: opacity 0.2s;
display: flex; align-items: center; justify-content: center;
}
.game-card:hover .delete-btn { opacity: 1; }
.delete-btn:hover { background: var(--gg-danger); }
</style>
+1 -1
View File
@@ -153,7 +153,7 @@ async function refreshMembers() {
<div v-if="list.length === 0" class="empty-row">暂无</div>
<div v-else class="member-row-list">
<div v-for="m in list" :key="m.id" class="member-row">
<img :src="m.avatar || '/default-avatar.png'" :alt="m.username" class="avatar" />
<img :src="m.avatar || '/default-avatar.svg'" :alt="m.username" class="avatar" />
<span class="member-name">{{ m.username }}</span>
<span v-if="m.statusNote" class="member-note">{{ m.statusNote }}</span>
</div>
+1 -1
View File
@@ -137,7 +137,7 @@ function openGameDetail(game: Game) {
>
<div class="game-cover-wrap">
<img
:src="game.cover || '/game-placeholder.png'"
:src="game.cover || '/game-placeholder.svg'"
:alt="game.name"
class="game-cover"
/>
+1 -1
View File
@@ -106,7 +106,7 @@ function goHome() {
</div>
<div class="user-info">
<img
:src="userStore.user?.avatar || '/default-avatar.png'"
:src="userStore.user?.avatar || '/default-avatar.svg'"
class="user-avatar"
alt=""
/>
+1 -1
View File
@@ -36,7 +36,7 @@ function handleAvatarUpload() {
<el-card class="profile-card">
<div class="profile-form">
<div class="avatar-section">
<img :src="avatar || '/default-avatar.png'" class="avatar" />
<img :src="avatar || '/default-avatar.svg'" class="avatar" />
<el-button @click="handleAvatarUpload">更换头像</el-button>
</div>