feat: add game library CRUD/import/export/favorites/comments, fix team creation

- Game library: add/delete games per group, JSON/CSV import/export, favorites, star ratings & comments
- Fix team session creation: add creator to members array, handle null currentGroup
- Fix image loading: rename SVG files from .png to .svg extensions
- Add PocketBase migrations for game_comments and game_favorites collections
- Remove seed data script

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-17 21:03:20 +08:00
parent 802712c662
commit 4b97c99e56
23 changed files with 996 additions and 348 deletions
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { addGame } from '@/api/games'
import type { GamePlatform } from '@/types'
import { ElMessage } from 'element-plus'
import { getAllPlatforms } from '@/api/games'
const props = defineProps<{
modelValue: boolean
groupId: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'created': []
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const name = ref('')
const platform = ref<GamePlatform | ''>('')
const tagsInput = ref('')
const cover = ref('')
const loading = ref(false)
const platforms = getAllPlatforms()
async function handleSubmit() {
if (!name.value.trim()) {
ElMessage.warning('请输入游戏名称')
return
}
try {
loading.value = true
const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
await addGame(props.groupId, {
name: name.value.trim(),
platform: platform.value || undefined,
tags: tags.length > 0 ? tags : undefined,
cover: cover.value.trim() || undefined
})
ElMessage.success('添加成功')
name.value = ''
platform.value = ''
tagsInput.value = ''
cover.value = ''
emit('created')
visible.value = false
} catch (error: any) {
ElMessage.error(error.message || '添加失败')
} finally {
loading.value = false
}
}
</script>
<template>
<el-dialog v-model="visible" title="添加游戏" 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-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>封面图 URL</label>
<el-input v-model="cover" placeholder="https://example.com/cover.jpg" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">添加</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); }
</style>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getGameComments, addComment } from '@/api/games'
import type { GameComment } from '@/types'
import { ElMessage } from 'element-plus'
const props = defineProps<{ gameId: string }>()
const comments = ref<GameComment[]>([])
const newContent = ref('')
const newRating = ref(0)
const loading = ref(false)
const submitting = ref(false)
onMounted(async () => {
await loadComments()
})
async function loadComments() {
try {
loading.value = true
comments.value = await getGameComments(props.gameId)
} catch { /* ignore */ } finally { loading.value = false }
}
async function handleSubmit() {
if (!newContent.value.trim()) {
ElMessage.warning('请输入评论内容')
return
}
try {
submitting.value = true
await addComment(props.gameId, newContent.value.trim(), newRating.value || undefined)
newContent.value = ''
newRating.value = 0
await loadComments()
ElMessage.success('评论成功')
} catch (error: any) {
ElMessage.error(error.message || '评论失败')
} finally { submitting.value = false }
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
</script>
<template>
<div class="comments-section">
<h4 class="comments-title">评论 ({{ comments.length }})</h4>
<div class="comment-form">
<div class="rating-row">
<span class="rating-label">评分:</span>
<span
v-for="star in 5" :key="star"
class="star" :class="{ active: star <= newRating }"
@click="newRating = star"
></span>
<span v-if="newRating > 0" class="rating-clear" @click="newRating = 0">清除</span>
</div>
<div class="input-row">
<el-input v-model="newContent" placeholder="写下你的评论..." maxlength="1000" show-word-limit type="textarea" :rows="2" />
<el-button type="primary" :loading="submitting" @click="handleSubmit">发表</el-button>
</div>
</div>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="comments.length === 0" class="empty">暂无评论</div>
<div v-else class="comment-list">
<div v-for="c in comments" :key="c.id" class="comment-item">
<div class="comment-header">
<span class="comment-author">{{ c.expand?.author?.username || '用户' }}</span>
<span v-if="c.rating" class="comment-rating">{{ ''.repeat(c.rating) }}</span>
<span class="comment-date">{{ formatDate(c.created) }}</span>
</div>
<p class="comment-content">{{ c.content }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.comments-section { margin-top: 20px; }
.comments-title { font-size: 15px; font-weight: 600; margin: 0 0 16px; color: var(--gg-text); }
.comment-form { margin-bottom: 20px; }
.rating-row { display: flex; align-items: center; gap: 4px; margin-bottom: 10px; }
.rating-label { font-size: 13px; color: var(--gg-text-secondary); }
.star { font-size: 20px; cursor: pointer; color: var(--gg-border); transition: color 0.15s; }
.star.active { color: #f59e0b; }
.rating-clear { font-size: 12px; color: var(--gg-text-muted); cursor: pointer; margin-left: 8px; }
.input-row { display: flex; gap: 10px; align-items: flex-end; }
.input-row .el-input { flex: 1; }
.loading, .empty { text-align: center; padding: 16px; color: var(--gg-text-muted); font-size: 13px; }
.comment-list { display: flex; flex-direction: column; gap: 12px; }
.comment-item {
padding: 12px 14px; background: var(--gg-bg); border-radius: var(--gg-radius-sm);
border: 1px solid var(--gg-border);
}
.comment-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.comment-author { font-size: 13px; font-weight: 600; color: var(--gg-text); }
.comment-rating { font-size: 12px; color: #f59e0b; }
.comment-date { font-size: 12px; color: var(--gg-text-muted); margin-left: auto; }
.comment-content { font-size: 14px; color: var(--gg-text-secondary); margin: 0; line-height: 1.6; }
</style>
@@ -1,15 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, onMounted } from 'vue'
import type { Game } from '@/types'
import { toggleFavorite, isFavorite, deleteGame } from '@/api/games'
import { ElMessage, ElMessageBox } from 'element-plus'
import GameComments from './GameComments.vue'
const props = defineProps<{
modelValue: boolean
game: Game | null
groupId?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'create-team': [gameName: string]
'deleted': []
}>()
const visible = computed({
@@ -17,6 +22,32 @@ const visible = computed({
set: (val) => emit('update:modelValue', val)
})
const favorited = ref(false)
onMounted(async () => {
if (props.game) {
favorited.value = await isFavorite(props.game.id)
}
})
async function handleFavorite() {
if (!props.game) return
favorited.value = await toggleFavorite(props.game.id)
}
async function handleDelete() {
if (!props.game) return
try {
await ElMessageBox.confirm(`确定要删除「${props.game.name}」吗?`, '确认', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
})
await deleteGame(props.game.id)
ElMessage.success('删除成功')
visible.value = false
emit('deleted')
} catch { /* 用户取消 */ }
}
function handleCreateTeam() {
if (props.game) {
emit('create-team', props.game.name)
@@ -26,15 +57,11 @@ function handleCreateTeam() {
</script>
<template>
<el-dialog v-model="visible" width="440px" :show-close="true">
<el-dialog v-model="visible" width="480px" :show-close="true">
<template v-if="game">
<div class="game-detail">
<div class="detail-cover-wrap">
<img
:src="game.cover || '/game-placeholder.png'"
:alt="game.name"
class="detail-cover"
/>
<img :src="game.cover || '/game-placeholder.svg'" :alt="game.name" class="detail-cover" />
</div>
<h2 class="detail-name">{{ game.name }}</h2>
@@ -48,84 +75,51 @@ function handleCreateTeam() {
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<div class="detail-actions">
<button class="fav-btn" :class="{ active: favorited }" @click="handleFavorite">
{{ favorited ? ' 已收藏' : ' 收藏' }}
</button>
<button class="delete-detail-btn" @click="handleDelete">删除</button>
</div>
<el-button type="primary" class="team-btn" @click="handleCreateTeam">
快速组队
</el-button>
<GameComments v-if="game.id" :game-id="game.id" />
</div>
</template>
</el-dialog>
</template>
<style scoped>
.game-detail {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 8px 0;
}
.game-detail { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 8px 0; }
.detail-cover-wrap {
width: 200px;
height: 260px;
border-radius: var(--gg-radius-md);
overflow: hidden;
background: var(--gg-bg-elevated);
border: 1px solid var(--gg-border);
}
.detail-cover-wrap { width: 200px; height: 260px; border-radius: var(--gg-radius-md); overflow: hidden; background: var(--gg-bg-elevated); border: 1px solid var(--gg-border); }
.detail-cover { width: 100%; height: 100%; object-fit: cover; }
.detail-cover {
width: 100%;
height: 100%;
object-fit: cover;
}
.detail-name { margin: 0; font-size: 22px; font-weight: 700; color: var(--gg-text); text-align: center; }
.detail-name {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--gg-text);
text-align: center;
}
.detail-meta { display: flex; align-items: center; gap: 12px; }
.platform-badge { padding: 4px 14px; background: rgba(168, 85, 247, 0.15); color: var(--gg-accent); border-radius: 6px; font-size: 13px; font-weight: 600; }
.popularity { font-size: 14px; color: var(--gg-text-secondary); }
.detail-meta {
display: flex;
align-items: center;
gap: 12px;
}
.detail-tags { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
.tag { padding: 4px 12px; background: var(--gg-bg-elevated); border-radius: 6px; font-size: 12px; color: var(--gg-primary); font-weight: 500; }
.platform-badge {
padding: 4px 14px;
background: rgba(168, 85, 247, 0.15);
color: var(--gg-accent);
border-radius: 6px;
font-size: 13px;
font-weight: 600;
.detail-actions { display: flex; gap: 12px; width: 100%; justify-content: center; }
.fav-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;
}
.fav-btn:hover { border-color: #f59e0b; color: #f59e0b; }
.fav-btn.active { border-color: #f59e0b; color: #f59e0b; background: rgba(245, 158, 11, 0.1); }
.popularity {
font-size: 14px;
color: var(--gg-text-secondary);
.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;
}
.delete-detail-btn:hover { border-color: var(--gg-danger); color: var(--gg-danger); }
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
}
.tag {
padding: 4px 12px;
background: var(--gg-bg-elevated);
border-radius: 6px;
font-size: 12px;
color: var(--gg-primary);
font-weight: 500;
}
.team-btn {
width: 100%;
margin-top: 4px;
}
.team-btn { width: 100%; margin-top: 4px; }
</style>
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { importGames } from '@/api/games'
import { ElMessage } from 'element-plus'
const props = defineProps<{
modelValue: boolean
groupId: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'imported': []
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const previewData = ref<any[]>([])
const importing = ref(false)
function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
try {
const text = e.target?.result as string
if (file.name.endsWith('.csv')) {
parseCSV(text)
} else {
const json = JSON.parse(text)
previewData.value = Array.isArray(json) ? json : [json]
}
} catch {
ElMessage.error('文件解析失败')
}
}
reader.readAsText(file)
}
function parseCSV(text: string) {
const lines = text.split('\n').filter(l => l.trim())
if (lines.length < 2) { previewData.value = []; return }
const headers = lines[0].split(',').map(h => h.trim().toLowerCase())
const data = []
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim())
const obj: any = {}
headers.forEach((h, idx) => {
if (h === 'tags' && values[idx]) {
obj[h] = values[idx].split(';').map((t: string) => t.trim())
} else {
obj[h] = values[idx] || undefined
}
})
if (obj.name) data.push(obj)
}
previewData.value = data
}
async function handleImport() {
if (previewData.value.length === 0) {
ElMessage.warning('没有数据可导入')
return
}
try {
importing.value = true
const results = await importGames(props.groupId, previewData.value)
const success = results.filter(r => r.success).length
ElMessage.success(`成功导入 ${success}/${results.length} 个游戏`)
previewData.value = []
emit('imported')
visible.value = false
} catch (error: any) {
ElMessage.error(error.message || '导入失败')
} finally {
importing.value = false
}
}
function reset() {
previewData.value = []
}
</script>
<template>
<el-dialog v-model="visible" title="导入游戏" width="500px" @close="reset">
<div class="import-content">
<div class="upload-area">
<p class="upload-hint">上传 JSON CSV 文件</p>
<input type="file" accept=".json,.csv" class="file-input" @change="handleFileUpload" />
</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>
</div>
<div v-if="previewData.length > 0" class="preview">
<h4>预览 ({{ previewData.length }} 个游戏)</h4>
<div class="preview-list">
<div v-for="(item, idx) in previewData" :key="idx" class="preview-item">
{{ item.name }} <span v-if="item.platform" class="preview-platform">{{ item.platform }}</span>
</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="importing" :disabled="previewData.length === 0" @click="handleImport">
确认导入
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.import-content { display: flex; flex-direction: column; gap: 16px; }
.upload-area {
border: 2px dashed var(--gg-border); border-radius: var(--gg-radius-md);
padding: 32px; text-align: center;
}
.upload-hint { margin: 0 0 12px; color: var(--gg-text-secondary); font-size: 14px; }
.file-input { font-size: 14px; }
.format-hint { font-size: 12px; color: var(--gg-text-muted); }
.format-hint p { margin: 4px 0; }
.preview h4 { margin: 0 0 8px; font-size: 14px; color: var(--gg-text); }
.preview-list { max-height: 200px; overflow-y: auto; }
.preview-item {
padding: 8px 12px; border-bottom: 1px solid var(--gg-border);
font-size: 13px; color: var(--gg-text-secondary);
}
.preview-platform {
font-size: 11px; padding: 2px 8px; border-radius: 4px;
background: var(--gg-bg-elevated); color: var(--gg-accent); margin-left: 8px;
}
</style>
@@ -58,7 +58,7 @@ async function removeMember(userId: string, username: string) {
<div class="members-list">
<div v-for="member in members" :key="member.id" class="member-row">
<img :src="member.avatar || '/default-avatar.png'" class="member-avatar" alt="" />
<img :src="member.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
<div class="member-info">
<span class="member-name">{{ member.username }}</span>
<span v-if="member.id === group.owner" class="owner-badge">群主</span>
@@ -82,7 +82,7 @@ function confirmCustomGame() {
@click="selectGame(game)"
>
<img
:src="game.cover || '/game-placeholder.png'"
:src="game.cover || '/game-placeholder.svg'"
:alt="game.name"
class="game-cover"
/>
@@ -59,7 +59,7 @@ async function inviteMember(userId: string, username: string) {
class="member-item"
>
<img
:src="member.avatar || '/default-avatar.png'"
:src="member.avatar || '/default-avatar.svg'"
:alt="member.username"
class="avatar"
/>
@@ -49,7 +49,7 @@ async function rejectInvitation() {
<div class="invitation-card">
<div class="invitation-header">
<img
:src="invitation.expand?.from?.avatar || '/default-avatar.png'"
:src="invitation.expand?.from?.avatar || '/default-avatar.svg'"
:alt="invitation.expand?.from?.username"
class="avatar"
/>
@@ -1,16 +1,20 @@
<!-- src/components/team/TeamSessionPanel.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useTeamStore } from '@/stores/team'
import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user'
import { TeamStatusMap } from '@/types'
import { ElMessageBox } from 'element-plus'
import { ElMessageBox, ElMessage } from 'element-plus'
import GameSelectDialog from '@/components/team/GameSelectDialog.vue'
const teamStore = useTeamStore()
const groupStore = useGroupStore()
const userStore = useUserStore()
const session = computed(() => teamStore.currentSession)
const statusText = computed(() => session.value ? TeamStatusMap[session.value.status] : '')
const showGameSelect = ref(false)
const memberDetails = computed(() => {
if (!session.value) return []
@@ -43,6 +47,35 @@ async function endGame() {
// 用户取消
}
}
async function handleGameSelected(gameName: string) {
let group = groupStore.currentGroup
if (!group) {
if (groupStore.groups.length > 0) {
group = groupStore.groups[0]
await groupStore.setCurrentGroup(group.id)
} else {
ElMessage.warning('请先创建或加入一个群组')
return
}
}
if (teamStore.currentSession) return
const userId = userStore.userId
try {
await teamStore.createSession({
sourceGroup: group.id,
name: `${gameName} 小队`,
gameName,
members: [userId]
})
ElMessage.success('临时小队创建成功!')
} catch (error: any) {
console.error('创建临时小队失败:', error)
ElMessage.error('创建临时小队失败:' + (error.message || '未知错误'))
}
}
</script>
<template>
@@ -66,7 +99,7 @@ async function endGame() {
class="member-item"
>
<img
:src="member.avatar || '/default-avatar.png'"
:src="member.avatar || '/default-avatar.svg'"
:alt="member.username"
class="avatar"
/>
@@ -87,8 +120,13 @@ async function endGame() {
</div>
<div v-else class="no-session">
<p>暂未加入任何临时小组</p>
<p class="no-session-text">暂未加入任何临时小组</p>
<button class="create-team-btn" @click="showGameSelect = true">
创建临时小队
</button>
</div>
<GameSelectDialog v-model="showGameSelect" @select="handleGameSelected" />
</template>
<style scoped>
@@ -251,9 +289,30 @@ async function endGame() {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
padding: 48px 32px;
padding: 40px 32px;
text-align: center;
}
.no-session-text {
color: var(--gg-text-muted);
font-size: 15px;
margin: 0 0 20px;
}
.create-team-btn {
padding: 12px 32px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient-green);
color: white;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: box-shadow 0.3s, transform 0.15s;
}
.create-team-btn:hover {
box-shadow: 0 4px 20px rgba(5, 150, 105, 0.3);
transform: translateY(-1px);
}
</style>