# Game Group V2 MVP Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build a game team matching platform with handshake-style invitations, multi-group support, and temporary team sessions. **Architecture:** - Backend: PocketBase (Go-based BaaS) with SQLite storage - Frontend: Vue 3 + TypeScript + Element Plus + Tailwind CSS - Real-time: PocketBase built-in WebSocket subscriptions **Tech Stack:** - PocketBase 0.22+ - Vue 3.4+, TypeScript 5.3+ - Element Plus 2.5+, Tailwind CSS 3.4+ - Vite 5.0+ --- ## Phase 1: Backend Setup (PocketBase) ### Task 1: Initialize PocketBase Project **Files:** - Create: `backend/.env` - Create: `backend/pb_migrations/1738717600_init.pb.js` - Create: `backend/docker-compose.yml` - Create: `backend/README.md` **Step 1: Create .env configuration** ```bash # backend/.env PB_PORT=8090 PB_DATA=./pb_data PB_MIGRATIONS=./pb_migrations ``` **Step 2: Create initial migration** Create `backend/pb_migrations/1738717600_init.pb.js`: ```javascript /// https://github.com/pocketbase/pb_migrations /// Created: users, groups, games, teamSessions, invitations collections // Users collection - extended auth collection migrate((app) => { const usersCollection = app.findCollectionByName("users") if (usersCollection) { // Extend existing users collection usersCollection.schema.addField(new SchemaField({ system: false, id: "status_user", name: "status", type: "select", required: false, presentable: false, unique: false, options: { maxSelect: 1, values: ["idle", "working", "in_team", "away"] } })) usersCollection.schema.addField(new SchemaField({ system: false, id: "status_note", name: "statusNote", type: "text", required: false, presentable: false, unique: false })) usersCollection.schema.addField(new SchemaField({ system: false, id: "max_groups", name: "maxGroups", type: "number", required: false, presentable: false, unique: false })) usersCollection.schema.addField(new SchemaField({ system: false, id: "workdays_json", name: "workdays", type: "json", required: false, presentable: false, unique: false })) usersCollection.schema.addField(new SchemaField({ system: false, id: "work_start_time", name: "workStartTime", type: "text", required: false, presentable: false, unique: false })) usersCollection.schema.addField(new SchemaField({ system: false, id: "next_work_time", name: "nextWorkTime", type: "number", required: false, presentable: false, unique: false })) usersCollection.schema.addField(new SchemaField({ system: false, id: "points_num", name: "points", type: "number", required: false, presentable: false, unique: false })) } }, (app) => { // Rollback const usersCollection = app.findCollectionByName("users") if (usersCollection) { usersCollection.schema.removeField("status") usersCollection.schema.removeField("statusNote") usersCollection.schema.removeField("maxGroups") usersCollection.schema.removeField("workdays") usersCollection.schema.removeField("workStartTime") usersCollection.schema.removeField("nextWorkTime") usersCollection.schema.removeField("points") } }) // Groups collection migrate((app) => { app.findCollectionByNameOrCreate("groups", (collection) => { collection.name = "groups" collection.schema.addField(new SchemaField({ system: false, id: "name_group", name: "name", type: "text", required: true, presentable: true, unique: false })) collection.schema.addField(new SchemaField({ system: false, id: "description_text", name: "description", type: "text", required: false, presentable: false, unique: false })) collection.schema.addField(new RelationField({ system: false, id: "owner_user", name: "owner", type: "relation", required: true, presentable: false, unique: false, collectionId: "_pb_users_auth_" })) collection.schema.addField(new RelationField({ system: false, id: "members_users", name: "members", type: "relation", required: false, presentable: false, unique: false, collectionId: "_pb_users_auth_", maxSelect: 65535 })) collection.schema.addField(new SchemaField({ system: false, id: "max_members", name: "maxMembers", type: "number", required: false, presentable: false, unique: false })) }) }, (app) => { app.findCollectionByName("groups")?.delete() }) // Games collection migrate((app) => { app.findCollectionByNameOrCreate("games", (collection) => { collection.name = "games" collection.schema.addField(new SchemaField({ system: false, id: "name_game", name: "name", type: "text", required: true, presentable: true, unique: false })) collection.schema.addField(new SchemaField({ system: false, id: "platform_select", name: "platform", type: "select", required: false, presentable: false, unique: false, options: { maxSelect: 1, values: ["PC", "PS5", "Xbox", "Switch", "Mobile"] } })) collection.schema.addField(new SchemaField({ system: false, id: "tags_json", name: "tags", type: "json", required: false, presentable: false, unique: false })) collection.schema.addField(new SchemaField({ system: false, id: "cover_url", name: "cover", type: "url", required: false, presentable: false, unique: false })) collection.schema.addField(new SchemaField({ system: false, id: "popular_count", name: "popularCount", type: "number", required: false, presentable: false, unique: false })) }) }, (app) => { app.findCollectionByName("games")?.delete() }) // Team Sessions collection migrate((app) => { app.findCollectionByNameOrCreate("teamSessions", (collection) => { collection.name = "teamSessions" collection.schema.addField(new SchemaField({ system: false, id: "name_session", name: "name", type: "text", required: true, presentable: true, unique: false })) collection.schema.addField(new RelationField({ system: false, id: "source_group", name: "sourceGroup", type: "relation", required: true, presentable: false, unique: false, collectionId: "groups" })) collection.schema.addField(new SchemaField({ system: false, id: "game_name", name: "gameName", type: "text", required: true, presentable: false, unique: false })) collection.schema.addField(new RelationField({ system: false, id: "members_users", name: "members", type: "relation", required: false, presentable: false, unique: false, collectionId: "_pb_users_auth_", maxSelect: 65535 })) collection.schema.addField(new SchemaField({ system: false, id: "status_session", name: "status", type: "select", required: false, presentable: false, unique: false, options: { maxSelect: 1, values: ["recruiting", "playing", "finished", "dissolved"] } })) }) }, (app) => { app.findCollectionByName("teamSessions")?.delete() }) // Invitations collection migrate((app) => { app.findCollectionByNameOrCreate("invitations", (collection) => { collection.name = "invitations" collection.schema.addField(new RelationField({ system: false, id: "from_user", name: "from", type: "relation", required: true, presentable: false, unique: false, collectionId: "_pb_users_auth_" })) collection.schema.addField(new RelationField({ system: false, id: "to_user", name: "to", type: "relation", required: true, presentable: false, unique: false, collectionId: "_pb_users_auth_" })) collection.schema.addField(new RelationField({ system: false, id: "team_session", name: "teamSession", type: "relation", required: true, presentable: false, unique: false, collectionId: "teamSessions" })) collection.schema.addField(new SchemaField({ system: false, id: "status_invite", name: "status", type: "select", required: false, presentable: false, unique: false, options: { maxSelect: 1, values: ["pending", "accepted", "rejected"] } })) collection.schema.addField(new SchemaField({ system: false, id: "reject_reason", name: "rejectReason", type: "text", required: false, presentable: false, unique: false })) }) }, (app) => { app.findCollectionByName("invitations")?.delete() }) ``` **Step 3: Create docker-compose.yml** ```yaml # backend/docker-compose.yml version: '3.8' services: pocketbase: image: ghcr.io/muchobien/pocketbase:latest container_name: gamegroup-pb ports: - "${PB_PORT:-8090}:8090" volumes: - ./pb_data:/pb_data - ./pb_migrations:/pb_migrations environment: - GO_ENV=production restart: unless-stopped ``` **Step 4: Create backend README** ```markdown # Game Group V2 Backend PocketBase BaaS 服务 ## 启动 ```bash # 方式1: Docker docker-compose up -d # 方式2: 直接运行 ./pocketbase serve --env .env ``` 访问: http://localhost:8090/_/ 管理后台: http://localhost:8090/_/ (admin/admin) ``` **Step 5: Commit** ```bash cd backend git init git add . git commit -m "feat: initialize PocketBase backend with migrations" ``` --- ### Task 2: Configure PocketBase API Rules **Files:** - Create: `backend/pb_hooks/main.go` **Step 1: Create API rules hook** Create `backend/pb_hooks/main.go`: ```go package main import ( "log" "strings" "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/tools/types" ) // Custom status enum const ( StatusIdle = "idle" StatusWorking = "working" StatusInTeam = "in_team" StatusAway = "away" ) func init() { // Hook called after Pocketbase initialization pocketbase.OnBeforeServe().Add(func(e *core.ServeEvent) error { // Register custom endpoints e.Router.GET("/api/status", func(re *core.RequestEvent) error { return e.JSON(200, map[string]string{"status": "ok"}) }) return nil }) } // Middleware to check if user is group member func isGroupMember(app *pocketbase.PocketBase, groupId string, userId string) bool { group, err := app.Dao().FindCollectionByNameOrId("groups") if err != nil { return false } record, err := app.Dao().FindRecordById(group, groupId) if err != nil { return false } members := record.GetStringSlice("members") for _, m := range members { if m == userId { return true } } return record.GetString("owner") == userId } // Middleware to check if user is group owner func isGroupOwner(app *pocketbase.PocketBase, groupId string, userId string) bool { group, err := app.Dao().FindCollectionByNameOrId("groups") if err != nil { return false } record, err := app.Dao().FindRecordById(group, groupId) if err != nil { return false } return record.GetString("owner") == userId } func main() { app := pocketbase.New() // Groups API Rules app.OnRecordBeforeCreateRequest("groups").Add(func(e *core.RecordEvent) error { if e.HttpContext.IsAdmin() { return nil } authRecord, _ := e.HttpContext.AuthRecord() if authRecord == nil { return apis.NewForbiddenError("需要登录", nil) } // Set owner to current user e.Record.Set("owner", authRecord.Id) // Add owner to members members := e.Record.GetStringSlice("members") e.Record.Set("members", append(members, authRecord.Id)) return nil }) app.OnRecordBeforeUpdateRequest("groups").Add(func(e *core.RecordEvent) error { if e.HttpContext.IsAdmin() { return nil } authRecord, _ := e.HttpContext.AuthRecord() if authRecord == nil { return apis.NewForbiddenError("需要登录", nil) } // Only owner can update if e.Record.GetString("owner") != authRecord.Id { return apis.NewForbiddenError("只有群主可以修改群组", nil) } return nil }) app.OnRecordBeforeDeleteRequest("groups").Add(func(e *core.RecordEvent) error { if e.HttpContext.IsAdmin() { return nil } authRecord, _ := e.HttpContext.AuthRecord() if authRecord == nil { return apis.NewForbiddenError("需要登录", nil) } if e.Record.GetString("owner") != authRecord.Id { return apis.NewForbiddenError("只有群主可以解散群组", nil) } return nil }) // Team Sessions API Rules app.OnRecordBeforeCreateRequest("teamSessions").Add(func(e *core.RecordEvent) error { authRecord, _ := e.HttpContext.AuthRecord() if authRecord == nil { return apis.NewForbiddenError("需要登录", nil) } // Check user is member of source group groupId := e.Record.GetString("sourceGroup") if !isGroupMember(app, groupId, authRecord.Id) { return apis.NewForbiddenError("你不是该群组成员", nil) } // Set initial status e.Record.Set("status", "recruiting") return nil }) // Invitations API Rules app.OnRecordBeforeCreateRequest("invitations").Add(func(e *core.RecordEvent) error { authRecord, _ := e.HttpContext.AuthRecord() if authRecord == nil { return apis.NewForbiddenError("需要登录", nil) } // Set from to current user e.Record.Set("from", authRecord.Id) // Check team session exists and user is member teamSessionId := e.Record.GetString("teamSession") teamSession, err := app.Dao().FindRecordById("teamSessions", teamSessionId) if err != nil { return apis.NewNotFoundError("临时小组不存在", nil) } sourceGroup := teamSession.GetString("sourceGroup") if !isGroupMember(app, sourceGroup, authRecord.Id) { return apis.NewForbiddenError("你不是该群组成员", nil) } // Set initial status e.Record.Set("status", "pending") return nil }) app.OnRecordAfterCreateRequest("invitations").Add(func(e *core.RecordEvent) error { // Send real-time notification to recipient app.Subscriptions().Broadcast("", map[string]any{ "type": "invitation", "invitation": e.Record, }, []string{e.Record.GetString("to")}) return nil }) if err := app.Start(); err != nil { log.Fatal(err) } } ``` **Step 2: Update go.mod if needed** ```bash cd backend/pb_hooks go mod init gamegroup-hooks go get github.com/pocketbase/pocketbase ``` **Step 3: Commit** ```bash cd backend git add pb_hooks/ git commit -m "feat: add API rules and hooks" ``` --- ## Phase 2: Frontend Setup ### Task 3: Initialize Vue 3 Project **Files:** - Create: `frontend/` - Create: `frontend/package.json` - Create: `frontend/vite.config.ts` - Create: `frontend/tsconfig.json` - Create: `frontend/tailwind.config.js` - Create: `frontend/index.html` - Create: `frontend/.env` **Step 1: Create package.json** ```json { "name": "gamegroup-v2-frontend", "version": "2.0.0", "type": "module", "scripts": { "dev": "vite", "build": "vue-tsc && vite build", "preview": "vite preview" }, "dependencies": { "vue": "^3.4.21", "vue-router": "^4.3.0", "pinia": "^2.1.7", "element-plus": "^2.6.3", "@element-plus/icons-vue": "^2.3.1", "pocketbase": "^0.21.1" }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.4", "typescript": "^5.4.5", "vue-tsc": "^2.0.11", "vite": "^5.2.8", "tailwindcss": "^3.4.3", "autoprefixer": "^10.4.19", "postcss": "^8.4.38" } } ``` **Step 2: Create vite.config.ts** ```typescript import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'path' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, server: { port: 5173, proxy: { '/api': { target: process.env.VITE_PB_URL || 'http://localhost:8090', changeOrigin: true } } } }) ``` **Step 3: Create tsconfig.json** ```json { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "preserve", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], "references": [{ "path": "./tsconfig.node.json" }] } ``` **Step 4: Create tsconfig.node.json** ```json { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ``` **Step 5: Create tailwind.config.js** ```javascript /** @type {import('tailwindcss').Config} */ export default { content: [ "./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}", ], theme: { extend: { colors: { primary: { 50: '#f0f9ff', 100: '#e0f2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e', } } }, }, plugins: [], } ``` **Step 6: Create postcss.config.js** ```javascript export default { plugins: { tailwindcss: {}, autoprefixer: {}, }, } ``` **Step 7: Create index.html** ```html Game Group V2
``` **Step 8: Create .env** ```env VITE_PB_URL=http://localhost:8090 ``` **Step 9: Create src directory structure** ```bash mkdir -p frontend/src/{api,components/{common,layout,team},views,stores,types,router,utils,assets} ``` **Step 10: Commit** ```bash cd frontend git init git add . git commit -m "feat: initialize Vue 3 frontend with Vite, Tailwind, Element Plus" ``` --- ### Task 4: Create PocketBase Client **Files:** - Create: `frontend/src/api/pocketbase.ts` - Create: `frontend/src/types/index.ts` **Step 1: Create PocketBase client** ```typescript // src/api/pocketbase.ts import PocketBase from 'pocketbase' const pbUrl = import.meta.env.VITE_PB_URL || 'http://localhost:8090' export const pb = new PocketBase(pbUrl) // 认证状态持久化 pb.authStore.loadFromCookie(document.cookie) // 保存认证状态到 cookie pb.authStore.onChange(() => { document.cookie = pb.authStore.exportToCookie({ httpOnly: false }) }) // 获取当前用户 export function getCurrentUser() { return pb.authStore.model } // 检查是否已登录 export function isAuthenticated(): boolean { return pb.authStore.isValid } // 登出 export function logout() { pb.authStore.clear() window.location.href = '/login' } export default pb ``` **Step 2: Create types** ```typescript // src/types/index.ts // 用户状态 export type UserStatus = 'idle' | 'working' | 'in_team' | 'away' // 用户状态中文映射 export const UserStatusMap: Record = { idle: '空闲', working: '工作中', in_team: '组队中', away: '离开' } // 用户状态图标 export const UserStatusIcon: Record = { idle: '🟢', working: '🔴', in_team: '🔵', away: '⚫' } // 临时小组状态 export type TeamStatus = 'recruiting' | 'playing' | 'finished' | 'dissolved' export const TeamStatusMap: Record = { recruiting: '招募中', playing: '游戏中', finished: '已结束', dissolved: '已解散' } // 邀请状态 export type InviteStatus = 'pending' | 'accepted' | 'rejected' export const InviteStatusMap: Record = { pending: '等待响应', accepted: '已接受', rejected: '已拒绝' } // 游戏平台 export type GamePlatform = 'PC' | 'PS5' | 'Xbox' | 'Switch' | 'Mobile' // 用户 export interface User { id: string username: string email: string avatar?: string status: UserStatus statusNote?: string maxGroups: number workdays: number[] workStartTime: string nextWorkTime?: number points: number created: string updated: string } // 群组 export interface Group { id: string name: string description?: string owner: string members: string[] maxMembers: number created: string updated: string expand?: { owner?: User members?: User[] } } // 临时小组 export interface TeamSession { id: string name: string sourceGroup: string gameName: string members: string[] status: TeamStatus created: string updated: string expand?: { members?: User[] sourceGroup?: Group } } // 邀请 export interface Invitation { id: string from: string to: string teamSession: string status: InviteStatus rejectReason?: string created: string updated: string expand?: { from?: User to?: User teamSession?: TeamSession } } // 游戏 export interface Game { id: string name: string platform?: GamePlatform tags?: string[] cover?: string popularCount: number created: string updated: string } // 工作时间设定 export interface WorkSchedule { workdays: number[] workStartTime: string } ``` **Step 3: Commit** ```bash git add src/api/pocketbase.ts src/types/index.ts git commit -m "feat: add PocketBase client and TypeScript types" ``` --- ### Task 5: Create API Services **Files:** - Create: `frontend/src/api/users.ts` - Create: `frontend/src/api/groups.ts` - Create: `frontend/src/api/sessions.ts` - Create: `frontend/src/api/invitations.ts` - Create: `frontend/src/api/games.ts` **Step 1: Create users API** ```typescript // src/api/users.ts import { pb } from './pocketbase' import type { User, UserStatus, WorkSchedule } from '@/types' // 登录 export async function login(email: string, password: string) { return await pb.collection('users').authWithPassword(email, password) } // 注册 export async function register(email: string, password: string, passwordConfirm: string, username: string) { return await pb.collection('users').create({ email, password, passwordConfirm, username, status: 'idle', maxGroups: 5, workdays: [1, 2, 3, 4, 5], workStartTime: '09:00', points: 0 }) } // 获取当前用户 export async function getCurrentUserFull(): Promise { const userId = pb.authStore.model?.id if (!userId) throw new Error('未登录') return await pb.collection('users').getOne(userId) } // 更新用户状态 export async function updateUserStatus(status: UserStatus, statusNote?: string) { const userId = pb.authStore.model?.id if (!userId) throw new Error('未登录') return await pb.collection('users').update(userId, { status, statusNote }) } // 更新工作时间设定 export async function updateWorkSchedule(schedule: WorkSchedule) { const userId = pb.authStore.model?.id if (!userId) throw new Error('未登录') // 计算下一个工作时间 const nextWorkTime = calculateNextWorkTime(schedule.workdays, schedule.workStartTime) return await pb.collection('users').update(userId, { workdays: schedule.workdays, workStartTime: schedule.workStartTime, nextWorkTime }) } // 计算下一个工作时间戳 function calculateNextWorkTime(workdays: number[], startTime: string): number { const now = new Date() const [hours, minutes] = startTime.split(':').map(Number) // 找到下一个工作日 let nextDate = new Date(now) let attempts = 0 while (attempts < 7) { const dayOfWeek = nextDate.getDay() || 7 // 转换为 1-7 (周一到周日) if (workdays.includes(dayOfWeek)) { const workTime = new Date(nextDate) workTime.setHours(hours, minutes, 0, 0) // 如果今天的工作时间还没过,就是今天 if (workTime > now) { return Math.floor(workTime.getTime() / 1000) } } // 加一天 nextDate.setDate(nextDate.getDate() + 1) nextDate.setHours(0, 0, 0, 0) attempts++ } return 0 } // 获取用户详情 export async function getUserById(id: string): Promise { return await pb.collection('users').getOne(id) } // 搜索用户 export async function searchUsers(query: string) { return await pb.collection('users').getList(1, 20, { filter: `username ~ "${query}"`, fields: 'id,username,avatar,status' }) } // 订阅用户状态变更 export function subscribeUserStatusChanges(callback: (data: any) => void) { return pb.collection('users').subscribe('*', callback) } ``` **Step 2: Create groups API** ```typescript // src/api/groups.ts import { pb } from './pocketbase' import type { Group } from '@/types' // 获取我的群组 export async function getMyGroups() { const userId = pb.authStore.model?.id if (!userId) throw new Error('未登录') return await pb.collection('groups').getList(1, 50, { filter: `owner = "${userId}" || members ~ "${userId}"` }) } // 创建群组 export async function createGroup(name: string, description?: string, maxMembers = 50) { const userId = pb.authStore.model?.id if (!userId) throw new Error('未登录') return await pb.collection('groups').create({ name, description, maxMembers }) } // 获取群组详情 export async function getGroupById(id: string): Promise { return await pb.collection('groups').getOne(id, { expand: 'owner,members' }) } // 更新群组 export async function updateGroup(id: string, data: Partial) { return await pb.collection('groups').update(id, data) } // 解散群组 export async function deleteGroup(id: string) { return await pb.collection('groups').delete(id) } // 加入群组 (需要群组代码或其他机制,这里简化) export async function joinGroup(groupId: string) { const group = await pb.collection('groups').getOne(groupId) const userId = pb.authStore.model?.id if (!userId) throw new Error('未登录') const members = group.members || [] if (members.includes(userId)) { throw new Error('你已经是该群组成员') } if (members.length >= group.maxMembers) { throw new Error('群组已满') } return await pb.collection('groups').update(groupId, { members: [...members, userId] }) } // 退出群组 export async function leaveGroup(groupId: string) { const group = await pb.collection('groups').getOne(groupId) const userId = pb.authStore.model?.id if (!userId) throw new Error('未登录') if (group.owner === userId) { throw new Error('群主不能退出群组,请先转让群主或解散群组') } const members = (group.members || []).filter((m: string) => m !== userId) return await pb.collection('groups').update(groupId, { members }) } // 获取群组内空闲成员 export async function getIdleGroupMembers(groupId: string) { const group = await pb.collection('groups').getOne(groupId, { expand: 'members' }) const members = group.expand?.members || [] return members.filter((m: any) => m.status === 'idle') } ``` **Step 3: Create sessions API** ```typescript // src/api/sessions.ts import { pb } from './pocketbase' import type { TeamSession, TeamStatus } from '@/types' // 创建临时小组 export async function createTeamSession(data: { name: string sourceGroup: string gameName: string }) { return await pb.collection('teamSessions').create({ ...data, status: 'recruiting', members: [pb.authStore.model?.id] }) } // 获取群组的活跃临时小组 export async function getActiveTeamSessions(groupId: string) { return await pb.collection('teamSessions').getList(1, 10, { filter: `sourceGroup = "${groupId}" && status != "dissolved"`, sort: '-created' }) } // 获取我的临时小组 export async function getMyTeamSessions() { const userId = pb.authStore.model?.id if (!userId) throw new Error('未登录') return await pb.collection('teamSessions').getList(1, 10, { filter: `members ~ "${userId}" && status = "playing"`, expand: 'members,sourceGroup' }) } // 更新临时小组状态 export async function updateTeamStatus(id: string, status: TeamStatus) { const updateData: any = { status } if (status === 'dissolved') { updateData.dissolvedAt = new Date().toISOString() } return await pb.collection('teamSessions').update(id, updateData) } // 添加成员到临时小组 export async function addMemberToTeam(teamId: string, userId: string) { const team = await pb.collection('teamSessions').getOne(teamId) const members = team.members || [] if (!members.includes(userId)) { members.push(userId) return await pb.collection('teamSessions').update(teamId, { members }) } return team } // 解散临时小组 export async function dissolveTeamSession(id: string) { return await updateTeamStatus(id, 'dissolved') } // 订阅临时小组变更 export function subscribeTeamChanges(callback: (data: any) => void) { return pb.collection('teamSessions').subscribe('*', callback) } ``` **Step 4: Create invitations API** ```typescript // src/api/invitations.ts import { pb } from './pocketbase' import type { Invitation, InviteStatus } from '@/types' // 发送邀请 export async function sendInvitation(toUserId: string, teamSessionId: string) { return await pb.collection('invitations').create({ to: toUserId, teamSession: teamSessionId }) } // 获取我的待处理邀请 export async function getMyPendingInvitations() { const userId = pb.authStore.model?.id if (!userId) return { items: [], totalItems: 0 } return await pb.collection('invitations').getList(1, 20, { filter: `to = "${userId}" && status = "pending"`, expand: 'from,teamSession,teamSession.sourceGroup' }) } // 获取我发送的邀请 export async function getMySentInvitations() { const userId = pb.authStore.model?.id if (!userId) return { items: [], totalItems: 0 } return await pb.collection('invitations').getList(1, 20, { filter: `from = "${userId}"`, expand: 'to,teamSession' }) } // 响应邀请 export async function respondToInvitation( invitationId: string, status: InviteStatus, rejectReason?: string ) { const updateData: any = { status } if (rejectReason) { updateData.rejectReason = rejectReason } return await pb.collection('invitations').update(invitationId, updateData) } // 接受邀请 export async function acceptInvitation(invitationId: string) { const invitation = await pb.collection('invitations').getOne(invitationId, { expand: 'teamSession' }) // 更新邀请状态 await respondToInvitation(invitationId, 'accepted') // 添加到临时小组 const teamSession = invitation.expand?.teamSession if (teamSession) { const { addMemberToTeam } = await import('./sessions') const { updateUserStatus } = await import('./users') // 添加到小组 await addMemberToTeam(teamSession.id, pb.authStore.model?.id) // 更新用户状态 await updateUserStatus('in_team') // 如果所有人都已响应,更新小组状态 // (需要额外逻辑检查) } return invitation } // 拒绝邀请 export async function rejectInvitation(invitationId: string, reason?: string) { return await respondToInvitation(invitationId, 'rejected', reason) } // 订阅邀请变更 export function subscribeInvitations(callback: (data: any) => void) { return pb.collection('invitations').subscribe('*', callback) } ``` **Step 5: Create games API** ```typescript // src/api/games.ts import { pb } from './pocketbase' import type { Game, GamePlatform } from '@/types' // 获取游戏列表 export async function getGames(page = 1, limit = 20) { return await pb.collection('games').getList(page, limit, { sort: '-popularCount' }) } // 搜索游戏 export async function searchGames(query: string) { return await pb.collection('games').getList(1, 20, { filter: `name ~ "${query}"`, sort: '-popularCount' }) } // 按平台筛选游戏 export async function getGamesByPlatform(platform: GamePlatform) { return await pb.collection('games').getList(1, 50, { filter: `platform = "${platform}"`, sort: '-popularCount' }) } // 获取热门游戏 export async function getPopularGames(limit = 10) { return await pb.collection('games').getList(1, limit, { sort: '-popularCount' }) } // 按标签筛选游戏 export async function getGamesByTag(tag: string) { return await pb.collection('games').getList(1, 50, { filter: `tags ~ "${tag}"`, sort: '-popularCount' }) } // 获取游戏详情 export async function getGameById(id: string): Promise { return await pb.collection('games').getOne(id) } // 创建游戏 (管理员功能) export async function createGame(game: Partial) { return await pb.collection('games').create(game) } // 增加游戏热度 export async function incrementGamePopularity(gameId: string) { const game = await pb.collection('games').getOne(gameId) return await pb.collection('games').update(gameId, { popularCount: (game.popularCount || 0) + 1 }) } ``` **Step 6: Commit** ```bash git add src/api/ git commit -m "feat: add all API services (users, groups, sessions, invitations, games)" ``` --- ### Task 6: Create Pinia Stores **Files:** - Create: `frontend/src/stores/user.ts` - Create: `frontend/src/stores/group.ts` - Create: `frontend/src/stores/team.ts` - Create: `frontend/src/stores/notification.ts` **Step 1: Create user store** ```typescript // src/stores/user.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { User, UserStatus, WorkSchedule } from '@/types' import { getCurrentUser, updateUserStatus, updateWorkSchedule, logout as apiLogout } from '@/api/users' import { pb } from '@/api/pocketbase' export const useUserStore = defineStore('user', () => { const user = ref(null) const loading = ref(false) const isAuthenticated = computed(() => pb.authStore.isValid) const userId = computed(() => pb.authStore.model?.id) const status = computed(() => user.value?.status || 'idle') // 初始化用户信息 async function init() { if (isAuthenticated.value) { loading.value = true try { user.value = await getCurrentUser() } catch (error) { console.error('获取用户信息失败:', error) } finally { loading.value = false } } } // 设置状态 async function setStatus(newStatus: UserStatus, note?: string) { loading.value = true try { await updateUserStatus(newStatus, note) if (user.value) { user.value.status = newStatus user.value.statusNote = note } } catch (error) { console.error('更新状态失败:', error) throw error } finally { loading.value = false } } // 设置工作时间 async function setWorkSchedule(schedule: WorkSchedule) { loading.value = true try { await updateWorkSchedule(schedule) if (user.value) { user.value.workdays = schedule.workdays user.value.workStartTime = schedule.workStartTime } } catch (error) { console.error('更新工作时间失败:', error) throw error } finally { loading.value = false } } // 登出 function logout() { user.value = null apiLogout() } return { user, loading, isAuthenticated, userId, status, init, setStatus, setWorkSchedule, logout } }) ``` **Step 2: Create group store** ```typescript // src/stores/group.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { Group } from '@/types' import { getMyGroups, getGroupById } from '@/api/groups' export const useGroupStore = defineStore('group', () => { const myGroups = ref([]) const currentGroup = ref(null) const loading = ref(false) const currentGroupId = computed(() => currentGroup.value?.id) // 加载我的群组 async function loadMyGroups() { loading.value = true try { const result = await getMyGroups() myGroups.value = result.items as Group[] } catch (error) { console.error('加载群组失败:', error) } finally { loading.value = false } } // 设置当前群组 async function setCurrentGroup(groupId: string) { loading.value = true try { currentGroup.value = await getGroupById(groupId) } catch (error) { console.error('加载群组详情失败:', error) } finally { loading.value = false } } // 清除当前群组 function clearCurrentGroup() { currentGroup.value = null } return { myGroups, currentGroup, currentGroupId, loading, loadMyGroups, setCurrentGroup, clearCurrentGroup } }) ``` **Step 3: Create team store** ```typescript // src/stores/team.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { TeamSession } from '@/types' import { getMyTeamSessions, dissolveTeamSession } from '@/api/sessions' export const useTeamStore = defineStore('team', () => { const myActiveTeam = ref(null) const loading = ref(false) const hasActiveTeam = computed(() => !!myActiveTeam.value) const teamStatus = computed(() => myActiveTeam.value?.status) const teamMembers = computed(() => myActiveTeam.value?.members || []) // 加载我的活跃临时小组 async function loadMyActiveTeam() { loading.value = true try { const result = await getMyTeamSessions() myActiveTeam.value = result.items[0] as TeamSession || null } catch (error) { console.error('加载临时小组失败:', error) } finally { loading.value = false } } // 设置当前临时小组 function setActiveTeam(team: TeamSession) { myActiveTeam.value = team } // 清除当前临时小组 function clearActiveTeam() { myActiveTeam.value = null } // 解散临时小组 async function dissolveTeam() { if (!myActiveTeam.value) return loading.value = true try { await dissolveTeamSession(myActiveTeam.value.id) clearActiveTeam() } catch (error) { console.error('解散临时小组失败:', error) throw error } finally { loading.value = false } } return { myActiveTeam, hasActiveTeam, teamStatus, teamMembers, loading, loadMyActiveTeam, setActiveTeam, clearActiveTeam, dissolveTeam } }) ``` **Step 4: Create notification store** ```typescript // src/stores/notification.ts import { defineStore } from 'pinia' import { ref } from 'vue' import type { Invitation } from '@/types' import { subscribeInvitations } from '@/api/invitations' import { subscribeUserStatusChanges } from '@/api/users' export interface Notification { id: string type: 'invitation' | 'status' | 'system' title: string message: string data?: any read: boolean createdAt: Date } export const useNotificationStore = defineStore('notification', () => { const notifications = ref([]) const pendingInvitations = ref([]) // 添加通知 function addNotification(notification: Omit) { const id = Date.now().toString() notifications.value.unshift({ ...notification, id, read: false, createdAt: new Date() }) } // 标记通知已读 function markAsRead(id: string) { const notification = notifications.value.find(n => n.id === id) if (notification) { notification.read = true } } // 清除所有通知 function clearAll() { notifications.value = [] pendingInvitations.value = [] } // 初始化实时订阅 function initSubscriptions() { // 订阅邀请 subscribeInvitations((data) => { if (data.action === 'create' && data.record?.to === localStorage.getItem('userId')) { pendingInvitations.value.push(data.record as Invitation) addNotification({ type: 'invitation', title: '新邀请', message: `${data.record.expand?.from?.username} 邀请你加入游戏`, data: data.record }) } }) // 订阅用户状态变更 subscribeUserStatusChanges((data) => { if (data.action === 'update') { addNotification({ type: 'status', title: '状态更新', message: `${data.record.username} 状态变更为 ${data.record.status}`, data: data.record }) } }) } return { notifications, pendingInvitations, addNotification, markAsRead, clearAll, initSubscriptions } }) ``` **Step 5: Commit** ```bash git add src/stores/ git commit -m "feat: add Pinia stores (user, group, team, notification)" ``` --- ### Task 7: Create Router **Files:** - Create: `frontend/src/router/index.ts` **Step 1: Create router** ```typescript // src/router/index.ts import { createRouter, createWebHistory } from 'vue-router' import type { RouteRecordRaw } from 'vue-router' import { isAuthenticated } from '@/api/pocketbase' const routes: RouteRecordRaw[] = [ { path: '/login', name: 'Login', component: () => import('@/views/Login.vue'), meta: { requiresAuth: false } }, { path: '/register', name: 'Register', component: () => import('@/views/Register.vue'), meta: { requiresAuth: false } }, { path: '/', component: () => import('@/components/layout/MainLayout.vue'), meta: { requiresAuth: true }, children: [ { path: '', name: 'Home', component: () => import('@/views/GroupView.vue') }, { path: 'groups/:id', name: 'GroupDetail', component: () => import('@/views/GroupView.vue') }, { path: 'games', name: 'GamesLibrary', component: () => import('@/views/GamesLibrary.vue') } ] } ] const router = createRouter({ history: createWebHistory(), routes }) // 路由守卫 router.beforeEach((to, from, next) => { const requiresAuth = to.meta.requiresAuth !== false if (requiresAuth && !isAuthenticated()) { next({ name: 'Login', query: { redirect: to.fullPath } }) } else if (!requiresAuth && isAuthenticated() && (to.name === 'Login' || to.name === 'Register')) { next({ name: 'Home' }) } else { next() } }) export default router ``` **Step 2: Commit** ```bash git add src/router/ git commit -m "feat: add Vue Router with auth guard" ``` --- ### Task 8: Create Layout Components **Files:** - Create: `frontend/src/components/layout/MainLayout.vue` - Create: `frontend/src/components/layout/AppSidebar.vue` - Create: `frontend/src/components/layout/AppHeader.vue` **Step 1: Create MainLayout** ```vue ``` **Step 2: Create AppSidebar** ```vue ``` **Step 3: Create AppHeader** ```vue ``` **Step 4: Commit** ```bash git add src/components/layout/ git commit -m "feat: add layout components (MainLayout, AppSidebar, AppHeader)" ``` --- ### Task 9: Create Team Components **Files:** - Create: `frontend/src/components/team/StatusToggle.vue` - Create: `frontend/src/components/team/IdleMembersList.vue` - Create: `frontend/src/components/team/InviteButton.vue` - Create: `frontend/src/components/team/InvitationCard.vue` - Create: `frontend/src/components/team/TeamSessionPanel.vue` - Create: `frontend/src/components/team/WorkScheduleModal.vue` **Step 1: Create StatusToggle** ```vue ``` **Step 2: Create IdleMembersList** ```vue ``` **Step 3: Create InviteButton** ```vue ``` **Step 4: Create InvitationCard** ```vue ``` **Step 5: Create TeamSessionPanel** ```vue ``` **Step 6: Create WorkScheduleModal** ```vue ``` **Step 7: Commit** ```bash git add src/components/team/ git commit -m "feat: add team components (StatusToggle, IdleMembersList, InviteButton, etc.)" ``` --- ### Task 10: Create Page Components **Files:** - Create: `frontend/src/views/Login.vue` - Create: `frontend/src/views/Register.vue` - Create: `frontend/src/views/GroupView.vue` - Create: `frontend/src/views/GamesLibrary.vue` - Create: `frontend/src/components/common/NotificationPanel.vue` - Create: `frontend/src/components/team/GameSelectDialog.vue` **Step 1: Create Login page** ```vue ``` **Step 2: Create Register page** ```vue ``` **Step 3: Create GroupView page** ```vue ``` **Step 4: Create GamesLibrary page** ```vue ``` **Step 5: Create NotificationPanel component** ```vue ``` **Step 6: Create GameSelectDialog component** ```vue ``` **Step 7: Update main.ts** ```typescript // src/main.ts import { createApp } from 'vue' import { createPinia } from 'pinia' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import './assets/main.css' import App from './App.vue' import router from './router' const app = createApp(App) const pinia = createPinia() app.use(pinia) app.use(router) app.use(ElementPlus) app.mount('#app') ``` **Step 8: Create App.vue** ```vue ``` **Step 9: Create main.css** ```css /* src/assets/main.css */ @tailwind base; @tailwind components; @tailwind utilities; /* Custom styles */ :root { font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; } ``` **Step 10: Commit** ```bash git add src/views/ src/components/common/ src/components/team/GameSelectDialog.vue src/main.ts src/App.vue src/assets/ git commit -m "feat: add page components (Login, Register, GroupView, GamesLibrary)" ``` --- ### Task 11: Create Entry Point **Files:** - Update: `frontend/index.html` **Step 1: Update index.html** ```html Game Group V2
``` **Step 2: Commit** ```bash git add index.html git commit -m "feat: update entry point" ``` --- ## Phase 3: Testing ### Task 12: Test User Flow **Step 1: Start PocketBase** ```bash cd backend ./pocketbase serve ``` **Step 2: Start Frontend** ```bash cd frontend npm install npm run dev ``` **Step 3: Test Flow** 1. 访问 http://localhost:5173 2. 注册新用户 3. 登录 4. 创建群组 5. 切换状态为"空闲" 6. 注册另一个用户,登录 7. 加入同一个群组 8. 第一个用户邀请第二个用户 9. 第二个用户接受邀请 10. 查看临时小组创建成功 11. 点击"游戏结束"解散小组 --- ## Deployment ### Task 13: Deploy to NAS **Step 1: Build frontend** ```bash cd frontend npm run build ``` **Step 2: Copy to NAS** ```bash # 复制前端构建文件到 NAS web 目录 scp -r frontend/dist/* user@nas:/path/to/web/ # 复制 PocketBase 到 NAS scp -r backend/* user@nas:/path/to/pocketbase/ ``` **Step 3: Update .env on NAS** ```env PB_PORT=8090 ``` **Step 4: Start service** ```bash ssh user@nas cd /path/to/pocketbase docker-compose up -d ``` --- ## Summary This implementation plan covers: **Phase 1: Backend Setup** - PocketBase initialization - Database migrations - API rules and hooks **Phase 2: Frontend Setup** - Vue 3 + Vite project - API services - Pinia stores - Router with auth guard **Phase 3: Components** - Layout components - Team components - Page components **Phase 4: Testing & Deployment** Total tasks: 13 --- **Ready to implement! Use superpowers:executing-plans or superpowers:subagent-driven-development to execute.**