diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 008c13d..bb1257d 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -10,6 +10,7 @@ services: volumes: - ./pb_data:/pb_data - ./pb_migrations:/pb_migrations + - ./pb_hooks:/pb_hooks environment: - GO_ENV=production restart: unless-stopped diff --git a/backend/pb_hooks/go.mod b/backend/pb_hooks/go.mod deleted file mode 100644 index 1cbfed2..0000000 --- a/backend/pb_hooks/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module gamegroup-hooks - -go 1.21 - -require github.com/pocketbase/pocketbase v0.22.4 diff --git a/backend/pb_hooks/main.go b/backend/pb_hooks/main.go deleted file mode 100644 index 8008397..0000000 --- a/backend/pb_hooks/main.go +++ /dev/null @@ -1,159 +0,0 @@ -package main - -import ( - "log" - - "github.com/pocketbase/pocketbase" - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/core" -) - -func main() { - app := pocketbase.New() - - // ── Groups ── - - app.OnRecordBeforeCreateRequest("groups").Add(func(e *core.RecordCreateEvent) error { - authRecord, _ := e.HttpContext.AuthRecord() - if authRecord == nil { - return apis.NewForbiddenError("需要登录", nil) - } - e.Record.Set("owner", authRecord.Id) - e.Record.Set("members", []string{authRecord.Id}) - return nil - }) - - app.OnRecordBeforeUpdateRequest("groups").Add(func(e *core.RecordUpdateEvent) error { - authRecord, _ := e.HttpContext.AuthRecord() - if authRecord == nil { - return apis.NewForbiddenError("需要登录", nil) - } - original, err := app.Dao().FindRecordById("groups", e.Record.Id) - if err != nil { - return apis.NewNotFoundError("群组不存在", nil) - } - if original.GetString("owner") != authRecord.Id { - return apis.NewForbiddenError("只有群组所有者可以更新群组", nil) - } - // 保护 owner 字段不被篡改 - e.Record.Set("owner", original.GetString("owner")) - // 验证 members 数量 - members := e.Record.GetStringSlice("members") - maxMembers := e.Record.GetInt("maxMembers") - if maxMembers > 0 && len(members) > maxMembers { - return apis.NewBadRequestError("成员数量超过上限", nil) - } - return nil - }) - - app.OnRecordBeforeDeleteRequest("groups").Add(func(e *core.RecordDeleteEvent) error { - authRecord, _ := e.HttpContext.AuthRecord() - if authRecord == nil { - return apis.NewForbiddenError("需要登录", nil) - } - isOwner, err := isGroupOwner(app, e.Record.Id, authRecord.Id) - if err != nil || !isOwner { - return apis.NewForbiddenError("只有群组所有者可以删除群组", nil) - } - return nil - }) - - // ── Team Sessions ── - - app.OnRecordBeforeCreateRequest("team_sessions").Add(func(e *core.RecordCreateEvent) error { - authRecord, _ := e.HttpContext.AuthRecord() - if authRecord == nil { - return apis.NewForbiddenError("需要登录", nil) - } - groupId := e.Record.GetString("sourceGroup") - isMember, err := isGroupMember(app, groupId, authRecord.Id) - if err != nil || !isMember { - return apis.NewForbiddenError("只有群组成员可以创建团队会话", nil) - } - return nil - }) - - // ── Invitations ── - - app.OnRecordBeforeCreateRequest("invitations").Add(func(e *core.RecordCreateEvent) error { - authRecord, _ := e.HttpContext.AuthRecord() - if authRecord == nil { - return apis.NewForbiddenError("需要登录", nil) - } - // 强制设置 from 为当前用户 - e.Record.Set("from", authRecord.Id) - e.Record.Set("status", "pending") - return nil - }) - - // 接受邀请:自动加入 team session + 更新用户状态 - app.OnRecordBeforeUpdateRequest("invitations").Add(func(e *core.RecordUpdateEvent) error { - authRecord, _ := e.HttpContext.AuthRecord() - if authRecord == nil { - return apis.NewForbiddenError("需要登录", nil) - } - // 只有 recipient 可以更新邀请 - if e.Record.GetString("to") != authRecord.Id { - return apis.NewForbiddenError("无权操作此邀请", nil) - } - - newStatus := e.Record.GetString("status") - if newStatus == "accepted" { - teamSessionId := e.Record.GetString("teamSession") - teamSession, err := app.Dao().FindRecordById("team_sessions", teamSessionId) - if err != nil { - return apis.NewNotFoundError("临时小组不存在", nil) - } - // 将用户加入 team session members - members := teamSession.GetStringSlice("members") - alreadyIn := false - for _, m := range members { - if m == authRecord.Id { - alreadyIn = true - break - } - } - if !alreadyIn { - members = append(members, authRecord.Id) - teamSession.Set("members", members) - if err := app.Dao().SaveRecord(teamSession); err != nil { - return apis.NewBadRequestError("加入临时小组失败", nil) - } - } - - // 更新用户状态为 in_team - user, err := app.Dao().FindRecordById("users", authRecord.Id) - if err == nil { - user.Set("status", "in_team") - app.Dao().SaveRecord(user) - } - } - return nil - }) - - if err := app.Start(); err != nil { - log.Fatal(err) - } -} - -func isGroupMember(app *pocketbase.PocketBase, groupId string, userId string) (bool, error) { - group, err := app.Dao().FindRecordById("groups", groupId) - if err != nil { - return false, err - } - members := group.GetStringSlice("members") - for _, member := range members { - if member == userId { - return true, nil - } - } - return false, nil -} - -func isGroupOwner(app *pocketbase.PocketBase, groupId string, userId string) (bool, error) { - group, err := app.Dao().FindRecordById("groups", groupId) - if err != nil { - return false, err - } - return group.GetString("owner") == userId, nil -} diff --git a/backend/pb_hooks/main.js b/backend/pb_hooks/main.js new file mode 100644 index 0000000..08e3d9d --- /dev/null +++ b/backend/pb_hooks/main.js @@ -0,0 +1,5 @@ +// JS hooks placeholder - PocketBase 0.22.4 muchobien image may not support JS VM +// Key logic handled in frontend instead: +// - Groups: frontend sets owner + members on create +// - Invitations: frontend auto-joins team session on accept +// - Team dissolution: frontend resets member statuses on end diff --git a/backend/scripts/seed-games.sh b/backend/scripts/seed-games.sh new file mode 100644 index 0000000..524a5b1 --- /dev/null +++ b/backend/scripts/seed-games.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# seed-games.sh - 批量导入热门游戏数据到 PocketBase +set -e + +PB_URL="${PB_URL:-http://192.168.1.14:8090}" +ADMIN_EMAIL="${ADMIN_EMAIL:-admin@example.com}" +ADMIN_PASS="${ADMIN_PASS:-admin123456}" + +echo "获取管理员 Token..." +TOKEN=$(curl -s -X POST "$PB_URL/api/admins/auth-with-password" \ + -H "Content-Type: application/json" \ + -d "{\"identity\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\"}" | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") + +echo "Token 获取成功,开始导入游戏..." + +create_game() { + local name="$1" platform="$2" tags="$3" count="$4" + curl -s -X POST "$PB_URL/api/collections/games/records" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"$name\",\"platform\":\"$platform\",\"tags\":$tags,\"popularCount\":$count}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f' ✓ {d.get(\"name\",\"ERROR\")}')" +} + +# PC 游戏 +create_game "League of Legends" "PC" '["MOBA","竞技","5v5"]' 10000 +create_game "Counter-Strike 2" "PC" '["FPS","射击","竞技"]' 9500 +create_game "Valorant" "PC" '["FPS","射击","战术"]' 9000 +create_game "Dota 2" "PC" '["MOBA","竞技","策略"]' 8500 +create_game "Apex Legends" "PC" '["FPS","大逃杀","吃鸡"]' 8000 +create_game "PUBG" "PC" '["大逃杀","吃鸡","射击"]' 7500 +create_game "Overwatch 2" "PC" '["FPS","英雄射击","团队"]' 7000 +create_game "Minecraft" "PC" '["沙盒","生存","建造"]' 6500 +create_game "Genshin Impact" "PC" '["RPG","开放世界","二次元"]' 6000 +create_game "Elden Ring" "PC" '["RPG","魂系","开放世界"]' 5500 +create_game "Teamfight Tactics" "PC" '["自走棋","策略","休闲"]' 5000 +create_game "Call of Duty: Warzone" "PC" '["FPS","大逃杀","射击"]' 4500 +create_game "Fortnite" "PC" '["大逃杀","建造","射击"]' 4000 +create_game "Dead by Daylight" "PC" '["恐怖","非对称","多人"]' 3500 +create_game "Among Us" "PC" '["社交","推理","休闲"]' 3000 +create_game "It Takes Two" "PC" '["合作","双人","冒险"]' 2500 +create_game "Baldurs Gate 3" "PC" '["RPG","回合制","合作"]' 2000 + +# PS5 +create_game "Helldivers 2" "PS5" '["射击","合作","PvE"]' 1800 +create_game "Final Fantasy XIV" "PS5" '["MMORPG","RPG","多人"]' 1500 +create_game "Spider-Man 2" "PS5" '["动作","冒险","开放世界"]' 1200 +create_game "God of War Ragnarok" "PS5" '["动作","冒险","单人"]' 1000 + +# Switch +create_game "Mario Kart 8" "Switch" '["竞速","休闲","派对"]' 2000 +create_game "Zelda: TOTK" "Switch" '["冒险","开放世界","解谜"]' 1800 +create_game "Super Smash Bros" "Switch" '["格斗","派对","竞技"]' 1500 +create_game "Pokemon Scarlet" "Switch" '["RPG","收集","冒险"]' 1200 + +# Mobile +create_game "Honor of Kings" "Mobile" '["MOBA","竞技","5v5"]' 9000 +create_game "PUBG Mobile" "Mobile" '["大逃杀","吃鸡","射击"]' 7000 +create_game "Genshin Impact" "Mobile" '["RPG","开放世界","二次元"]' 5500 +create_game "Call of Duty Mobile" "Mobile" '["FPS","射击","多人"]' 4000 +create_game "Arena of Valor" "Mobile" '["MOBA","竞技","5v5"]' 3000 + +# Xbox +create_game "Halo Infinite" "Xbox" '["FPS","射击","竞技"]' 3000 +create_game "Forza Horizon 5" "Xbox" '["竞速","开放世界","休闲"]' 2500 +create_game "Starfield" "Xbox" '["RPG","开放世界","太空"]' 2000 + +echo "导入完成!" diff --git a/docker-compose.backend.yml b/docker-compose.backend.yml index 049f47d..2649c57 100644 --- a/docker-compose.backend.yml +++ b/docker-compose.backend.yml @@ -7,6 +7,7 @@ services: volumes: - ./backend/pb_data:/pb_data - ./backend/pb_migrations:/pb_migrations + - ./backend/pb_hooks:/pb_hooks environment: - GO_ENV=production restart: unless-stopped diff --git a/frontend/public/default-avatar.png b/frontend/public/default-avatar.png new file mode 100644 index 0000000..8687dbe --- /dev/null +++ b/frontend/public/default-avatar.png @@ -0,0 +1,4 @@ + + + 👤 + diff --git a/frontend/public/game-placeholder.png b/frontend/public/game-placeholder.png new file mode 100644 index 0000000..64043e1 --- /dev/null +++ b/frontend/public/game-placeholder.png @@ -0,0 +1,5 @@ + + + 🎮 + 暂无封面 + diff --git a/frontend/src/api/invitations.ts b/frontend/src/api/invitations.ts index 85f3afc..950b7b9 100644 --- a/frontend/src/api/invitations.ts +++ b/frontend/src/api/invitations.ts @@ -77,8 +77,29 @@ export async function respondInvitation( updateData.rejectReason = rejectReason } - // 后端 hook 会自动处理:加入 team members + 更新用户状态 + // 更新邀请状态 await pb.collection('invitations').update(invitationId, updateData) + + // 接受邀请:前端处理加入 team session + 更新用户状态 + if (response === 'accepted') { + const user = pb.authStore.model + if (!user) return + + // 获取邀请详情以找到 team session + const invitation = await pb.collection('invitations').getOne(invitationId) as any + const teamSessionId = invitation.teamSession + + // 加入 team session + const session = await pb.collection('team_sessions').getOne(teamSessionId) as any + const members: string[] = session.members || [] + if (!members.includes(user.id)) { + members.push(user.id) + await pb.collection('team_sessions').update(teamSessionId, { members }) + } + + // 更新用户状态为 in_team + await pb.collection('users').update(user.id, { status: 'in_team' }) + } } // 订阅邀请变更 diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 98b77f3..0e1c107 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -49,9 +49,23 @@ export async function updateTeamStatus(sessionId: string, status: TeamStatus): P return pb.collection('team_sessions').update(sessionId, updateData) as unknown as TeamSession } -// 结束游戏(解散临时小组) +// 结束游戏(解散临时小组 + 重置成员状态) export async function endGame(sessionId: string) { - return updateTeamStatus(sessionId, 'dissolved') + const session = await pb.collection('team_sessions').getOne(sessionId) as any + const members: string[] = session.members || [] + + // 解散临时小组 + await updateTeamStatus(sessionId, 'dissolved') + + // 重置所有成员状态为 idle + for (const memberId of members) { + try { + const user = await pb.collection('users').getOne(memberId) as any + if (user && user.status === 'in_team') { + await pb.collection('users').update(memberId, { status: 'idle' }) + } + } catch (_) {} + } } // 加入临时小组 diff --git a/frontend/src/components/game/GameDetailDialog.vue b/frontend/src/components/game/GameDetailDialog.vue new file mode 100644 index 0000000..4a53896 --- /dev/null +++ b/frontend/src/components/game/GameDetailDialog.vue @@ -0,0 +1,131 @@ + + + + + + + + + + + {{ game.name }} + + + {{ game.platform }} + 🔥 {{ game.popularCount }} 人游玩 + + + + {{ tag }} + + + + 快速组队 + + + + + + + diff --git a/frontend/src/components/team/TeamSessionPanel.vue b/frontend/src/components/team/TeamSessionPanel.vue index 4b49371..4d6401d 100644 --- a/frontend/src/components/team/TeamSessionPanel.vue +++ b/frontend/src/components/team/TeamSessionPanel.vue @@ -19,6 +19,14 @@ const memberDetails = computed(() => { .filter((m): m is NonNullable => Boolean(m)) }) +async function startGame() { + try { + await teamStore.updateStatus('playing') + } catch { + // ignore + } +} + async function endGame() { try { await ElMessageBox.confirm( @@ -67,9 +75,15 @@ async function endGame() { - + + 开始游戏 + + 游戏结束 + + 解散小组 + @@ -177,6 +191,43 @@ async function endGame() { color: var(--gg-text); } +.start-game-btn { + width: 100%; + padding: 12px; + margin-bottom: 8px; + border: none; + border-radius: var(--gg-radius-sm); + background: var(--gg-gradient-green); + color: white; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s, box-shadow 0.2s; +} + +.start-game-btn:hover { + opacity: 0.9; + box-shadow: 0 4px 16px rgba(5, 150, 105, 0.3); +} + +.dissolve-btn { + width: 100%; + padding: 10px; + border: 1px solid var(--gg-border); + border-radius: var(--gg-radius-sm); + background: transparent; + color: var(--gg-text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.dissolve-btn:hover { + border-color: var(--gg-danger); + color: var(--gg-danger); +} + .end-game-btn { width: 100%; padding: 12px; diff --git a/frontend/src/stores/notification.ts b/frontend/src/stores/notification.ts index 4674ea3..6142789 100644 --- a/frontend/src/stores/notification.ts +++ b/frontend/src/stores/notification.ts @@ -1,12 +1,14 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { Invitation } from '@/types' -import { getPendingInvitations } from '@/api/invitations' +import { getPendingInvitations, subscribeInvitations } from '@/api/invitations' +import { pb } from '@/api/pocketbase' export const useNotificationStore = defineStore('notification', () => { const pendingInvitations = ref([]) const loading = ref(false) const showPanel = ref(false) + let unsubFn: (() => Promise | void) | null = null const unreadCount = computed(() => pendingInvitations.value.length) @@ -21,6 +23,22 @@ export const useNotificationStore = defineStore('notification', () => { } } + async function startListening() { + const user = pb.authStore.model + if (!user) return + + unsubFn = await subscribeInvitations(() => { + loadPendingInvitations() + }) + } + + function stopListening() { + if (unsubFn) { + unsubFn() + unsubFn = null + } + } + function removeInvitation(invitationId: string) { pendingInvitations.value = pendingInvitations.value.filter(i => i.id !== invitationId) } @@ -35,6 +53,8 @@ export const useNotificationStore = defineStore('notification', () => { showPanel, unreadCount, loadPendingInvitations, + startListening, + stopListening, removeInvitation, togglePanel } diff --git a/frontend/src/views/GamesLibrary.vue b/frontend/src/views/GamesLibrary.vue index 0bfc004..27753e8 100644 --- a/frontend/src/views/GamesLibrary.vue +++ b/frontend/src/views/GamesLibrary.vue @@ -3,13 +3,15 @@ import { ref, onMounted } from 'vue' import { getGames, getAllPlatforms } from '@/api/games' import type { Game, GamePlatform } from '@/types' -import { ElCard, ElInput, ElSelect, ElOption } from 'element-plus' +import GameDetailDialog from '@/components/game/GameDetailDialog.vue' const games = ref([]) const loading = ref(false) const searchQuery = ref('') const selectedPlatform = ref('') const platforms = getAllPlatforms() +const selectedGame = ref(null) +const showDetail = ref(false) onMounted(async () => { await loadGames() @@ -34,6 +36,15 @@ async function loadGames() { function handleSearch() { loadGames() } + +function openGameDetail(game: Game) { + selectedGame.value = game + showDetail.value = true +} + +function handleCreateTeam(_gameName: string) { + // TODO: 导航到群组页面触发组队流程 +} @@ -65,7 +76,7 @@ function handleSearch() { - + {{ tag }} - + + + @@ -143,16 +160,13 @@ function handleSearch() { .game-card:hover { transform: translateY(-4px); border-color: var(--gg-primary); - box-shadow: 0 0 20px rgba(99, 102, 241, 0.15); + box-shadow: 0 4px 20px rgba(5, 150, 105, 0.15); } .game-cover { width: 100%; aspect-ratio: 3/4; object-fit: cover; - border-radius: var(--gg-radius-sm) var(--gg-radius-sm) 0 0; - mask-image: linear-gradient(to bottom, black 70%, transparent 100%); - -webkit-mask-image: linear-gradient(to bottom, black 70%, transparent 100%); } .game-info { @@ -186,7 +200,7 @@ function handleSearch() { background: var(--gg-bg-elevated); border-radius: 6px; font-size: 11px; - color: var(--gg-primary-light); + color: var(--gg-primary); font-weight: 500; } diff --git a/frontend/src/views/GroupView.vue b/frontend/src/views/GroupView.vue index 428664e..3f3ce87 100644 --- a/frontend/src/views/GroupView.vue +++ b/frontend/src/views/GroupView.vue @@ -1,14 +1,19 @@ @@ -125,7 +133,7 @@ function selectGroup(groupId: string) { v-for="game in popularGames" :key="game.id" class="game-card" - @click="router.push({ name: 'GamesLibrary' })" + @click="openGameDetail(game)" > + + {}" /> diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index 29736de..f14b617 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -1,5 +1,5 @@