Files
gamegroup2/docs/plans/2026-04-17-mvp-implementation.md
congsh 4d1a53fc69 feat: initialize PocketBase backend with migrations
- Created backend directory structure
- Added .env configuration for PocketBase
- Added initial migration with users, groups, games, teamSessions, invitations collections
- Added docker-compose.yml for containerized deployment
- Added README.md with setup instructions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:14:05 +08:00

81 KiB
Raw Permalink Blame History

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

# 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:

/// <ref>https://github.com/pocketbase/pb_migrations</ref>
/// <msg>Created: users, groups, games, teamSessions, invitations collections</msg>

// 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

# 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

# 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:

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

cd backend/pb_hooks
go mod init gamegroup-hooks
go get github.com/pocketbase/pocketbase

Step 3: Commit

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

{
  "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

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

{
  "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

{
  "compilerOptions": {
    "composite": true,
    "skipLibCheck": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true
  },
  "include": ["vite.config.ts"]
}

Step 5: Create tailwind.config.js

/** @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

export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}

Step 7: Create index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Game Group V2</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.ts"></script>
</body>
</html>

Step 8: Create .env

VITE_PB_URL=http://localhost:8090

Step 9: Create src directory structure

mkdir -p frontend/src/{api,components/{common,layout,team},views,stores,types,router,utils,assets}

Step 10: Commit

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

// 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

// src/types/index.ts

// 用户状态
export type UserStatus = 'idle' | 'working' | 'in_team' | 'away'

// 用户状态中文映射
export const UserStatusMap: Record<UserStatus, string> = {
  idle: '空闲',
  working: '工作中',
  in_team: '组队中',
  away: '离开'
}

// 用户状态图标
export const UserStatusIcon: Record<UserStatus, string> = {
  idle: '🟢',
  working: '🔴',
  in_team: '🔵',
  away: '⚫'
}

// 临时小组状态
export type TeamStatus = 'recruiting' | 'playing' | 'finished' | 'dissolved'

export const TeamStatusMap: Record<TeamStatus, string> = {
  recruiting: '招募中',
  playing: '游戏中',
  finished: '已结束',
  dissolved: '已解散'
}

// 邀请状态
export type InviteStatus = 'pending' | 'accepted' | 'rejected'

export const InviteStatusMap: Record<InviteStatus, string> = {
  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

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

// 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<User> {
  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<User> {
  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

// 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<Group> {
  return await pb.collection('groups').getOne(id, {
    expand: 'owner,members'
  })
}

// 更新群组
export async function updateGroup(id: string, data: Partial<Group>) {
  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

// 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

// 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

// 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<Game> {
  return await pb.collection('games').getOne(id)
}

// 创建游戏 (管理员功能)
export async function createGame(game: Partial<Game>) {
  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

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

// 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<User | null>(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

// 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<Group[]>([])
  const currentGroup = ref<Group | null>(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

// 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<TeamSession | null>(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

// 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<Notification[]>([])
  const pendingInvitations = ref<Invitation[]>([])

  // 添加通知
  function addNotification(notification: Omit<Notification, 'id' | 'read' | 'createdAt'>) {
    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

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

// 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

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

<!-- src/components/layout/MainLayout.vue -->
<template>
  <div class="min-h-screen bg-gray-50 flex">
    <AppSidebar />

    <div class="flex-1 flex flex-col">
      <AppHeader />

      <main class="flex-1 p-6 overflow-auto">
        <router-view />
      </main>
    </div>

    <!-- 通知中心 -->
    <NotificationPanel />
  </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue'
import AppSidebar from './AppSidebar.vue'
import AppHeader from './AppHeader.vue'
import NotificationPanel from '../common/NotificationPanel.vue'
import { useUserStore } from '@/stores/user'
import { useNotificationStore } from '@/stores/notification'

const userStore = useUserStore()
const notificationStore = useNotificationStore()

onMounted(async () => {
  await userStore.init()
  notificationStore.initSubscriptions()
})
</script>

Step 2: Create AppSidebar

<!-- src/components/layout/AppSidebar.vue -->
<template>
  <aside class="w-64 bg-white border-r border-gray-200 flex flex-col">
    <!-- Logo -->
    <div class="p-4 border-b border-gray-200">
      <h1 class="text-xl font-bold text-primary-600">Game Group V2</h1>
    </div>

    <!-- 群组列表 -->
    <div class="flex-1 overflow-auto p-4">
      <h2 class="text-sm font-medium text-gray-500 mb-2">我的群组</h2>
      <nav class="space-y-1">
        <router-link
          v-for="group in groupStore.myGroups"
          :key="group.id"
          :to="`/groups/${group.id}`"
          class="flex items-center px-3 py-2 rounded-lg transition-colors"
          :class="group.id === groupStore.currentGroupId ? 'bg-primary-50 text-primary-600' : 'hover:bg-gray-100'"
          @click="groupStore.setCurrentGroup(group.id)"
        >
          <span class="truncate">{{ group.name }}</span>
        </router-link>
      </nav>
    </div>

    <!-- 用户状态 -->
    <div class="p-4 border-t border-gray-200">
      <div class="flex items-center justify-between mb-2">
        <span class="text-sm text-gray-600">{{ userStore.user?.username }}</span>
        <span class="text-2xl">{{ UserStatusIcon[userStore.status] }}</span>
      </div>
      <div class="flex gap-2">
        <button
          @click="setIdle"
          class="flex-1 px-2 py-1 text-xs rounded"
          :class="userStore.status === 'idle' ? 'bg-green-100 text-green-700' : 'bg-gray-100'"
        >
          空闲
        </button>
        <button
          @click="setWorking"
          class="flex-1 px-2 py-1 text-xs rounded"
          :class="userStore.status === 'working' ? 'bg-red-100 text-red-700' : 'bg-gray-100'"
        >
          工作
        </button>
        <button
          @click="showWorkSchedule = true"
          class="px-2 py-1 text-xs bg-gray-100 rounded"
        >
          
        </button>
      </div>
    </div>
  </aside>

  <WorkScheduleModal v-model:show="showWorkSchedule" />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { UserStatusIcon, type UserStatus } from '@/types'
import WorkScheduleModal from '../team/WorkScheduleModal.vue'

const userStore = useUserStore()
const groupStore = useGroupStore()
const showWorkSchedule = ref(false)

async function setIdle() {
  await userStore.setStatus('idle')
}

async function setWorking() {
  await userStore.setStatus('working')
}
</script>

Step 3: Create AppHeader

<!-- src/components/layout/AppHeader.vue -->
<template>
  <header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6">
    <div class="flex items-center gap-4">
      <h2 class="text-lg font-medium">
        {{ groupStore.currentGroup?.name || '选择群组' }}
      </h2>
    </div>

    <div class="flex items-center gap-4">
      <!-- 通知按钮 -->
      <el-badge :value="notificationStore.pendingInvitations.length" :hidden="notificationStore.pendingInvitations.length === 0">
        <el-button :icon="Bell" circle />
      </el-badge>

      <!-- 用户菜单 -->
      <el-dropdown>
        <div class="flex items-center gap-2 cursor-pointer">
          <el-avatar :src="userStore.user?.avatar" />
          <span>{{ userStore.user?.username }}</span>
        </div>
        <template #dropdown>
          <el-dropdown-menu>
            <el-dropdown-item @click="goToGames">游戏库</el-dropdown-item>
            <el-dropdown-item divided @click="userStore.logout()">退出登录</el-dropdown-item>
          </el-dropdown-menu>
        </template>
      </el-dropdown>
    </div>
  </header>
</template>

<script setup lang="ts">
import { Bell } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { useNotificationStore } from '@/stores/notification'

const router = useRouter()
const userStore = useUserStore()
const groupStore = useGroupStore()
const notificationStore = useNotificationStore()

function goToGames() {
  router.push('/games')
}
</script>

Step 4: Commit

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

<!-- src/components/team/StatusToggle.vue -->
<template>
  <div class="flex items-center gap-2">
    <el-dropdown trigger="click" @command="handleStatusChange">
      <el-button :type="buttonType">
        <span class="mr-1">{{ UserStatusIcon[currentStatus] }}</span>
        {{ UserStatusMap[currentStatus] }}
      </el-button>
      <template #dropdown>
        <el-dropdown-menu>
          <el-dropdown-item command="idle">
            <span class="mr-2">🟢</span> 空闲
          </el-dropdown-item>
          <el-dropdown-item command="working">
            <span class="mr-2">🔴</span> 工作中
          </el-dropdown-item>
          <el-dropdown-item command="away">
            <span class="mr-2"></span> 离开
          </el-dropdown-item>
        </el-dropdown-menu>
      </template>
    </el-dropdown>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { UserStatusMap, UserStatusIcon, type UserStatus } from '@/types'

const userStore = useUserStore()
const currentStatus = computed(() => userStore.status)

const buttonType = computed(() => {
  switch (currentStatus.value) {
    case 'idle': return 'success'
    case 'working': return 'danger'
    case 'in_team': return 'primary'
    default: return 'info'
  }
})

async function handleStatusChange(status: UserStatus) {
  try {
    await userStore.setStatus(status)
    ElMessage.success(`状态已变更为 ${UserStatusMap[status]}`)
  } catch (error) {
    ElMessage.error('状态更新失败')
  }
}
</script>

Step 2: Create IdleMembersList

<!-- src/components/team/IdleMembersList.vue -->
<template>
  <div class="bg-white rounded-lg shadow p-4">
    <h3 class="text-lg font-medium mb-4">空闲成员 ({{ idleMembers.length }})</h3>

    <div v-if="idleMembers.length === 0" class="text-gray-400 text-center py-8">
      暂无空闲成员
    </div>

    <div v-else class="grid grid-cols-1 md:grid-cols-2 gap-3">
      <div
        v-for="member in idleMembers"
        :key="member.id"
        class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
      >
        <div class="flex items-center gap-3">
          <el-avatar :src="member.avatar" />
          <div>
            <div class="font-medium">{{ member.username }}</div>
            <div class="text-sm text-gray-500">{{ UserStatusIcon[member.status] }} {{ UserStatusMap[member.status] }}</div>
          </div>
        </div>
        <InviteButton :user-id="member.id" />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useGroupStore } from '@/stores/group'
import { UserStatusMap, UserStatusIcon } from '@/types'
import InviteButton from './InviteButton.vue'

const groupStore = useGroupStore()

const idleMembers = computed(() => {
  const members = groupStore.currentGroup?.expand?.members || []
  return members.filter((m: any) => m.status === 'idle')
})
</script>

Step 3: Create InviteButton

<!-- src/components/team/InviteButton.vue -->
<template>
  <el-button
    type="primary"
    size="small"
    :loading="loading"
    @click="handleInvite"
  >
    邀请
  </el-button>

  <GameSelectDialog v-model:show="showGameSelect" @confirm="handleGameSelect" />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { sendInvitation } from '@/api/invitations'
import { createTeamSession } from '@/api/sessions'
import { useTeamStore } from '@/stores/team'
import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user'
import GameSelectDialog from './GameSelectDialog.vue'

const props = defineProps<{
  userId: string
}>()

const teamStore = useTeamStore()
const groupStore = useGroupStore()
const userStore = useUserStore()

const loading = ref(false)
const showGameSelect = ref(false)

async function handleInvite() {
  // 如果已有活跃临时小组,直接邀请
  if (teamStore.hasActiveTeam) {
    await sendInviteToExistingTeam()
  } else {
    // 需要选择游戏创建临时小组
    showGameSelect.value = true
  }
}

async function sendInviteToExistingTeam() {
  loading.value = true
  try {
    await sendInvitation(props.userId, teamStore.myActiveTeam!.id)
    ElMessage.success('邀请已发送')
  } catch (error: any) {
    ElMessage.error(error.message || '邀请发送失败')
  } finally {
    loading.value = false
  }
}

async function handleGameSelect(gameName: string) {
  loading.value = true
  try {
    // 创建临时小组
    const team = await createTeamSession({
      name: `${userStore.user?.username}的车队`,
      sourceGroup: groupStore.currentGroupId!,
      gameName
    })

    // 设置为当前活跃小组
    teamStore.setActiveTeam(team)

    // 发送邀请
    await sendInvitation(props.userId, team.id)
    ElMessage.success('邀请已发送')
  } catch (error: any) {
    ElMessage.error(error.message || '邀请发送失败')
  } finally {
    loading.value = false
  }
}
</script>

Step 4: Create InvitationCard

<!-- src/components/team/InvitationCard.vue -->
<template>
  <el-card class="mb-3">
    <div class="flex items-center justify-between">
      <div class="flex items-center gap-3">
        <el-avatar :src="invitation.expand?.from?.avatar" />
        <div>
          <div class="font-medium">{{ invitation.expand?.from?.username }} 邀请你加入</div>
          <div class="text-sm text-gray-500">游戏: {{ invitation.expand?.teamSession?.gameName }}</div>
        </div>
      </div>
      <div class="flex gap-2">
        <el-button
          type="success"
          size="small"
          :loading="loading"
          @click="handleAccept"
        >
          接受
        </el-button>
        <el-popconfirm
          title="是否拒绝邀请?"
          @confirm="handleReject"
        >
          <template #reference>
            <el-button size="small">拒绝</el-button>
          </template>
        </el-popconfirm>
      </div>
    </div>

    <!-- 拒绝原因输入 -->
    <el-input
      v-if="showReasonInput"
      v-model="rejectReason"
      type="textarea"
      placeholder="请输入拒绝原因(仅对发起人可见)"
      class="mt-3"
      @blur="handleRejectWithReason"
    />
  </el-card>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { Invitation } from '@/types'
import { acceptInvitation, rejectInvitation } from '@/api/invitations'

const props = defineProps<{
  invitation: Invitation
}>()

const loading = ref(false)
const showReasonInput = ref(false)
const rejectReason = ref('')

async function handleAccept() {
  loading.value = true
  try {
    await acceptInvitation(props.invitation.id)
    ElMessage.success('已接受邀请,正在加入临时小组...')
    // 刷新页面或更新状态
  } catch (error) {
    ElMessage.error('接受邀请失败')
  } finally {
    loading.value = false
  }
}

function handleReject() {
  showReasonInput.value = true
}

async function handleRejectWithReason() {
  loading.value = true
  try {
    await rejectInvitation(props.invitation.id, rejectReason.value)
    ElMessage.success('已拒绝邀请')
  } catch (error) {
    ElMessage.error('操作失败')
  } finally {
    loading.value = false
    showReasonInput.value = false
    rejectReason.value = ''
  }
}
</script>

Step 5: Create TeamSessionPanel

<!-- src/components/team/TeamSessionPanel.vue -->
<template>
  <div v-if="teamStore.hasActiveTeam" class="bg-white rounded-lg shadow p-4">
    <div class="flex items-center justify-between mb-4">
      <h3 class="text-lg font-medium">{{ teamStore.myActiveTeam?.name }}</h3>
      <el-tag :type="getStatusType()">
        {{ TeamStatusMap[teamStore.teamStatus] }}
      </el-tag>
    </div>

    <div class="mb-4">
      <span class="text-gray-500">游戏:</span>
      <span class="ml-2 font-medium">{{ teamStore.myActiveTeam?.gameName }}</span>
    </div>

    <div class="mb-4">
      <span class="text-gray-500">成员:</span>
      <div class="mt-2 flex flex-wrap gap-2">
        <el-tag
          v-for="member in teamMembers"
          :key="member.id"
          :type="member.id === userStore.userId ? 'primary' : 'default'"
        >
          {{ member.expand?.username || member.id }}
        </el-tag>
      </div>
    </div>

    <div class="flex gap-2">
      <el-button
        type="danger"
        :loading="teamStore.loading"
        @click="handleDissolve"
      >
        游戏结束
      </el-button>
    </div>
  </div>

  <div v-else class="bg-white rounded-lg shadow p-4 text-center text-gray-400">
    暂无活跃临时小组
  </div>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router'
import { ElMessageBox, ElMessage } from 'element-plus'
import { useTeamStore } from '@/stores/team'
import { useUserStore } from '@/stores/user'
import { TeamStatusMap, type TeamStatus } from '@/types'

const router = useRouter()
const teamStore = useTeamStore()
const userStore = useUserStore()

const teamMembers = computed(() => {
  // TODO: 从 teamSession.expand.members 获取成员详情
  return teamStore.myActiveTeam?.members || []
})

function getStatusType() {
  const status = teamStore.teamStatus
  switch (status) {
    case 'recruiting': return 'warning'
    case 'playing': return 'success'
    default: return 'info'
  }
}

async function handleDissolve() {
  try {
    await ElMessageBox.confirm('确定要解散临时小组吗?', '确认', {
      type: 'warning'
    })

    await teamStore.dissolveTeam()

    // 恢复用户状态
    await userStore.setStatus('idle')

    ElMessage.success('临时小组已解散')
  } catch (error) {
    if (error !== 'cancel') {
      ElMessage.error('解散失败')
    }
  }
}
</script>

Step 6: Create WorkScheduleModal

<!-- src/components/team/WorkScheduleModal.vue -->
<template>
  <el-dialog
    v-model="visible"
    title="设置工作时间"
    width="400px"
  >
    <el-form :model="form" label-width="80px">
      <el-form-item label="工作日">
        <el-checkbox-group v-model="form.workdays">
          <el-checkbox :label="1">周一</el-checkbox>
          <el-checkbox :label="2">周二</el-checkbox>
          <el-checkbox :label="3">周三</el-checkbox>
          <el-checkbox :label="4">周四</el-checkbox>
          <el-checkbox :label="5">周五</el-checkbox>
          <el-checkbox :label="6">周六</el-checkbox>
          <el-checkbox :label="7">周日</el-checkbox>
        </el-checkbox-group>
      </el-form-item>

      <el-form-item label="开始时间">
        <el-time-picker
          v-model="workTime"
          format="HH:mm"
          value-format="HH:mm"
        />
      </el-form-item>
    </el-form>

    <template #footer>
      <el-button @click="visible = false">取消</el-button>
      <el-button type="primary" @click="handleSave">保存</el-button>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'

const props = defineProps<{
  show: boolean
}>()

const emit = defineEmits<{
  'update:show': [value: boolean]
}>()

const userStore = useUserStore()

const visible = computed({
  get: () => props.show,
  set: (val) => emit('update:show', val)
})

const form = ref({
  workdays: [1, 2, 3, 4, 5],
  workStartTime: '09:00'
})

const workTime = ref('09:00')

watch(() => userStore.user, (user) => {
  if (user) {
    form.value.workdays = user.workdays || [1, 2, 3, 4, 5]
    form.value.workStartTime = user.workStartTime || '09:00'
    workTime.value = form.value.workStartTime
  }
}, { immediate: true })

async function handleSave() {
  try {
    await userStore.setWorkSchedule({
      workdays: form.value.workdays,
      workStartTime: workTime.value
    })
    ElMessage.success('工作时间已保存')
    visible.value = false
  } catch (error) {
    ElMessage.error('保存失败')
  }
}
</script>

Step 7: Commit

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

<!-- src/views/Login.vue -->
<template>
  <div class="min-h-screen flex items-center justify-center bg-gray-100">
    <el-card class="w-96">
      <template #header>
        <h1 class="text-2xl font-bold text-center">登录 Game Group</h1>
      </template>

      <el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleLogin">
        <el-form-item prop="email">
          <el-input
            v-model="form.email"
            type="email"
            placeholder="邮箱"
            prefix-icon="User"
          />
        </el-form-item>

        <el-form-item prop="password">
          <el-input
            v-model="form.password"
            type="password"
            placeholder="密码"
            prefix-icon="Lock"
            show-password
          />
        </el-form-item>

        <el-form-item>
          <el-button
            type="primary"
            native-type="submit"
            :loading="loading"
            class="w-full"
          >
            登录
          </el-button>
        </el-form-item>

        <div class="text-center text-sm text-gray-500">
          还没有账号
          <router-link to="/register" class="text-primary-600">立即注册</router-link>
        </div>
      </el-form>
    </el-card>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, FormInstance, FormRules } from 'element-plus'
import { login } from '@/api/users'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'

const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const groupStore = useGroupStore()

const formRef = ref<FormInstance>()
const loading = ref(false)

const form = reactive({
  email: '',
  password: ''
})

const rules: FormRules = {
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码至少6位', trigger: 'blur' }
  ]
}

async function handleLogin() {
  if (!formRef.value) return

  await formRef.value.validate(async (valid) => {
    if (!valid) return

    loading.value = true
    try {
      await login(form.email, form.password)
      await userStore.init()
      await groupStore.loadMyGroups()

      ElMessage.success('登录成功')

      const redirect = route.query.redirect as string
      router.push(redirect || '/')
    } catch (error: any) {
      ElMessage.error(error.message || '登录失败')
    } finally {
      loading.value = false
    }
  })
}
</script>

Step 2: Create Register page

<!-- src/views/Register.vue -->
<template>
  <div class="min-h-screen flex items-center justify-center bg-gray-100">
    <el-card class="w-96">
      <template #header>
        <h1 class="text-2xl font-bold text-center">注册 Game Group</h1>
      </template>

      <el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleRegister">
        <el-form-item prop="username">
          <el-input
            v-model="form.username"
            placeholder="用户名"
            prefix-icon="User"
          />
        </el-form-item>

        <el-form-item prop="email">
          <el-input
            v-model="form.email"
            type="email"
            placeholder="邮箱"
            prefix-icon="Message"
          />
        </el-form-item>

        <el-form-item prop="password">
          <el-input
            v-model="form.password"
            type="password"
            placeholder="密码"
            prefix-icon="Lock"
            show-password
          />
        </el-form-item>

        <el-form-item prop="passwordConfirm">
          <el-input
            v-model="form.passwordConfirm"
            type="password"
            placeholder="确认密码"
            prefix-icon="Lock"
            show-password
          />
        </el-form-item>

        <el-form-item>
          <el-button
            type="primary"
            native-type="submit"
            :loading="loading"
            class="w-full"
          >
            注册
          </el-button>
        </el-form-item>

        <div class="text-center text-sm text-gray-500">
          已有账号
          <router-link to="/login" class="text-primary-600">立即登录</router-link>
        </div>
      </el-form>
    </el-card>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, FormInstance, FormRules } from 'element-plus'
import { register } from '@/api/users'

const router = useRouter()

const formRef = ref<FormInstance>()
const loading = ref(false)

const form = reactive({
  username: '',
  email: '',
  password: '',
  passwordConfirm: ''
})

const validatePasswordConfirm = (_rule: any, value: string, callback: any) => {
  if (value !== form.password) {
    callback(new Error('两次密码不一致'))
  } else {
    callback()
  }
}

const rules: FormRules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 2, max: 20, message: '用户名2-20位', trigger: 'blur' }
  ],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码至少6位', trigger: 'blur' }
  ],
  passwordConfirm: [
    { required: true, message: '请确认密码', trigger: 'blur' },
    { validator: validatePasswordConfirm, trigger: 'blur' }
  ]
}

async function handleRegister() {
  if (!formRef.value) return

  await formRef.value.validate(async (valid) => {
    if (!valid) return

    loading.value = true
    try {
      await register(form.email, form.password, form.passwordConfirm, form.username)
      ElMessage.success('注册成功,请登录')
      router.push('/login')
    } catch (error: any) {
      ElMessage.error(error.message || '注册失败')
    } finally {
      loading.value = false
    }
  })
}
</script>

Step 3: Create GroupView page

<!-- src/views/GroupView.vue -->
<template>
  <div class="max-w-6xl mx-auto">
    <!-- 临时小组面板 -->
    <TeamSessionPanel class="mb-6" />

    <!-- 空闲成员列表 -->
    <IdleMembersList class="mb-6" />

    <!-- 待处理邀请 -->
    <div v-if="notificationStore.pendingInvitations.length > 0" class="bg-white rounded-lg shadow p-4">
      <h3 class="text-lg font-medium mb-4">待处理邀请</h3>
      <InvitationCard
        v-for="invitation in notificationStore.pendingInvitations"
        :key="invitation.id"
        :invitation="invitation"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useGroupStore } from '@/stores/group'
import { useTeamStore } from '@/stores/team'
import { useNotificationStore } from '@/stores/notification'
import { getMyPendingInvitations } from '@/api/invitations'
import TeamSessionPanel from '@/components/team/TeamSessionPanel.vue'
import IdleMembersList from '@/components/team/IdleMembersList.vue'
import InvitationCard from '@/components/team/InvitationCard.vue'

const route = useRoute()
const groupStore = useGroupStore()
const teamStore = useTeamStore()
const notificationStore = useNotificationStore()

onMounted(async () => {
  // 加载群组详情
  const groupId = route.params.id as string
  if (groupId) {
    await groupStore.setCurrentGroup(groupId)
  }

  // 加载活跃临时小组
  await teamStore.loadMyActiveTeam()

  // 加载待处理邀请
  const invitations = await getMyPendingInvitations()
  notificationStore.pendingInvitations = invitations.items
})

// 监听群组变化
watch(() => route.params.id, async (newId) => {
  if (newId) {
    await groupStore.setCurrentGroup(newId as string)
  }
})
</script>

Step 4: Create GamesLibrary page

<!-- src/views/GamesLibrary.vue -->
<template>
  <div class="max-w-6xl mx-auto">
    <div class="flex items-center justify-between mb-6">
      <h1 class="text-2xl font-bold">游戏库</h1>
      <el-input
        v-model="searchQuery"
        placeholder="搜索游戏..."
        style="width: 300px"
        clearable
        @input="handleSearch"
      >
        <template #prefix>
          <el-icon><Search /></el-icon>
        </template>
      </el-input>
    </div>

    <!-- 平台筛选 -->
    <div class="flex gap-2 mb-6">
      <el-button
        v-for="platform in platforms"
        :key="platform.value"
        :type="selectedPlatform === platform.value ? 'primary' : 'default'"
        @click="handlePlatformChange(platform.value)"
      >
        {{ platform.label }}
      </el-button>
    </div>

    <!-- 游戏列表 -->
    <div v-loading="loading" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
      <div
        v-for="game in games"
        :key="game.id"
        class="bg-white rounded-lg shadow overflow-hidden hover:shadow-lg transition-shadow cursor-pointer"
        @click="handleSelectGame(game)"
      >
        <img
          :src="game.cover || '/placeholder-game.png'"
          :alt="game.name"
          class="w-full aspect-video object-cover"
        />
        <div class="p-3">
          <div class="font-medium truncate">{{ game.name }}</div>
          <div class="text-sm text-gray-500">{{ game.platform }}</div>
          <div class="flex items-center justify-between mt-2">
            <el-tag size="small">{{ game.popularCount || 0 }}</el-tag>
            <el-tag size="small" type="info">{{ game.tags?.[0] || '' }}</el-tag>
          </div>
        </div>
      </div>
    </div>

    <!-- 分页 -->
    <div class="flex justify-center mt-6">
      <el-pagination
        v-model:current-page="currentPage"
        :page-size="pageSize"
        :total="total"
        layout="prev, pager, next"
        @current-change="handlePageChange"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { getGames, searchGames, getGamesByPlatform, type GamePlatform } from '@/api/games'
import type { Game } from '@/types'

const platforms = [
  { label: '全部', value: '' },
  { label: 'PC', value: 'PC' },
  { label: 'PS5', value: 'PS5' },
  { label: 'Xbox', value: 'Xbox' },
  { label: 'Switch', value: 'Switch' },
  { label: 'Mobile', value: 'Mobile' }
]

const loading = ref(false)
const games = ref<Game[]>([])
const searchQuery = ref('')
const selectedPlatform = ref<'' | GamePlatform>('')
const currentPage = ref(1)
const pageSize = ref(24)
const total = ref(0)

async function loadGames() {
  loading.value = true
  try {
    const result = await getGames(currentPage.value, pageSize.value)
    games.value = result.items as Game[]
    total.value = result.totalItems
  } catch (error) {
    console.error('加载游戏失败:', error)
  } finally {
    loading.value = false
  }
}

async function handleSearch() {
  if (!searchQuery.value) {
    loadGames()
    return
  }

  loading.value = true
  try {
    const result = await searchGames(searchQuery.value)
    games.value = result.items as Game[]
    total.value = result.totalItems
  } catch (error) {
    console.error('搜索失败:', error)
  } finally {
    loading.value = false
  }
}

async function handlePlatformChange(platform: '' | GamePlatform) {
  selectedPlatform.value = platform
  currentPage.value = 1

  if (!platform) {
    loadGames()
    return
  }

  loading.value = true
  try {
    const result = await getGamesByPlatform(platform)
    games.value = result.items as Game[]
    total.value = result.totalItems
  } catch (error) {
    console.error('加载失败:', error)
  } finally {
    loading.value = false
  }
}

function handlePageChange(page: number) {
  currentPage.value = page
  loadGames()
}

function handleSelectGame(game: Game) {
  // 可以在这里处理游戏选择逻辑
  console.log('Selected game:', game)
}

onMounted(() => {
  loadGames()
})
</script>

Step 5: Create NotificationPanel component

<!-- src/components/common/NotificationPanel.vue -->
<template>
  <el-drawer v-model="visible" title="通知" size="400px">
    <template #header>
      <div class="flex items-center justify-between">
        <span>通知 ({{ unreadCount }})</span>
        <el-button link @click="markAllRead">全部已读</el-button>
      </div>
    </template>

    <div class="space-y-3">
      <div
        v-for="notification in notificationStore.notifications"
        :key="notification.id"
        class="p-3 rounded-lg"
        :class="notification.read ? 'bg-gray-50' : 'bg-primary-50'"
      >
        <div class="flex items-start justify-between">
          <div>
            <div class="font-medium">{{ notification.title }}</div>
            <div class="text-sm text-gray-600 mt-1">{{ notification.message }}</div>
            <div class="text-xs text-gray-400 mt-1">{{ formatTime(notification.createdAt) }}</div>
          </div>
          <el-button
            v-if="notification.type === 'invitation'"
            type="primary"
            size="small"
            @click="handleInvitationClick(notification)"
          >
            查看
          </el-button>
        </div>
      </div>

      <div v-if="notificationStore.notifications.length === 0" class="text-center text-gray-400 py-8">
        暂无通知
      </div>
    </div>
  </el-drawer>
</template>

<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useNotificationStore } from '@/stores/notification'

const notificationStore = useNotificationStore()
const visible = ref(false)

const unreadCount = computed(() =>
  notificationStore.notifications.filter(n => !n.read).length
)

watch(() => notificationStore.notifications.length, (newLength) => {
  if (newLength > 0) {
    visible.value = true
  }
})

function markAllRead() {
  notificationStore.notifications.forEach(n => n.read = true)
}

function formatTime(date: Date) {
  const now = new Date()
  const diff = now.getTime() - date.getTime()
  const minutes = Math.floor(diff / 60000)

  if (minutes < 1) return '刚刚'
  if (minutes < 60) return `${minutes}分钟前`
  if (minutes < 1440) return `${Math.floor(minutes / 60)}小时前`
  return `${Math.floor(minutes / 1440)}天前`
}

function handleInvitationClick(notification: any) {
  // 处理邀请点击
  notificationStore.markAsRead(notification.id)
}
</script>

Step 6: Create GameSelectDialog component

<!-- src/components/team/GameSelectDialog.vue -->
<template>
  <el-dialog
    v-model="visible"
    title="选择游戏"
    width="500px"
  >
    <el-input
      v-model="searchQuery"
      placeholder="搜索游戏..."
      clearable
      @input="handleSearch"
    >
      <template #prefix>
        <el-icon><Search /></el-icon>
      </template>
    </el-input>

    <div v-loading="loading" class="mt-4 max-h-96 overflow-auto">
      <div
        v-for="game in games"
        :key="game.id"
        class="flex items-center gap-3 p-3 hover:bg-gray-50 rounded-lg cursor-pointer"
        @click="handleSelect(game)"
      >
        <img
          :src="game.cover || '/placeholder-game.png'"
          :alt="game.name"
          class="w-12 h-12 rounded object-cover"
        />
        <div class="flex-1">
          <div class="font-medium">{{ game.name }}</div>
          <div class="text-sm text-gray-500">{{ game.platform }}</div>
        </div>
      </div>

      <div v-if="games.length === 0 && !loading" class="text-center text-gray-400 py-8">
        未找到游戏
      </div>
    </div>
  </el-dialog>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { searchGames } from '@/api/games'
import type { Game } from '@/types'

const props = defineProps<{
  show: boolean
}>()

const emit = defineEmits<{
  'update:show': [value: boolean]
  'confirm': [gameName: string]
}>()

const visible = computed({
  get: () => props.show,
  set: (val) => emit('update:show', val)
})

const searchQuery = ref('')
const loading = ref(false)
const games = ref<Game[]>([])

watch(() => props.show, (show) => {
  if (show) {
    handleSearch()
  } else {
    searchQuery.value = ''
    games.value = []
  }
})

async function handleSearch() {
  if (!searchQuery.value) {
    games.value = []
    return
  }

  loading.value = true
  try {
    const result = await searchGames(searchQuery.value)
    games.value = result.items as Game[]
  } catch (error) {
    console.error('搜索失败:', error)
  } finally {
    loading.value = false
  }
}

function handleSelect(game: Game) {
  emit('confirm', game.name)
  visible.value = false
}
</script>

Step 7: Update main.ts

// 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

<!-- src/App.vue -->
<template>
  <router-view />
</template>

<script setup lang="ts">
</script>

Step 9: Create main.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

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

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Game Group V2</title>
  <link rel="icon" href="/favicon.ico">
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.ts"></script>
</body>
</html>

Step 2: Commit

git add index.html
git commit -m "feat: update entry point"

Phase 3: Testing

Task 12: Test User Flow

Step 1: Start PocketBase

cd backend
./pocketbase serve

Step 2: Start Frontend

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

cd frontend
npm run build

Step 2: Copy to NAS

# 复制前端构建文件到 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

PB_PORT=8090

Step 4: Start service

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.