feat: game cover file upload + fix game visibility rules

- Change game cover field from URL text to file upload (PocketBase migration)
- AddGameDialog now supports drag-and-drop image upload instead of URL input
- Add getGameCoverUrl() helper using pb.files.getUrl() for cover display
- Fix games listRule/viewRule: group.members.id syntax doesn't work in PB 0.22,
  changed to group.members ~ @request.auth.id for correct member visibility
- Update changelog with all v0.3.5 entries

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-23 19:09:57 +08:00
parent 625645dad4
commit ceafc873c7
8 changed files with 130 additions and 23 deletions
@@ -0,0 +1,51 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
// remove old url cover field
collection.schema.removeField("cover_field")
// add new file cover field
collection.schema.addField(new SchemaField({
"system": false,
"id": "cover_file",
"name": "cover",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"maxSize": 5242880,
"mimeTypes": ["image/jpeg","image/png","image/gif","image/webp","image/svg+xml"],
"thumbs": null,
"protected": false
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
// remove file cover field
collection.schema.removeField("cover_file")
// restore url cover field
collection.schema.addField(new SchemaField({
"system": false,
"id": "cover_field",
"name": "cover",
"type": "url",
"required": false,
"presentable": false,
"unique": false,
"options": {
"exceptDomains": null,
"onlyDomains": null
}
}))
return dao.saveCollection(collection)
})
+18 -9
View File
@@ -1,6 +1,12 @@
import pb from './pocketbase' import pb from './pocketbase'
import type { Game, GamePlatform, GameComment } from '@/types' import type { Game, GamePlatform, GameComment } from '@/types'
// 生成游戏封面 URL
export function getGameCoverUrl(game: Game, thumb?: string): string {
if (!game.cover) return ''
return pb.files.getUrl(game, game.cover, thumb ? { thumb } : undefined) as string
}
// 获取群组的游戏列表 // 获取群组的游戏列表
export async function getGroupGames(groupId: string, options?: { export async function getGroupGames(groupId: string, options?: {
page?: number page?: number
@@ -26,15 +32,19 @@ export async function addGame(groupId: string, data: {
name: string name: string
platform?: GamePlatform platform?: GamePlatform
tags?: string[] tags?: string[]
cover?: string coverFile?: File
}) { }) {
const user = pb.authStore.model const user = pb.authStore.model
return pb.collection('games').create({ const formData = new FormData()
...data, formData.append('name', data.name)
group: groupId, formData.append('group', groupId)
addedBy: user?.id, formData.append('addedBy', user?.id || '')
popularCount: 0 formData.append('popularCount', '0')
}, { $autoCancel: false }) if (data.platform) formData.append('platform', data.platform)
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 })
} }
// 删除游戏 // 删除游戏
@@ -42,12 +52,11 @@ export async function deleteGame(gameId: string) {
return pb.collection('games').delete(gameId, { $autoCancel: false }) return pb.collection('games').delete(gameId, { $autoCancel: false })
} }
// 导入游戏(批量添加) // 导入游戏(批量添加,无封面文件
export async function importGames(groupId: string, games: Array<{ export async function importGames(groupId: string, games: Array<{
name: string name: string
platform?: GamePlatform platform?: GamePlatform
tags?: string[] tags?: string[]
cover?: string
}>) { }>) {
const results = [] const results = []
for (const game of games) { for (const game of games) {
+51 -5
View File
@@ -4,6 +4,8 @@ import { addGame } from '@/api/games'
import type { GamePlatform } from '@/types' import type { GamePlatform } from '@/types'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getAllPlatforms } from '@/api/games' import { getAllPlatforms } from '@/api/games'
import { Plus } from '@element-plus/icons-vue'
import type { UploadFile } from 'element-plus'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -23,10 +25,23 @@ const visible = computed({
const name = ref('') const name = ref('')
const platform = ref<GamePlatform | ''>('') const platform = ref<GamePlatform | ''>('')
const tagsInput = ref('') const tagsInput = ref('')
const cover = ref('') const coverFile = ref<File | null>(null)
const coverPreview = ref('')
const loading = ref(false) const loading = ref(false)
const platforms = getAllPlatforms() const platforms = getAllPlatforms()
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() { async function handleSubmit() {
if (!name.value.trim()) { if (!name.value.trim()) {
ElMessage.warning('请输入游戏名称') ElMessage.warning('请输入游戏名称')
@@ -39,13 +54,14 @@ async function handleSubmit() {
name: name.value.trim(), name: name.value.trim(),
platform: platform.value || undefined, platform: platform.value || undefined,
tags: tags.length > 0 ? tags : undefined, tags: tags.length > 0 ? tags : undefined,
cover: cover.value.trim() || undefined coverFile: coverFile.value || undefined
}) })
ElMessage.success('添加成功') ElMessage.success('添加成功')
name.value = '' name.value = ''
platform.value = '' platform.value = ''
tagsInput.value = '' tagsInput.value = ''
cover.value = '' coverFile.value = null
coverPreview.value = ''
emit('created') emit('created')
visible.value = false visible.value = false
} catch (error: any) { } catch (error: any) {
@@ -74,8 +90,23 @@ async function handleSubmit() {
<el-input v-model="tagsInput" placeholder="逗号分隔,如: MOBA,竞技,5v5" /> <el-input v-model="tagsInput" placeholder="逗号分隔,如: MOBA,竞技,5v5" />
</div> </div>
<div class="field"> <div class="field">
<label>封面图 URL</label> <label>封面图</label>
<el-input v-model="cover" placeholder="https://example.com/cover.jpg" /> <el-upload
: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>
</div> </div>
<template #footer> <template #footer>
@@ -89,4 +120,19 @@ async function handleSubmit() {
.form-fields { display: flex; flex-direction: column; gap: 18px; } .form-fields { display: flex; flex-direction: column; gap: 18px; }
.field { display: flex; flex-direction: column; gap: 6px; } .field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 13px; font-weight: 500; color: var(--gg-text-secondary); } .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> </style>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
import type { Game } from '@/types' import type { Game } from '@/types'
import { toggleFavorite, isFavorite, deleteGame } from '@/api/games' import { toggleFavorite, isFavorite, deleteGame, getGameCoverUrl } from '@/api/games'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { TrendCharts, StarFilled, Star, Delete } from '@element-plus/icons-vue' import { TrendCharts, StarFilled, Star, Delete } from '@element-plus/icons-vue'
import GameComments from './GameComments.vue' import GameComments from './GameComments.vue'
@@ -62,7 +62,7 @@ function handleCreateTeam() {
<template v-if="game"> <template v-if="game">
<div class="game-detail"> <div class="game-detail">
<div class="detail-cover-wrap"> <div class="detail-cover-wrap">
<img :src="game.cover || '/game-placeholder.svg'" :alt="game.name" class="detail-cover" /> <img :src="getGameCoverUrl(game) || '/game-placeholder.svg'" :alt="game.name" class="detail-cover" />
</div> </div>
<h2 class="detail-name">{{ game.name }}</h2> <h2 class="detail-name">{{ game.name }}</h2>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { searchGames } from '@/api/games' import { searchGames, getGameCoverUrl } from '@/api/games'
import type { Game } from '@/types' import type { Game } from '@/types'
const props = defineProps<{ const props = defineProps<{
@@ -82,7 +82,7 @@ function confirmCustomGame() {
@click="selectGame(game)" @click="selectGame(game)"
> >
<img <img
:src="game.cover || '/game-placeholder.svg'" :src="getGameCoverUrl(game) || '/game-placeholder.svg'"
:alt="game.name" :alt="game.name"
class="game-cover" class="game-cover"
/> />
+2 -1
View File
@@ -31,7 +31,8 @@ const logs = ref<LogEntry[]>([
{ type: 'fix', text: '修复活动展开详情后未订阅评论和 RSVP 实时更新' }, { type: 'fix', text: '修复活动展开详情后未订阅评论和 RSVP 实时更新' },
{ type: 'fix', text: '修复活动相对时间未使用 status 字段判断状态的问题' }, { type: 'fix', text: '修复活动相对时间未使用 status 字段判断状态的问题' },
{ type: 'refactor', text: '移除顶部 Header 和首页欢迎条中重复的"创建群组""加入群组"按钮' }, { type: 'refactor', text: '移除顶部 Header 和首页欢迎条中重复的"创建群组""加入群组"按钮' },
{ type: 'refactor', text: '小队邀请链接改用 API 函数替代原始 PocketBase 调用' }, { type: 'feat', text: '游戏封面支持本地上传图片,无需外部图床,添加游戏时拖拽或点击选择文件即可' },
{ type: 'fix', text: '修复非群主成员无法看到群组内游戏的问题(PB listRule 语法修正)' },
] ]
}, },
{ {
+2 -2
View File
@@ -2,7 +2,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue' import { ref, onMounted, computed, watch } from 'vue'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { getGroupGames, deleteGame, exportGames, getAllPlatforms } from '@/api/games' import { getGroupGames, deleteGame, exportGames, getAllPlatforms, getGameCoverUrl } from '@/api/games'
import type { Game, GamePlatform } from '@/types' import type { Game, GamePlatform } from '@/types'
import GameDetailDialog from '@/components/game/GameDetailDialog.vue' import GameDetailDialog from '@/components/game/GameDetailDialog.vue'
import AddGameDialog from '@/components/game/AddGameDialog.vue' import AddGameDialog from '@/components/game/AddGameDialog.vue'
@@ -171,7 +171,7 @@ async function handleImportComplete() {
<div v-else class="games-grid"> <div v-else class="games-grid">
<div v-for="game in games" :key="game.id" class="game-card" @click="openGameDetail(game)"> <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" /> <img :src="getGameCoverUrl(game) || '/game-placeholder.svg'" :alt="game.name" class="game-cover" />
<div class="game-info"> <div class="game-info">
<h3 class="game-name">{{ game.name }}</h3> <h3 class="game-name">{{ game.name }}</h3>
<p class="game-platform">{{ game.platform }}</p> <p class="game-platform">{{ game.platform }}</p>
+2 -2
View File
@@ -5,7 +5,7 @@ import { useRouter } from 'vue-router'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useTeamStore } from '@/stores/team' import { useTeamStore } from '@/stores/team'
import { getPopularGames } from '@/api/games' import { getPopularGames, getGameCoverUrl } from '@/api/games'
import GameDetailDialog from '@/components/game/GameDetailDialog.vue' import GameDetailDialog from '@/components/game/GameDetailDialog.vue'
import { UserStatusMap } from '@/types' import { UserStatusMap } from '@/types'
import type { Game } from '@/types' import type { Game } from '@/types'
@@ -160,7 +160,7 @@ function openGameDetail(game: Game) {
> >
<div class="game-cover-wrap"> <div class="game-cover-wrap">
<img <img
:src="game.cover || '/game-placeholder.svg'" :src="getGameCoverUrl(game) || '/game-placeholder.svg'"
:alt="game.name" :alt="game.name"
class="game-cover" class="game-cover"
/> />