feat: complete Phase 1 - game library, lifecycle, realtime sync

- Seed 33 popular games across 5 platforms via admin API script
- Add GameDetailDialog with game info and quick-team button
- Update GamesLibrary with game card click to open detail dialog
- Update Home hot games to open detail dialog instead of navigating
- Rewrite invitation accept: frontend auto-joins team + updates status
- Add user status reset on team dissolution (endGame)
- Add start game / dissolve buttons to TeamSessionPanel lifecycle
- Integrate realtime subscriptions in GroupView and Layout
- Add notification store realtime invitation listener
- Add placeholder images for game covers and avatars
- Remove Go hooks, add JS hooks placeholder + Docker mount

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-17 20:23:39 +08:00
parent 0bcf39bb4b
commit 802712c662
17 changed files with 394 additions and 180 deletions
+22 -1
View File
@@ -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' })
}
}
// 订阅邀请变更
+16 -2
View File
@@ -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 (_) {}
}
}
// 加入临时小组
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Game } from '@/types'
const props = defineProps<{
modelValue: boolean
game: Game | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'create-team': [gameName: string]
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
function handleCreateTeam() {
if (props.game) {
emit('create-team', props.game.name)
visible.value = false
}
}
</script>
<template>
<el-dialog v-model="visible" width="440px" :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"
/>
</div>
<h2 class="detail-name">{{ game.name }}</h2>
<div class="detail-meta">
<span v-if="game.platform" class="platform-badge">{{ game.platform }}</span>
<span class="popularity">🔥 {{ game.popularCount }} 人游玩</span>
</div>
<div v-if="game.tags?.length" class="detail-tags">
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<el-button type="primary" class="team-btn" @click="handleCreateTeam">
快速组队
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.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 {
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-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-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;
}
</style>
@@ -19,6 +19,14 @@ const memberDetails = computed(() => {
.filter((m): m is NonNullable<typeof m> => 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() {
</div>
</div>
<button class="end-game-btn" @click="endGame">
<button v-if="session.status === 'recruiting'" class="start-game-btn" @click="startGame">
开始游戏
</button>
<button v-else-if="session.status === 'playing'" class="end-game-btn" @click="endGame">
游戏结束
</button>
<button v-if="session.status === 'recruiting'" class="dissolve-btn" @click="endGame">
解散小组
</button>
</div>
<div v-else class="no-session">
@@ -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;
+21 -1
View File
@@ -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<Invitation[]>([])
const loading = ref(false)
const showPanel = ref(false)
let unsubFn: (() => Promise<void> | 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
}
+22 -8
View File
@@ -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<Game[]>([])
const loading = ref(false)
const searchQuery = ref('')
const selectedPlatform = ref<GamePlatform | ''>('')
const platforms = getAllPlatforms()
const selectedGame = ref<Game | null>(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: 导航到群组页面触发组队流程
}
</script>
<template>
@@ -65,7 +76,7 @@ function handleSearch() {
</div>
<div v-else class="games-grid">
<el-card v-for="game in games" :key="game.id" class="game-card">
<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"
@@ -78,8 +89,14 @@ function handleSearch() {
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
</div>
</el-card>
</div>
</div>
<GameDetailDialog
v-model="showDetail"
:game="selectedGame"
@create-team="handleCreateTeam"
/>
</div>
</template>
@@ -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;
}
</style>
+30 -1
View File
@@ -1,14 +1,19 @@
<!-- src/views/GroupView.vue -->
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { onMounted, onUnmounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useGroupStore } from '@/stores/group'
import { useTeamStore } from '@/stores/team'
import { pb } from '@/api/pocketbase'
import TeamSessionPanel from '@/components/team/TeamSessionPanel.vue'
import IdleMembersList from '@/components/team/IdleMembersList.vue'
import GroupMembersPanel from '@/components/group/GroupMembersPanel.vue'
const route = useRoute()
const groupStore = useGroupStore()
const teamStore = useTeamStore()
const unsubFns: (() => Promise<void>)[] = []
const groupId = route.params.id as string
@@ -53,6 +58,30 @@ const statusColors: Record<string, string> = {
onMounted(async () => {
await groupStore.setCurrentGroup(groupId)
// 订阅用户状态变更
unsubFns.push(await pb.collection('users').subscribe('*', () => {
groupStore.setCurrentGroup(groupId)
}))
// 订阅群组变更
unsubFns.push(await pb.collection('groups').subscribe('*', (payload) => {
if (payload.record.id === groupId) {
groupStore.setCurrentGroup(groupId)
}
}))
// 订阅临时小组变更
unsubFns.push(await pb.collection('team_sessions').subscribe('*', () => {
teamStore.loadActiveSession()
}))
})
onUnmounted(async () => {
for (const unsub of unsubFns) {
try { await unsub() } catch (_) {}
}
unsubFns.length = 0
})
async function refreshMembers() {
+11 -1
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'vue-router'
import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user'
import { getPopularGames } from '@/api/games'
import GameDetailDialog from '@/components/game/GameDetailDialog.vue'
import { UserStatusMap } from '@/types'
import type { Game } from '@/types'
import TeamSessionPanel from '@/components/team/TeamSessionPanel.vue'
@@ -16,6 +17,8 @@ const userStore = useUserStore()
const popularGames = ref<Game[]>([])
const loading = ref(false)
const selectedGame = ref<Game | null>(null)
const showGameDetail = ref(false)
const statusDotClass = computed(() => {
const s = userStore.userStatus
@@ -43,6 +46,11 @@ function selectGroup(groupId: string) {
groupStore.setCurrentGroup(groupId)
router.push({ name: 'GroupView', params: { id: groupId } })
}
function openGameDetail(game: Game) {
selectedGame.value = game
showGameDetail.value = true
}
</script>
<template>
@@ -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)"
>
<div class="game-cover-wrap">
<img
@@ -141,6 +149,8 @@ function selectGroup(groupId: string) {
</div>
</div>
</section>
<GameDetailDialog v-model="showGameDetail" :game="selectedGame" @create-team="() => {}" />
</div>
</template>
+6 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
@@ -27,6 +27,11 @@ onMounted(async () => {
await groupStore.loadGroups()
await teamStore.loadActiveSession()
await notificationStore.loadPendingInvitations()
await notificationStore.startListening()
})
onUnmounted(() => {
notificationStore.stopListening()
})
function handleLogout() {