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:
@@ -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)
|
||||
})
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -120,6 +120,7 @@ export interface Invitation {
|
||||
export interface Game {
|
||||
id: string
|
||||
name: string
|
||||
aliases?: string[]
|
||||
platform?: GamePlatform
|
||||
tags?: string[]
|
||||
cover?: string
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user