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:
+122
-65
@@ -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,cover(tags 用分号分隔)</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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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=""
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user