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:
@@ -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)
|
||||||
|
})
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user