- 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>
81 KiB
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
- 访问 http://localhost:5173
- 注册新用户
- 登录
- 创建群组
- 切换状态为"空闲"
- 注册另一个用户,登录
- 加入同一个群组
- 第一个用户邀请第二个用户
- 第二个用户接受邀请
- 查看临时小组创建成功
- 点击"游戏结束"解散小组
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.