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
+122 -65
View File
@@ -1,88 +1,127 @@
// src/api/games.ts
import pb from './pocketbase'
import type { Game, GamePlatform } from '@/types'
import type { Game, GamePlatform, GameComment } from '@/types'
// 获取游戏列表
export async function getGames(options?: {
// 获取群组的游戏列表
export async function getGroupGames(groupId: string, options?: {
page?: number
limit?: number
platform?: GamePlatform
search?: string
}): Promise<{ items: Game[], total: number }> {
const { page = 1, limit = 20, platform, search } = options || {}
let filter = ''
if (platform) {
filter = `platform="${platform}"`
}
if (search) {
const searchFilter = `name ~ "${search}"`
filter = filter ? `${filter} && ${searchFilter}` : searchFilter
}
const { page = 1, limit = 50, platform, search } = options || {}
let filter = `group="${groupId}"`
if (platform) filter += ` && platform="${platform}"`
if (search) filter += ` && name ~ "${search}"`
const result = await pb.collection('games').getList(page, limit, {
filter,
sort: '-popularCount'
sort: '-created'
})
return {
items: result.items as unknown as Game[],
total: result.totalItems
}
return { items: result.items as unknown as Game[], total: result.totalItems }
}
// 获取热门游戏
export async function getPopularGames(limit = 10): Promise<Game[]> {
const result = await pb.collection('games').getList(1, limit, {
sort: '-popularCount'
})
return result.items as unknown as Game[]
}
// 搜索游戏
export async function searchGames(query: string, limit = 20): Promise<Game[]> {
if (!query.trim()) return []
const result = await pb.collection('games').getList(1, limit, {
filter: `name ~ "${query}"`,
sort: '-popularCount'
})
return result.items as unknown as Game[]
}
// 添加游戏(需要管理员权限)
export async function addGame(data: {
// 添加游戏到群组
export async function addGame(groupId: string, data: {
name: string
platform: GamePlatform
platform?: GamePlatform
tags?: string[]
cover?: string
}) {
return pb.collection('games').create(data)
}
// 更新游戏热度
export async function incrementGamePopularity(gameId: string) {
const game = await pb.collection('games').getOne(gameId)
return pb.collection('games').update(gameId, {
popularCount: (game.popularCount || 0) + 1
const user = pb.authStore.model
return pb.collection('games').create({
...data,
group: groupId,
addedBy: user?.id,
popularCount: 0
})
}
// 获取游戏详情
export async function getGame(gameId: string): Promise<Game> {
return pb.collection('games').getOne(gameId) as unknown as Game
// 删除游戏
export async function deleteGame(gameId: string) {
return pb.collection('games').delete(gameId)
}
// 按平台获取游戏
export async function getGamesByPlatform(platform: GamePlatform): Promise<Game[]> {
const result = await pb.collection('games').getList(1, 50, {
filter: `platform="${platform}"`,
sort: '-popularCount'
// 导入游戏(批量添加)
export async function importGames(groupId: string, games: Array<{
name: string
platform?: GamePlatform
tags?: string[]
cover?: string
}>) {
const results = []
for (const game of games) {
try {
const result = await addGame(groupId, game)
results.push({ success: true, data: result })
} catch (error: any) {
results.push({ success: false, error: error.message, data: game })
}
}
return results
}
// 导出游戏(获取全部数据)
export async function exportGames(groupId: string): Promise<Game[]> {
const result = await pb.collection('games').getFullList({
filter: `group="${groupId}"`,
sort: '-created'
})
return result as unknown as Game[]
}
// 收藏/取消收藏
export async function toggleFavorite(gameId: string): Promise<boolean> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const existing = await pb.collection('game_favorites').getList(1, 1, {
filter: `game="${gameId}" && user="${user.id}"`
})
return result.items as unknown as Game[]
if (existing.items.length > 0) {
await pb.collection('game_favorites').delete(existing.items[0].id)
return false
} else {
await pb.collection('game_favorites').create({
game: gameId,
user: user.id
})
return true
}
}
// 检查是否已收藏
export async function isFavorite(gameId: string): Promise<boolean> {
const user = pb.authStore.model
if (!user) return false
const result = await pb.collection('game_favorites').getList(1, 1, {
filter: `game="${gameId}" && user="${user.id}"`
})
return result.items.length > 0
}
// 添加评论
export async function addComment(gameId: string, content: string, rating?: number) {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
return pb.collection('game_comments').create({
game: gameId,
author: user.id,
content,
rating
})
}
// 获取游戏评论
export async function getGameComments(gameId: string): Promise<GameComment[]> {
const result = await pb.collection('game_comments').getList(1, 50, {
filter: `game="${gameId}"`,
sort: '-created',
expand: 'author'
})
return result.items as unknown as GameComment[]
}
// 获取所有平台
@@ -90,9 +129,27 @@ export function getAllPlatforms(): GamePlatform[] {
return ['PC', 'PS5', 'Xbox', 'Switch', 'Mobile']
}
// 订阅游戏变更
export function subscribeGames(callback: (game: Game) => void) {
return pb.collection('games').subscribe('*', (payload) => {
callback(payload.record as unknown as Game)
// 搜索游戏(用于 GameSelectDialog,全局搜索或群组内搜索)
export async function searchGames(query: string, groupId?: string, limit = 20): Promise<Game[]> {
if (!query.trim()) return []
let filter = `name ~ "${query}"`
if (groupId) filter += ` && group="${groupId}"`
const result = await pb.collection('games').getList(1, limit, {
filter,
sort: '-popularCount'
})
return result.items as unknown as Game[]
}
// 获取热门游戏(全局,按 popularCount 排序)
export async function getPopularGames(limit = 10): Promise<Game[]> {
const user = pb.authStore.model
if (!user) return []
// 获取用户所在群组的游戏
const result = await pb.collection('games').getList(1, limit, {
sort: '-popularCount'
})
return result.items as unknown as Game[]
}
@@ -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>
+7 -2
View File
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { TeamSession, TeamStatus } from '@/types'
import { getActiveTeamSession, createTeamSession, updateTeamStatus, endGame } from '@/api/sessions'
import { pb } from '@/api/pocketbase'
import { getPendingInvitations, sendBulkInvitations } from '@/api/invitations'
export const useTeamStore = defineStore('team', () => {
@@ -43,8 +44,12 @@ export const useTeamStore = defineStore('team', () => {
const session = await createTeamSession(data)
currentSession.value = session
// 发送邀请
await sendBulkInvitations(data.members, session.id)
// 发送邀请(排除创建者自己)
const currentUserId = pb.authStore.model?.id
const others = data.members.filter(id => id !== currentUserId)
if (others.length > 0) {
await sendBulkInvitations(others, session.id)
}
return session
} catch (error: any) {
+25
View File
@@ -117,6 +117,8 @@ export interface Game {
tags?: string[]
cover?: string
popularCount: number
group: string
addedBy?: string
created: string
updated: string
}
@@ -126,3 +128,26 @@ export interface WorkSchedule {
workdays: number[]
workStartTime: string
}
// 游戏评论
export interface GameComment {
id: string
game: string
author: string
content: string
rating?: number
created: string
updated: string
expand?: {
author?: User
}
}
// 游戏收藏
export interface GameFavorite {
id: string
game: string
user: string
created: string
updated: string
}
+150 -132
View File
@@ -1,9 +1,15 @@
<!-- src/views/GamesLibrary.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getGames, getAllPlatforms } from '@/api/games'
import { ref, onMounted, computed } from 'vue'
import { useGroupStore } from '@/stores/group'
import { getGroupGames, deleteGame, exportGames, getAllPlatforms } from '@/api/games'
import type { Game, GamePlatform } from '@/types'
import GameDetailDialog from '@/components/game/GameDetailDialog.vue'
import AddGameDialog from '@/components/game/AddGameDialog.vue'
import ImportGamesDialog from '@/components/game/ImportGamesDialog.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const groupStore = useGroupStore()
const games = ref<Game[]>([])
const loading = ref(false)
@@ -12,16 +18,23 @@ const selectedPlatform = ref<GamePlatform | ''>('')
const platforms = getAllPlatforms()
const selectedGame = ref<Game | null>(null)
const showDetail = ref(false)
const showAddGame = ref(false)
const showImport = ref(false)
const currentGroupId = computed(() => groupStore.currentGroupId)
onMounted(async () => {
await loadGames()
if (currentGroupId.value) {
await loadGames()
}
})
async function loadGames() {
if (!currentGroupId.value) return
try {
loading.value = true
const result = await getGames({
limit: 50,
const result = await getGroupGames(currentGroupId.value, {
limit: 100,
search: searchQuery.value || undefined,
platform: selectedPlatform.value || undefined
})
@@ -33,174 +46,179 @@ async function loadGames() {
}
}
function handleSearch() {
loadGames()
}
function openGameDetail(game: Game) {
selectedGame.value = game
showDetail.value = true
}
function handleCreateTeam(_gameName: string) {
// TODO: 导航到群组页面触发组队流程
async function handleDeleteGame(game: Game) {
try {
await ElMessageBox.confirm(`确定要删除「${game.name}」吗?`, '确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await deleteGame(game.id)
ElMessage.success('删除成功')
await loadGames()
} catch { /* 用户取消 */ }
}
function handleExport() {
if (!currentGroupId.value) return
exportGames(currentGroupId.value).then(data => {
const json = JSON.stringify(data.map(g => ({
name: g.name,
platform: g.platform,
tags: g.tags,
cover: g.cover
})), null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `games-export-${currentGroupId.value}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${data.length} 个游戏`)
})
}
async function handleGameAdded() {
showAddGame.value = false
await loadGames()
}
async function handleImportComplete() {
showImport.value = false
await loadGames()
}
</script>
<template>
<div class="games-library">
<div class="page-header">
<h1>游戏库</h1>
<div class="filters">
<el-input
v-model="searchQuery"
placeholder="搜索游戏..."
clearable
@input="handleSearch"
/>
<el-select v-model="selectedPlatform" placeholder="选择平台" clearable @change="loadGames">
<el-option
v-for="platform in platforms"
:key="platform"
:label="platform"
:value="platform"
<!-- 无群组提示 -->
<div v-if="!currentGroupId" class="no-group">
<p class="no-group-text">请先选择一个群组</p>
<p class="no-group-hint">在左侧选择或创建群组后即可管理游戏库</p>
</div>
<template v-else>
<div class="page-header">
<h1>游戏库</h1>
<div class="toolbar">
<el-input
v-model="searchQuery"
placeholder="搜索游戏..."
clearable
style="width: 240px"
@input="loadGames"
/>
</el-select>
</div>
</div>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="games.length === 0" class="empty">
<p>暂无游戏</p>
</div>
<div v-else class="games-grid">
<div v-for="game in games" :key="game.id" class="game-card" @click="openGameDetail(game)">
<img
:src="game.cover || '/game-placeholder.png'"
:alt="game.name"
class="game-cover"
/>
<div class="game-info">
<h3 class="game-name">{{ game.name }}</h3>
<p class="game-platform">{{ game.platform }}</p>
<div class="game-tags">
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<el-select v-model="selectedPlatform" placeholder="平台" clearable style="width: 120px" @change="loadGames">
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
</el-select>
<button class="tool-btn primary" @click="showAddGame = true">添加游戏</button>
<button class="tool-btn" @click="showImport = true">导入</button>
<button class="tool-btn" @click="handleExport">导出</button>
</div>
</div>
</div>
<GameDetailDialog
v-model="showDetail"
:game="selectedGame"
@create-team="handleCreateTeam"
/>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="games.length === 0" class="empty">
<p class="empty-text">暂无游戏</p>
<p class="empty-hint">点击添加游戏导入开始管理游戏库</p>
</div>
<div v-else class="games-grid">
<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" />
<div class="game-info">
<h3 class="game-name">{{ game.name }}</h3>
<p class="game-platform">{{ game.platform }}</p>
<div class="game-tags">
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
</div>
<button class="delete-btn" @click.stop="handleDeleteGame(game)" title="删除"></button>
</div>
</div>
<GameDetailDialog v-model="showDetail" :game="selectedGame" :group-id="currentGroupId" @deleted="loadGames" />
<AddGameDialog v-model="showAddGame" :group-id="currentGroupId" @created="handleGameAdded" />
<ImportGamesDialog v-model="showImport" :group-id="currentGroupId" @imported="handleImportComplete" />
</template>
</div>
</template>
<style scoped>
.games-library {
width: 100%;
}
.games-library { width: 100%; }
.page-header {
margin-bottom: 28px;
.no-group {
text-align: center;
padding: 120px 32px;
}
.no-group-text { font-size: 18px; font-weight: 600; color: var(--gg-text-secondary); margin: 0 0 8px; }
.no-group-hint { font-size: 14px; color: var(--gg-text-muted); margin: 0; }
.page-header { margin-bottom: 24px; }
.page-header h1 {
font-size: 28px;
font-weight: 700;
margin: 0 0 16px;
background: var(--gg-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-size: 28px; font-weight: 700; margin: 0 0 16px;
background: var(--gg-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.filters {
display: flex;
gap: 12px;
}
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.filters .el-input {
width: 300px;
.tool-btn {
padding: 8px 18px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
background: var(--gg-bg-card); color: var(--gg-text-secondary); font-size: 13px; font-weight: 500;
cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.tool-btn:hover { border-color: var(--gg-primary); color: var(--gg-primary); }
.tool-btn.primary {
background: var(--gg-gradient-green); border-color: transparent; color: white;
}
.tool-btn.primary:hover { opacity: 0.9; }
.loading {
text-align: center;
padding: 80px 32px;
color: var(--gg-text-muted);
font-size: 16px;
}
.loading { text-align: center; padding: 80px 32px; color: var(--gg-text-muted); }
.empty {
text-align: center;
padding: 80px 32px;
color: var(--gg-text-muted);
font-size: 15px;
}
.empty { text-align: center; padding: 80px 32px; }
.empty-text { font-size: 16px; font-weight: 500; color: var(--gg-text-secondary); margin: 0 0 8px; }
.empty-hint { font-size: 14px; color: var(--gg-text-muted); margin: 0; }
.games-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px;
}
.game-card {
border-radius: var(--gg-radius-md);
overflow: hidden;
cursor: pointer;
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
position: relative; border-radius: var(--gg-radius-md); overflow: hidden; cursor: pointer;
background: var(--gg-bg-card); border: 1px solid var(--gg-border);
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
}
.game-card:hover { transform: translateY(-4px); border-color: var(--gg-primary); box-shadow: 0 4px 20px rgba(5, 150, 105, 0.15); }
.game-card:hover {
transform: translateY(-4px);
border-color: var(--gg-primary);
box-shadow: 0 4px 20px rgba(5, 150, 105, 0.15);
}
.game-cover { width: 100%; aspect-ratio: 3/4; object-fit: cover; }
.game-cover {
width: 100%;
aspect-ratio: 3/4;
object-fit: cover;
}
.game-info {
padding: 14px;
}
.game-info { padding: 14px; }
.game-name {
font-size: 16px;
font-weight: 700;
margin: 0 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--gg-text);
}
.game-platform {
font-size: 13px;
color: var(--gg-text-secondary);
margin: 0 0 8px;
}
.game-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
font-size: 16px; font-weight: 700; margin: 0 0 4px; color: var(--gg-text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.game-platform { font-size: 13px; color: var(--gg-text-secondary); margin: 0 0 8px; }
.game-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.tag {
padding: 3px 10px;
background: var(--gg-bg-elevated);
border-radius: 6px;
font-size: 11px;
color: var(--gg-primary);
font-weight: 500;
padding: 3px 10px; background: var(--gg-bg-elevated); border-radius: 6px;
font-size: 11px; color: var(--gg-primary); font-weight: 500;
}
.delete-btn {
position: absolute; top: 8px; right: 8px; width: 28px; height: 28px;
border: none; border-radius: 50%; background: rgba(0,0,0,0.5); color: white;
font-size: 12px; cursor: pointer; opacity: 0; transition: opacity 0.2s;
display: flex; align-items: center; justify-content: center;
}
.game-card:hover .delete-btn { opacity: 1; }
.delete-btn:hover { background: var(--gg-danger); }
</style>
+1 -1
View File
@@ -153,7 +153,7 @@ async function refreshMembers() {
<div v-if="list.length === 0" class="empty-row">暂无</div>
<div v-else class="member-row-list">
<div v-for="m in list" :key="m.id" class="member-row">
<img :src="m.avatar || '/default-avatar.png'" :alt="m.username" class="avatar" />
<img :src="m.avatar || '/default-avatar.svg'" :alt="m.username" class="avatar" />
<span class="member-name">{{ m.username }}</span>
<span v-if="m.statusNote" class="member-note">{{ m.statusNote }}</span>
</div>
+1 -1
View File
@@ -137,7 +137,7 @@ function openGameDetail(game: Game) {
>
<div class="game-cover-wrap">
<img
:src="game.cover || '/game-placeholder.png'"
:src="game.cover || '/game-placeholder.svg'"
:alt="game.name"
class="game-cover"
/>
+1 -1
View File
@@ -106,7 +106,7 @@ function goHome() {
</div>
<div class="user-info">
<img
:src="userStore.user?.avatar || '/default-avatar.png'"
:src="userStore.user?.avatar || '/default-avatar.svg'"
class="user-avatar"
alt=""
/>
+1 -1
View File
@@ -36,7 +36,7 @@ function handleAvatarUpload() {
<el-card class="profile-card">
<div class="profile-form">
<div class="avatar-section">
<img :src="avatar || '/default-avatar.png'" class="avatar" />
<img :src="avatar || '/default-avatar.svg'" class="avatar" />
<el-button @click="handleAvatarUpload">更换头像</el-button>
</div>