feat: game aliases + edit permissions + scoped game select (v0.3.6)

- Add aliases field to games (json array), searchable in game library and team session
- Add edit functionality inside GameDetailDialog (creator/owner/admin can modify)
- Add permission control for delete/edit buttons (only visible to authorized users)
- Scope GameSelectDialog to current group only
- Support aliases in import/export

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-24 16:33:08 +08:00
parent f3fe7d4f2d
commit d76ecb15d6
12 changed files with 469 additions and 17 deletions
@@ -0,0 +1,18 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
collection.listRule = "@request.auth.id != \"\" && group.members ~ @request.auth.id"
collection.viewRule = "@request.auth.id != \"\" && group.members ~ @request.auth.id"
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
collection.listRule = "group.owner = @request.auth.id || group.members.id = @request.auth.id"
collection.viewRule = "group.owner = @request.auth.id || group.members.id = @request.auth.id"
return dao.saveCollection(collection)
})
@@ -0,0 +1,37 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
// add aliases field (json array of strings)
collection.schema.addField(new SchemaField({
"system": false,
"id": "aliases_field",
"name": "aliases",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 5000
}
}))
// update rules: allow group admins to update/delete
collection.updateRule = "group.owner = @request.auth.id || addedBy = @request.auth.id || group.admins ~ @request.auth.id"
collection.deleteRule = "group.owner = @request.auth.id || addedBy = @request.auth.id || group.admins ~ @request.auth.id"
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
// remove aliases field
collection.schema.removeField("aliases_field")
// restore original rules
collection.updateRule = "group.owner = @request.auth.id || addedBy = @request.auth.id"
collection.deleteRule = "group.owner = @request.auth.id || addedBy = @request.auth.id"
return dao.saveCollection(collection)
})
+30 -2
View File
@@ -17,7 +17,7 @@ export async function getGroupGames(groupId: string, options?: {
const { page = 1, limit = 50, platform, search } = options || {}
let filter = `group="${groupId}"`
if (platform) filter += ` && platform="${platform}"`
if (search) filter += ` && name ~ "${search}"`
if (search) filter += ` && (name ~ "${search}" || aliases ~ "${search}")`
const result = await pb.collection('games').getList(page, limit, {
filter,
@@ -30,6 +30,7 @@ export async function getGroupGames(groupId: string, options?: {
// 添加游戏到群组
export async function addGame(groupId: string, data: {
name: string
aliases?: string[]
platform?: GamePlatform
tags?: string[]
coverFile?: File
@@ -41,12 +42,39 @@ export async function addGame(groupId: string, data: {
formData.append('addedBy', user?.id || '')
formData.append('popularCount', '0')
if (data.platform) formData.append('platform', data.platform)
if (data.aliases && data.aliases.length > 0) formData.append('aliases', JSON.stringify(data.aliases))
if (data.tags && data.tags.length > 0) formData.append('tags', JSON.stringify(data.tags))
if (data.coverFile) formData.append('cover', data.coverFile)
return pb.collection('games').create(formData, { $autoCancel: false })
}
// 更新游戏
export async function updateGame(gameId: string, data: {
name?: string
aliases?: string[]
platform?: GamePlatform | ''
tags?: string[]
coverFile?: File
}) {
const formData = new FormData()
if (data.name !== undefined) formData.append('name', data.name)
if (data.aliases && data.aliases.length > 0) {
formData.append('aliases', JSON.stringify(data.aliases))
} else {
formData.append('aliases', '[]')
}
if (data.platform !== undefined) formData.append('platform', data.platform || '')
if (data.tags && data.tags.length > 0) {
formData.append('tags', JSON.stringify(data.tags))
} else {
formData.append('tags', '[]')
}
if (data.coverFile) formData.append('cover', data.coverFile)
return pb.collection('games').update(gameId, formData, { $autoCancel: false })
}
// 删除游戏
export async function deleteGame(gameId: string) {
return pb.collection('games').delete(gameId, { $autoCancel: false })
@@ -146,7 +174,7 @@ export function getAllPlatforms(): GamePlatform[] {
// 搜索游戏(用于 GameSelectDialog,全局搜索或群组内搜索)
export async function searchGames(query: string, groupId?: string, limit = 20): Promise<Game[]> {
if (!query.trim()) return []
let filter = `name ~ "${query}"`
let filter = `(name ~ "${query}" || aliases ~ "${query}")`
if (groupId) filter += ` && group="${groupId}"`
const result = await pb.collection('games').getList(1, limit, {
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import type { Game } from '@/types'
import { toggleFavorite, isFavorite, deleteGame, getGameCoverUrl } from '@/api/games'
import { computed, ref, onMounted, watch } from 'vue'
import type { Game, GamePlatform } from '@/types'
import { toggleFavorite, isFavorite, deleteGame, updateGame, getGameCoverUrl, getAllPlatforms } from '@/api/games'
import { useGroupStore } from '@/stores/group'
import { pb } from '@/api/pocketbase'
import { ElMessage, ElMessageBox } from 'element-plus'
import { TrendCharts, StarFilled, Star, Delete } from '@element-plus/icons-vue'
import { TrendCharts, StarFilled, Star, Delete, Edit, Plus } from '@element-plus/icons-vue'
import type { UploadFile, UploadInstance } from 'element-plus'
import GameComments from './GameComments.vue'
const props = defineProps<{
@@ -18,12 +21,40 @@ const emit = defineEmits<{
'deleted': []
}>()
const groupStore = useGroupStore()
const platforms = getAllPlatforms()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const favorited = ref(false)
const editing = ref(false)
const editName = ref('')
const editAliases = ref('')
const editPlatform = ref<GamePlatform | ''>('')
const editTags = ref('')
const editCoverFile = ref<File | null>(null)
const editCoverPreview = ref('')
const uploadRef = ref<UploadInstance>()
const saving = ref(false)
const canModify = computed(() => {
if (!props.game) return false
const userId = pb.authStore.model?.id
if (!userId) return false
if (groupStore.isGroupOwner) return true
if (groupStore.isGroupAdmin) return true
if (props.game.addedBy === userId) return true
return false
})
watch(() => props.modelValue, (val) => {
if (val) {
editing.value = false
}
})
onMounted(async () => {
if (props.game) {
@@ -31,6 +62,59 @@ onMounted(async () => {
}
})
function startEdit() {
if (!props.game) return
editName.value = props.game.name
editAliases.value = (props.game.aliases || []).join(', ')
editPlatform.value = props.game.platform || ''
editTags.value = (props.game.tags || []).join(', ')
editCoverFile.value = null
editCoverPreview.value = getGameCoverUrl(props.game) || ''
editing.value = true
}
function cancelEdit() {
editing.value = false
}
function handleEditFileChange(uploadFile: UploadFile) {
if (uploadFile.raw) {
editCoverFile.value = uploadFile.raw
editCoverPreview.value = URL.createObjectURL(uploadFile.raw)
}
}
function handleEditFileRemove() {
editCoverFile.value = null
editCoverPreview.value = getGameCoverUrl(props.game!) || ''
}
async function handleSave() {
if (!props.game || !editName.value.trim()) {
ElMessage.warning('请输入游戏名称')
return
}
try {
saving.value = true
const aliases = editAliases.value.split(',').map(t => t.trim()).filter(Boolean)
const tags = editTags.value.split(',').map(t => t.trim()).filter(Boolean)
await updateGame(props.game.id, {
name: editName.value.trim(),
aliases: aliases.length > 0 ? aliases : undefined,
platform: editPlatform.value || undefined,
tags: tags.length > 0 ? tags : undefined,
coverFile: editCoverFile.value || undefined
})
ElMessage.success('修改成功')
editing.value = false
emit('deleted') // trigger reload
} catch (error: any) {
ElMessage.error(error.message || '修改失败')
} finally {
saving.value = false
}
}
async function handleFavorite() {
if (!props.game) return
favorited.value = await toggleFavorite(props.game.id)
@@ -60,13 +144,66 @@ function handleCreateTeam() {
<template>
<el-dialog v-model="visible" width="480px" :show-close="true">
<template v-if="game">
<div class="game-detail">
<!-- 编辑模式 -->
<div v-if="editing" class="game-detail">
<div class="form-fields">
<div class="field">
<label>游戏名称 *</label>
<el-input v-model="editName" placeholder="输入游戏名称" maxlength="100" />
</div>
<div class="field">
<label>别名</label>
<el-input v-model="editAliases" placeholder="逗号分隔,如: LOL,英雄联盟,撸啊撸" />
</div>
<div class="field">
<label>平台</label>
<el-select v-model="editPlatform" placeholder="选择平台" clearable style="width: 100%">
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
</el-select>
</div>
<div class="field">
<label>标签</label>
<el-input v-model="editTags" placeholder="逗号分隔,如: MOBA,竞技,5v5" />
</div>
<div class="field">
<label>封面图</label>
<el-upload
ref="uploadRef"
:auto-upload="false"
:limit="1"
accept="image/*"
:on-change="handleEditFileChange"
:on-remove="handleEditFileRemove"
drag
>
<div v-if="editCoverPreview" class="cover-preview">
<img :src="editCoverPreview" alt="封面预览" />
</div>
<div v-else class="upload-placeholder">
<el-icon :size="28"><Plus /></el-icon>
<span>点击或拖拽上传封面</span>
</div>
</el-upload>
</div>
</div>
<div class="edit-actions">
<el-button @click="cancelEdit">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</div>
</div>
<!-- 详情模式 -->
<div v-else class="game-detail">
<div class="detail-cover-wrap">
<img :src="getGameCoverUrl(game) || '/game-placeholder.svg'" :alt="game.name" class="detail-cover" />
</div>
<h2 class="detail-name">{{ game.name }}</h2>
<div v-if="game.aliases?.length" class="detail-aliases">
<span v-for="alias in game.aliases" :key="alias" class="alias-tag">{{ alias }}</span>
</div>
<div class="detail-meta">
<span v-if="game.platform" class="platform-badge">{{ game.platform }}</span>
<span class="popularity"><el-icon><TrendCharts /></el-icon> {{ game.popularCount }} 人游玩</span>
@@ -81,7 +218,8 @@ function handleCreateTeam() {
<el-icon><StarFilled v-if="favorited" /><Star v-else /></el-icon>
{{ favorited ? '已收藏' : '收藏' }}
</button>
<button class="delete-detail-btn" @click="handleDelete"><el-icon><Delete /></el-icon> 删除</button>
<button v-if="canModify" class="edit-btn" @click="startEdit"><el-icon><Edit /></el-icon> 编辑</button>
<button v-if="canModify" class="delete-detail-btn" @click="handleDelete"><el-icon><Delete /></el-icon> 删除</button>
</div>
<el-button type="primary" class="team-btn" @click="handleCreateTeam">
@@ -102,6 +240,9 @@ function handleCreateTeam() {
.detail-name { margin: 0; font-size: 22px; font-weight: 700; color: var(--gg-text); text-align: center; }
.detail-aliases { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
.alias-tag { padding: 3px 10px; background: rgba(59, 130, 246, 0.1); color: #3b82f6; border-radius: 6px; font-size: 12px; font-weight: 500; }
.detail-meta { display: flex; align-items: center; gap: 12px; }
.platform-badge { padding: 4px 14px; background: rgba(5, 150, 105, 0.15); color: var(--gg-accent); border-radius: 6px; font-size: 13px; font-weight: 600; }
.popularity { font-size: 14px; color: var(--gg-text-secondary); }
@@ -117,6 +258,12 @@ function handleCreateTeam() {
.fav-btn:hover { border-color: #f59e0b; color: #f59e0b; }
.fav-btn.active { border-color: #f59e0b; color: #f59e0b; background: rgba(245, 158, 11, 0.1); }
.edit-btn {
padding: 8px 20px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
background: transparent; color: var(--gg-text-secondary); font-size: 14px; cursor: pointer; transition: all 0.2s;
}
.edit-btn:hover { border-color: var(--gg-primary); color: var(--gg-primary); }
.delete-detail-btn {
padding: 8px 20px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
background: transparent; color: var(--gg-text-muted); font-size: 14px; cursor: pointer; transition: all 0.2s;
@@ -124,4 +271,15 @@ function handleCreateTeam() {
.delete-detail-btn:hover { border-color: var(--gg-danger); color: var(--gg-danger); }
.team-btn { width: 100%; margin-top: 4px; }
/* 编辑表单 */
.form-fields { display: flex; flex-direction: column; gap: 16px; width: 100%; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 13px; font-weight: 500; color: var(--gg-text-secondary); }
.cover-preview img { width: 100%; max-height: 200px; object-fit: contain; border-radius: 6px; }
.upload-placeholder {
display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 20px;
color: var(--gg-text-muted); font-size: 13px;
}
.edit-actions { display: flex; gap: 12px; width: 100%; justify-content: flex-end; }
</style>
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { addGame, updateGame, getGameCoverUrl } from '@/api/games'
import type { Game, GamePlatform } from '@/types'
import { ElMessage } from 'element-plus'
import { getAllPlatforms } from '@/api/games'
import { Plus } from '@element-plus/icons-vue'
import type { UploadFile, UploadInstance } from 'element-plus'
const props = defineProps<{
modelValue: boolean
groupId: string
game?: Game | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'saved': []
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const isEdit = computed(() => !!props.game)
const name = ref('')
const aliasesInput = ref('')
const platform = ref<GamePlatform | ''>('')
const tagsInput = ref('')
const coverFile = ref<File | null>(null)
const coverPreview = ref('')
const uploadRef = ref<UploadInstance>()
const loading = ref(false)
const platforms = getAllPlatforms()
watch(() => props.modelValue, (val) => {
if (val && props.game) {
name.value = props.game.name
aliasesInput.value = (props.game.aliases || []).join(', ')
platform.value = props.game.platform || ''
tagsInput.value = (props.game.tags || []).join(', ')
coverFile.value = null
coverPreview.value = getGameCoverUrl(props.game) || ''
} else if (val) {
name.value = ''
aliasesInput.value = ''
platform.value = ''
tagsInput.value = ''
coverFile.value = null
coverPreview.value = ''
}
})
function handleFileChange(uploadFile: UploadFile) {
if (uploadFile.raw) {
coverFile.value = uploadFile.raw
coverPreview.value = URL.createObjectURL(uploadFile.raw)
}
}
function handleFileRemove() {
coverFile.value = null
coverPreview.value = ''
}
async function handleSubmit() {
if (!name.value.trim()) {
ElMessage.warning('请输入游戏名称')
return
}
try {
loading.value = true
const aliases = aliasesInput.value.split(',').map(t => t.trim()).filter(Boolean)
const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
if (isEdit.value && props.game) {
await updateGame(props.game.id, {
name: name.value.trim(),
aliases: aliases.length > 0 ? aliases : undefined,
platform: platform.value || undefined,
tags: tags.length > 0 ? tags : undefined,
coverFile: coverFile.value || undefined
})
ElMessage.success('修改成功')
} else {
await addGame(props.groupId, {
name: name.value.trim(),
aliases: aliases.length > 0 ? aliases : undefined,
platform: platform.value || undefined,
tags: tags.length > 0 ? tags : undefined,
coverFile: coverFile.value || undefined
})
ElMessage.success('添加成功')
}
name.value = ''
aliasesInput.value = ''
platform.value = ''
tagsInput.value = ''
coverFile.value = null
coverPreview.value = ''
uploadRef.value?.clearFiles()
emit('saved')
visible.value = false
} catch (error: any) {
ElMessage.error(error.message || (isEdit.value ? '修改失败' : '添加失败'))
} finally {
loading.value = false
}
}
</script>
<template>
<el-dialog v-model="visible" :title="isEdit ? '编辑游戏' : '添加游戏'" width="440px">
<div class="form-fields">
<div class="field">
<label>游戏名称 *</label>
<el-input v-model="name" placeholder="输入游戏名称" maxlength="100" />
</div>
<div class="field">
<label>别名</label>
<el-input v-model="aliasesInput" placeholder="逗号分隔,如: LOL,英雄联盟,撸啊撸" />
</div>
<div class="field">
<label>平台</label>
<el-select v-model="platform" placeholder="选择平台" clearable style="width: 100%">
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
</el-select>
</div>
<div class="field">
<label>标签</label>
<el-input v-model="tagsInput" placeholder="逗号分隔,如: MOBA,竞技,5v5" />
</div>
<div class="field">
<label>封面图</label>
<el-upload
ref="uploadRef"
:auto-upload="false"
:limit="1"
accept="image/*"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
drag
>
<div v-if="coverPreview" class="cover-preview">
<img :src="coverPreview" alt="封面预览" />
</div>
<div v-else class="upload-placeholder">
<el-icon :size="28"><Plus /></el-icon>
<span>点击或拖拽上传封面</span>
</div>
</el-upload>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">{{ isEdit ? '保存' : '添加' }}</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.form-fields { display: flex; flex-direction: column; gap: 18px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 13px; font-weight: 500; color: var(--gg-text-secondary); }
.cover-preview img {
width: 100%;
max-height: 200px;
object-fit: contain;
border-radius: 6px;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px;
color: var(--gg-text-muted);
font-size: 13px;
}
</style>
@@ -52,7 +52,7 @@ function parseCSV(text: string) {
const values = lines[i].split(',').map(v => v.trim())
const obj: any = {}
headers.forEach((h, idx) => {
if (h === 'tags' && values[idx]) {
if ((h === 'tags' || h === 'aliases') && values[idx]) {
obj[h] = values[idx].split(';').map((t: string) => t.trim())
} else {
obj[h] = values[idx] || undefined
@@ -97,8 +97,8 @@ function reset() {
</div>
<div class="format-hint">
<p><strong>JSON 格式:</strong> [{"name":"LOL","platform":"PC","tags":["MOBA"]}]</p>
<p><strong>CSV 格式:</strong> name,platform,tags,covertags 用分号分隔</p>
<p><strong>JSON 格式:</strong> [{"name":"英雄联盟","aliases":["LOL","撸啊撸"],"platform":"PC","tags":["MOBA"]}]</p>
<p><strong>CSV 格式:</strong> name,aliases,platform,tags,coveraliases/tags 用分号分隔</p>
</div>
<div v-if="previewData.length > 0" class="preview">
@@ -5,6 +5,7 @@ import type { Game } from '@/types'
const props = defineProps<{
modelValue: boolean
groupId?: string
}>()
const emit = defineEmits<{
@@ -37,7 +38,7 @@ async function handleSearch() {
}
loading.value = true
try {
games.value = await searchGames(searchQuery.value)
games.value = await searchGames(searchQuery.value, props.groupId)
} catch (error) {
console.error('搜索游戏失败:', error)
} finally {
@@ -73,6 +73,7 @@ async function sendInvite(teamSessionId: string) {
<GameSelectDialog
v-model="showGameSelect"
:group-id="groupStore.currentGroupId"
@select="handleGameSelect"
/>
</template>
@@ -168,7 +168,7 @@ async function handleGameSelected(gameName: string) {
</button>
</div>
<GameSelectDialog v-model="showGameSelect" @select="handleGameSelected" />
<GameSelectDialog v-model="showGameSelect" :group-id="groupStore.currentGroupId" @select="handleGameSelected" />
</template>
<style scoped>
+1
View File
@@ -120,6 +120,7 @@ export interface Invitation {
export interface Game {
id: string
name: string
aliases?: string[]
platform?: GamePlatform
tags?: string[]
cover?: string
+14
View File
@@ -10,6 +10,20 @@ interface LogEntry {
}
const logs = ref<LogEntry[]>([
{
version: 'v0.3.6',
date: '2026-04-24',
title: '游戏库别名与编辑权限',
items: [
{ type: 'feat', text: '游戏支持多个别名(如 LOL、撸啊撸),搜索时可通过别名匹配到游戏' },
{ type: 'feat', text: '游戏详情弹窗内新增编辑功能,创建人、群主、管理员可修改游戏信息' },
{ type: 'feat', text: '游戏编辑表单支持修改名称、别名、平台、标签、封面图' },
{ type: 'feat', text: '游戏详情弹窗以蓝色标签展示别名列表' },
{ type: 'fix', text: '游戏删除按钮改为权限控制:仅创建人、群主、管理员可见' },
{ type: 'fix', text: '组队选游戏限制为本群组游戏库,不再显示其他群组的游戏' },
{ type: 'feat', text: '导入/导出游戏支持别名(aliases)字段' },
]
},
{
version: 'v0.3.5',
date: '2026-04-23',
+15 -4
View File
@@ -3,9 +3,10 @@
import { ref, onMounted, computed, watch } from 'vue'
import { useGroupStore } from '@/stores/group'
import { getGroupGames, deleteGame, exportGames, getAllPlatforms, getGameCoverUrl } from '@/api/games'
import { pb } from '@/api/pocketbase'
import type { Game, GamePlatform } from '@/types'
import GameDetailDialog from '@/components/game/GameDetailDialog.vue'
import AddGameDialog from '@/components/game/AddGameDialog.vue'
import GameFormDialog from '@/components/game/GameFormDialog.vue'
import ImportGamesDialog from '@/components/game/ImportGamesDialog.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Close } from '@element-plus/icons-vue'
@@ -26,6 +27,15 @@ const showImport = ref(false)
const groupOptions = computed(() => groupStore.groups)
const hasGroups = computed(() => groupOptions.value.length > 0)
const currentUserId = computed(() => pb.authStore.model?.id || '')
function canModifyGame(game: Game): boolean {
if (groupStore.isGroupOwner) return true
if (groupStore.isGroupAdmin) return true
if (game.addedBy === currentUserId.value) return true
return false
}
onMounted(async () => {
if (groupStore.groups.length === 0) {
await groupStore.loadGroups()
@@ -87,6 +97,7 @@ function handleExport() {
exportGames(selectedGroupId.value).then(data => {
const json = JSON.stringify(data.map(g => ({
name: g.name,
aliases: g.aliases,
platform: g.platform,
tags: g.tags,
cover: g.cover
@@ -102,7 +113,7 @@ function handleExport() {
})
}
async function handleGameAdded() {
async function handleGameSaved() {
showAddGame.value = false
await loadGames()
}
@@ -179,12 +190,12 @@ async function handleImportComplete() {
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
</div>
<button class="delete-btn" @click.stop="handleDeleteGame(game)" title="删除"><el-icon><Close /></el-icon></button>
<button v-if="canModifyGame(game)" class="delete-btn" @click.stop="handleDeleteGame(game)" title="删除"><el-icon><Close /></el-icon></button>
</div>
</div>
<GameDetailDialog v-model="showDetail" :game="selectedGame" :group-id="selectedGroupId" @deleted="loadGames" />
<AddGameDialog v-model="showAddGame" :group-id="selectedGroupId" @created="handleGameAdded" />
<GameFormDialog v-model="showAddGame" :group-id="selectedGroupId" @saved="handleGameSaved" />
<ImportGamesDialog v-model="showImport" :group-id="selectedGroupId" @imported="handleImportComplete" />
</template>
</div>