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:
@@ -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
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module gamegroup-hooks
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/pocketbase/pocketbase v0.22.4
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 "导入完成!"
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect fill="#e8f5e9" width="64" height="64" rx="32"/>
|
||||
<text x="32" y="40" text-anchor="middle" fill="#059669" font-size="28">👤</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 232 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="260" viewBox="0 0 200 260">
|
||||
<rect fill="#e8f5e9" width="200" height="260"/>
|
||||
<text x="100" y="120" text-anchor="middle" fill="#059669" font-size="48">🎮</text>
|
||||
<text x="100" y="155" text-anchor="middle" fill="#6b7280" font-size="14">暂无封面</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 327 B |
@@ -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' })
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅邀请变更
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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