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
@@ -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">