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:
@@ -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,cover(tags 用分号分隔)</p>
|
||||
<p><strong>JSON 格式:</strong> [{"name":"英雄联盟","aliases":["LOL","撸啊撸"],"platform":"PC","tags":["MOBA"]}]</p>
|
||||
<p><strong>CSV 格式:</strong> name,aliases,platform,tags,cover(aliases/tags 用分号分隔)</p>
|
||||
</div>
|
||||
|
||||
<div v-if="previewData.length > 0" class="preview">
|
||||
|
||||
Reference in New Issue
Block a user