# 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
{{ UserStatusIcon[currentStatus] }}
{{ UserStatusMap[currentStatus] }}
🟢 空闲
🔴 工作中
⚫ 离开
```
**Step 2: Create IdleMembersList**
```vue
空闲成员 ({{ idleMembers.length }})
暂无空闲成员
{{ member.username }}
{{ UserStatusIcon[member.status] }} {{ UserStatusMap[member.status] }}
```
**Step 3: Create InviteButton**
```vue
邀请
```
**Step 4: Create InvitationCard**
```vue
{{ invitation.expand?.from?.username }} 邀请你加入
游戏: {{ invitation.expand?.teamSession?.gameName }}
接受
拒绝
```
**Step 5: Create TeamSessionPanel**
```vue
{{ teamStore.myActiveTeam?.name }}
{{ TeamStatusMap[teamStore.teamStatus] }}
游戏:
{{ teamStore.myActiveTeam?.gameName }}
成员:
{{ member.expand?.username || member.id }}
游戏结束
暂无活跃临时小组
```
**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
登录 Game Group
登录
还没有账号?
立即注册
```
**Step 2: Create Register page**
```vue
注册 Game Group
注册
已有账号?
立即登录
```
**Step 3: Create GroupView page**
```vue
```
**Step 4: Create GamesLibrary page**
```vue
游戏库
{{ platform.label }}
{{ game.name }}
{{ game.platform }}
{{ game.popularCount || 0 }}
{{ game.tags?.[0] || '' }}
```
**Step 5: Create NotificationPanel component**
```vue
通知 ({{ unreadCount }})
全部已读
{{ notification.title }}
{{ notification.message }}
{{ formatTime(notification.createdAt) }}
查看
暂无通知
```
**Step 6: Create GameSelectDialog component**
```vue
{{ game.name }}
{{ game.platform }}
未找到游戏
```
**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.**