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:
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user