commit 4d1a53fc693bc1f664d2edd239d018c27ab568b7 Author: congsh Date: Fri Apr 17 14:14:05 2026 +0800 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) diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..7af3b7f --- /dev/null +++ b/backend/.env @@ -0,0 +1,3 @@ +PB_PORT=8090 +PB_DATA=./pb_data +PB_MIGRATIONS=./pb_migrations diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..cf4d339 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,16 @@ +# 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) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..38d6ace --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,14 @@ +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 diff --git a/backend/pb_migrations/1738717600_init.pb.js b/backend/pb_migrations/1738717600_init.pb.js new file mode 100644 index 0000000..441e6bb --- /dev/null +++ b/backend/pb_migrations/1738717600_init.pb.js @@ -0,0 +1,781 @@ +/// + +migrate((app) => { + // Users collection + app.deleteCollection("users"); + app.createCollection("users", { + id: "users", + name: "users", + type: "auth", + fields: [ + { + id: "new_aQr3td8dJl", + name: "username", + type: "text", + system: false, + required: true, + unique: true, + options: { + min: 3, + max: 20, + pattern: "^[a-zA-Z0-9_]+$", + } + }, + { + id: "new_Jx4zWq9kLm", + name: "displayName", + type: "text", + system: false, + required: true, + unique: false, + options: { + min: null, + max: 50, + pattern: "", + } + }, + { + id: "new_Xp7vR2sT4u", + name: "avatar", + type: "url", + system: false, + required: false, + unique: false, + options: { + exceptDomains: null, + onlyDomains: null, + } + }, + { + id: "new_Nh8gD3fF6g", + name: "bio", + type: "text", + system: false, + required: false, + unique: false, + options: { + min: null, + max: 500, + pattern: "", + } + }, + { + id: "new_Vc5bH8kJ7o", + name: "region", + type: "select", + system: false, + required: true, + unique: false, + options: { + maxSelect: 1, + values: [ + "cn", + "us", + "eu", + "asia", + "other" + ], + } + }, + { + id: "new_Tf6rK9mP8q", + name: "preferences", + type: "json", + system: false, + required: false, + unique: false, + options: { + maxSize: 10000, + } + }, + { + id: "new_Dg7hL0nR9s", + name: "stats", + type: "json", + system: false, + required: false, + unique: false, + options: { + maxSize: 10000, + } + }, + { + id: "new_Wm8jO1pS0t", + name: "isActive", + type: "bool", + system: false, + required: false, + unique: false, + options: {} + }, + { + id: "new_Yn9kQ2qT1u", + name: "lastSeen", + type: "date", + system: false, + required: false, + unique: false, + options: { + min: "", + max: "", + } + }, + ], + indexes: [ + "create index users_username_idx on users (username)", + "create index users_region_idx on users (region)", + "create index users_isActive_idx on users (isActive)", + ], + listRule: "id = @request.auth.id", + viewRule: "id = @request.auth.id", + createRule: null, + updateRule: "id = @request.auth.id", + deleteRule: "id = @request.auth.id", + options: { + allowEmailAuth: true, + allowOAuth2Auth: false, + allowUsernameAuth: false, + exceptEmailDomains: null, + manageRule: null, + minPasswordLength: 8, + onlyEmailDomains: null, + requireEmail: false, + } + }); + + // Games collection + app.deleteCollection("games"); + app.createCollection("games", { + id: "games", + name: "games", + type: "base", + fields: [ + { + id: "new_Zp3wT8sV5x", + name: "name", + type: "text", + system: false, + required: true, + unique: true, + options: { + min: 1, + max: 100, + pattern: "", + } + }, + { + id: "new_Bq4xU9tW6y", + name: "nameEn", + type: "text", + system: false, + required: false, + unique: false, + options: { + min: null, + max: 100, + pattern: "", + } + }, + { + id: "new_Cr5yV0uX7z", + name: "cover", + type: "url", + system: false, + required: false, + unique: false, + options: { + exceptDomains: null, + onlyDomains: null, + } + }, + { + id: "new_Ds6zW1vY8a", + name: "description", + type: "editor", + system: false, + required: false, + unique: false, + options: { + convertUrls: false, + } + }, + { + id: "new_Et7aX2wZ9b", + name: "type", + type: "select", + system: false, + required: true, + unique: false, + options: { + maxSelect: 1, + values: [ + "fps", + "moba", + "rpg", + "strategy", + "racing", + "sports", + "casual", + "other" + ], + } + }, + { + id: "new_Fu8bY3xA0c", + name: "platform", + type: "select", + system: false, + required: true, + unique: false, + options: { + maxSelect: 1, + values: [ + "pc", + "mobile", + "console", + "crossplay" + ], + } + }, + { + id: "new_Gv9cZ4yB1d", + name: "maxTeamSize", + type: "number", + system: false, + required: true, + unique: false, + options: { + min: 2, + max: 100, + noDecimal: true, + } + }, + { + id: "new_Hw0dA5zC2e", + name: "tags", + type: "json", + system: false, + required: false, + unique: false, + options: { + maxSize: 5000, + } + }, + { + id: "new_Ix1eB6aD3f", + name: "isActive", + type: "bool", + system: false, + required: false, + unique: false, + options: {} + }, + ], + indexes: [ + "create index games_name_idx on games (name)", + "create index games_type_idx on games (type)", + "create index games_platform_idx on games (platform)", + "create index games_isActive_idx on games (isActive)", + ], + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + options: { + allowEmailAuth: false, + allowOAuth2Auth: false, + allowUsernameAuth: false, + exceptEmailDomains: null, + manageRule: null, + onlyEmailDomains: null, + requireEmail: false, + } + }); + + // Groups collection + app.deleteCollection("groups"); + app.createCollection("groups", { + id: "groups", + name: "groups", + type: "base", + fields: [ + { + id: "new_Jy2fC7bE4g", + name: "name", + type: "text", + system: false, + required: true, + unique: false, + options: { + min: 3, + max: 50, + pattern: "", + } + }, + { + id: "new_Kz3gD8cF5h", + name: "description", + type: "text", + system: false, + required: false, + unique: false, + options: { + min: null, + max: 500, + pattern: "", + } + }, + { + id: "new_La4hE9dG6i", + name: "avatar", + type: "url", + system: false, + required: false, + unique: false, + options: { + exceptDomains: null, + onlyDomains: null, + } + }, + { + id: "new_Mb5iF0eH7j", + name: "owner", + type: "relation", + system: false, + required: true, + unique: false, + options: { + collectionId: "users", + cascadeDelete: true, + minSelect: null, + maxSelect: 1, + displayFields: [ + "username", + "displayName" + ], + } + }, + { + id: "new_Nc6jG1fI8k", + name: "game", + type: "relation", + system: false, + required: true, + unique: false, + options: { + collectionId: "games", + cascadeDelete: false, + minSelect: null, + maxSelect: 1, + displayFields: [ + "name" + ], + } + }, + { + id: "new_Od7kH2gJ9l", + name: "members", + type: "relation", + system: false, + required: false, + unique: false, + options: { + collectionId: "users", + cascadeDelete: false, + minSelect: null, + maxSelect: null, + displayFields: [ + "username", + "displayName" + ], + } + }, + { + id: "new_Pe8lI3hK0m", + name: "maxMembers", + type: "number", + system: false, + required: true, + unique: false, + options: { + min: 2, + max: 100, + noDecimal: true, + } + }, + { + id: "new_Qf9mJ4iL1n", + name: "status", + type: "select", + system: false, + required: true, + unique: false, + options: { + maxSelect: 1, + values: [ + "recruiting", + "full", + "inactive" + ], + } + }, + { + id: "new_Rg0nK5jM2o", + name: "tags", + type: "json", + system: false, + required: false, + unique: false, + options: { + maxSize: 5000, + } + }, + { + id: "new_Sh1oL6kN3p", + name: "requirements", + type: "json", + system: false, + required: false, + unique: false, + options: { + maxSize: 10000, + } + }, + { + id: "new_Ti2pM7lO4q", + name: "stats", + type: "json", + system: false, + required: false, + unique: false, + options: { + maxSize: 10000, + } + }, + ], + indexes: [ + "create index groups_name_idx on groups (name)", + "create index groups_owner_idx on groups (owner)", + "create index groups_game_idx on groups (game)", + "create index groups_status_idx on groups (status)", + ], + listRule: "", + viewRule: "", + createRule: "@request.auth.id != \"\"", + updateRule: "owner = @request.auth.id", + deleteRule: "owner = @request.auth.id", + options: { + allowEmailAuth: false, + allowOAuth2Auth: false, + allowUsernameAuth: false, + exceptEmailDomains: null, + manageRule: null, + onlyEmailDomains: null, + requireEmail: false, + } + }); + + // Team Sessions collection + app.deleteCollection("teamSessions"); + app.createCollection("teamSessions", { + id: "teamSessions", + name: "teamSessions", + type: "base", + fields: [ + { + id: "new_Uj3qN8mP5r", + name: "group", + type: "relation", + system: false, + required: true, + unique: false, + options: { + collectionId: "groups", + cascadeDelete: true, + minSelect: null, + maxSelect: 1, + displayFields: [ + "name" + ], + } + }, + { + id: "new_Vk4rO9nQ6s", + name: "host", + type: "relation", + system: false, + required: true, + unique: false, + options: { + collectionId: "users", + cascadeDelete: false, + minSelect: null, + maxSelect: 1, + displayFields: [ + "username", + "displayName" + ], + } + }, + { + id: "new_Wl5sP0oR7t", + name: "participants", + type: "relation", + system: false, + required: false, + unique: false, + options: { + collectionId: "users", + cascadeDelete: false, + minSelect: null, + maxSelect: null, + displayFields: [ + "username", + "displayName" + ], + } + }, + { + id: "new_Xm6tQ1pS8u", + name: "status", + type: "select", + system: false, + required: true, + unique: false, + options: { + maxSelect: 1, + values: [ + "waiting", + "playing", + "completed", + "cancelled" + ], + } + }, + { + id: "new_Yn7uR2qT9v", + name: "voiceChat", + type: "bool", + system: false, + required: false, + unique: false, + options: {} + }, + { + id: "new_Zo8vS3rU0w", + name: "roomInfo", + type: "json", + system: false, + required: false, + unique: false, + options: { + maxSize: 10000, + } + }, + { + id: "new_Ap9wT4sV1x", + name: "notes", + type: "text", + system: false, + required: false, + unique: false, + options: { + min: null, + max: 1000, + pattern: "", + } + }, + { + id: "new_Bq0xU5tW2y", + name: "scheduledAt", + type: "date", + system: false, + required: false, + unique: false, + options: { + min: "", + max: "", + } + }, + { + id: "new_Cr1yV6uX3z", + name: "startedAt", + type: "date", + system: false, + required: false, + unique: false, + options: { + min: "", + max: "", + } + }, + { + id: "new_Ds2zW7vY4a", + name: "endedAt", + type: "date", + system: false, + required: false, + unique: false, + options: { + min: "", + max: "", + } + }, + ], + indexes: [ + "create index teamSessions_group_idx on teamSessions (group)", + "create index teamSessions_host_idx on teamSessions (host)", + "create index teamSessions_status_idx on teamSessions (status)", + "create index teamSessions_scheduledAt_idx on teamSessions (scheduledAt)", + ], + listRule: "group.owner = @request.auth.id || group.members.id = @request.auth.id", + viewRule: "group.owner = @request.auth.id || group.members.id = @request.auth.id", + createRule: "@request.auth.id != \"\"", + updateRule: "group.owner = @request.auth.id", + deleteRule: "group.owner = @request.auth.id", + options: { + allowEmailAuth: false, + allowOAuth2Auth: false, + allowUsernameAuth: false, + exceptEmailDomains: null, + manageRule: null, + onlyEmailDomains: null, + requireEmail: false, + } + }); + + // Invitations collection + app.deleteCollection("invitations"); + app.createCollection("invitations", { + id: "invitations", + name: "invitations", + type: "base", + fields: [ + { + id: "new_Et3yZ8wB5c", + name: "group", + type: "relation", + system: false, + required: true, + unique: false, + options: { + collectionId: "groups", + cascadeDelete: true, + minSelect: null, + maxSelect: 1, + displayFields: [ + "name" + ], + } + }, + { + id: "new_Fu4zA9xC6d", + name: "sender", + type: "relation", + system: false, + required: true, + unique: false, + options: { + collectionId: "users", + cascadeDelete: false, + minSelect: null, + maxSelect: 1, + displayFields: [ + "username", + "displayName" + ], + } + }, + { + id: "new_Gv5aB0yD7e", + name: "recipient", + type: "relation", + system: false, + required: true, + unique: false, + options: { + collectionId: "users", + cascadeDelete: false, + minSelect: null, + maxSelect: 1, + displayFields: [ + "username", + "displayName" + ], + } + }, + { + id: "new_Hw6bC1zE8f", + name: "status", + type: "select", + system: false, + required: true, + unique: false, + options: { + maxSelect: 1, + values: [ + "pending", + "accepted", + "rejected", + "cancelled" + ], + } + }, + { + id: "new_Ix7cD2aF9g", + name: "message", + type: "text", + system: false, + required: false, + unique: false, + options: { + min: null, + max: 500, + pattern: "", + } + }, + { + id: "new_Jy8dE3bG0h", + name: "respondedAt", + type: "date", + system: false, + required: false, + unique: false, + options: { + min: "", + max: "", + } + }, + ], + indexes: [ + "create index invitations_group_idx on invitations (group)", + "create index invitations_sender_idx on invitations (sender)", + "create index invitations_recipient_idx on invitations (recipient)", + "create index invitations_status_idx on invitations (status)", + "create unique index invitations_unique_pending on invitations (group, recipient) where status = 'pending'", + ], + listRule: "sender = @request.auth.id || recipient = @request.auth.id || group.owner = @request.auth.id", + viewRule: "sender = @request.auth.id || recipient = @request.auth.id || group.owner = @request.auth.id", + createRule: "group.owner = @request.auth.id", + updateRule: "recipient = @request.auth.id", + deleteRule: "sender = @request.auth.id || group.owner = @request.auth.id", + options: { + allowEmailAuth: false, + allowOAuth2Auth: false, + allowUsernameAuth: false, + exceptEmailDomains: null, + manageRule: null, + onlyEmailDomains: null, + requireEmail: false, + } + }); + +}, (app) => { + // Rollback + app.deleteCollection("invitations"); + app.deleteCollection("teamSessions"); + app.deleteCollection("groups"); + app.deleteCollection("games"); + app.deleteCollection("users"); +}); diff --git a/docs/plans/2026-04-17-game-group-v2-design.md b/docs/plans/2026-04-17-game-group-v2-design.md new file mode 100644 index 0000000..df5c73c --- /dev/null +++ b/docs/plans/2026-04-17-game-group-v2-design.md @@ -0,0 +1,569 @@ +# Game Group V2 设计文档 + +**日期**: 2026-04-17 +**版本**: v2.0 +**目标**: 重构游戏小队约玩系统,简化组队和投票流程,改进多群组隔离性 + +--- + +## 一、项目概述 + +### 1.1 现有问题分析 + +基于现有 game-group 项目的使用反馈: + +- **组队麻烦** - 原有匹配机制难以避免时间对齐问题,朋友之间闹矛盾会被强制匹配到一起 +- **投票麻烦** - 投票流程过于复杂 +- **隔离性不足** - 只有一个群组,缺乏多群组支持 + +### 1.2 设计目标 + +- **握手式匹配** - 类似 Steam 邀请机制,自主选择是否加入 +- **多群组支持** - 每人可加入 3-10 个群组(管理员可配置) +- **临时小组** - 组队后创建临时小组,游戏结束后解散 +- **轻量级后端** - 使用自建 BaaS 服务(PocketBase),部署在 NAS + +--- + +## 二、技术架构 + +### 2.1 技术栈 + +| 层级 | 技术选型 | 说明 | +|------|---------|------| +| 后端 | PocketBase | Go 语言,单文件可执行,内置 SQLite | +| 数据库 | SQLite | 轻量级,适合 NAS 部署 | +| 前端 | Vue 3 + TypeScript | 保持原有技术栈 | +| UI 框架 | Element Plus | 保持原有技术栈 | +| 样式 | Tailwind CSS | 保持原有技术栈 | +| 实时通信 | WebSocket | PocketBase 内置支持 | + +### 2.2 部署架构 + +``` +NAS 服务器 +├── PocketBase (端口可配置,默认 8090) +│ ├── SQLite 数据库文件 +│ └── 上传文件存储 +└── 前端静态文件 (可选 NAS Web 服务器) +``` + +--- + +## 三、数据模型 + +### 3.1 核心数据集合 + +```javascript +// users - 用户 +{ + id: string + username: string + email: string + avatar: string + status: "idle" | "working" | "in_team" | "away" // 用户状态 + statusNote: string // 状态备注/拒绝原因 + maxGroups: number // 可加入群组数(默认5) + workdays: number[] // 工作日 [1-7] + workStartTime: string // 工作开始时间 "HH:mm" + nextWorkTime: number // 下次工作时间戳 + points: number // 积分(二期) + createdAt: date + updatedAt: date +} + +// groups - 群组(长期) +{ + id: string + name: string + description: string + owner: string (userId) + members: string[] (userIds) + maxMembers: number + honors: array (二期 - 荣誉墙) + createdAt: date + updatedAt: date +} + +// teamSessions - 临时小组(短期) +{ + id: string + sourceGroup: string (groupId) + name: string + gameName: string + members: string[] (userIds) + status: "recruiting" | "playing" | "finished" | "dissolved" + createdAt: date + dissolvedAt: date +} + +// invitations - 邀请记录 +{ + id: string + from: string (userId) + to: string (userId) + teamSession: string (teamSessionId) + status: "pending" | "accepted" | "rejected" + rejectReason: string + createdAt: date + respondedAt: date +} + +// games - 游戏库 +{ + id: string + name: string + platform: "PC" | "PS5" | "Xbox" | "Switch" | "Mobile" + tags: string[] + cover: string (url) + popularCount: number + createdAt: date +} +``` + +### 3.2 二期数据集合 + +```javascript +// appointments - 预约系统 +{ + id: string + groupId: string + gameName: string + scheduledTime: date + initiator: string (userId) + participants: array + [{ userId, status: "going" | "maybe" | "not_going" }] + note: string + status: "pending" | "confirmed" | "cancelled" + createdAt: date +} + +// pointsHistory - 积分记录 +{ + id: string + userId: string + groupId: string + type: "team_joined" | "game_completed" | "appointment_attended" | "abandoned" + amount: number + reason: string + createdAt: date +} + +// honors - 荣誉墙 +{ + id: string + userId: string + groupId: string + title: string + description: string + icon: string + awardedAt: date +} +``` + +### 3.3 三期数据集合 + +```javascript +// ledgers - 账目 +{ + id: string + groupId: string + type: "income" | "expense" | "settlement" + amount: number + category: string + description: string + createdBy: string (userId) + relatedMembers: string[] (userIds) + settled: boolean + occurredAt: date + createdAt: date +} + +// ledgerShares - 分摊明细 +{ + id: string + ledgerId: string + userId: string + shareAmount: number + paidAmount: number + status: "pending" | "paid" | "waived" +} + +// assets - 资产 +{ + id: string + groupId: string + name: string + type: "game_account" | "console" | "equipment" + credentials: string (加密) + status: "available" | "borrowed" | "maintenance" + currentBorrower: string (userId) + note: string + createdAt: date +} + +// borrowLogs - 借还记录 +{ + id: string + assetId: string + borrower: string (userId) + borrowTime: date + returnTime: date + conditionNote: string + status: "active" | "returned" | "damaged" +} +``` + +### 3.4 四期数据集合 + +```javascript +// blacklist - 黑名单 +{ + id: string + groupId: string + reporter: string (userId) + targetGameId: string + reason: string + category: "behavior" | "cheating" | "abandonment" | "other" + description: string + evidence: string[] (urls) + status: "pending" | "approved" | "rejected" + reviewedBy: string (userId) + reviewedNote: string + createdAt: date + reviewedAt: date +} + +// bets - 竞猜 +{ + id: string + groupId: string + title: string + type: "winner" | "score" | "custom" + options: array + [{ id, name, odds }] + participants: object (userId -> optionId) + stakeAmount: number + status: "open" | "closed" | "settled" + resultOptionId: string + createdBy: string (userId) + closeTime: date + createdAt: date + settledAt: date +} + +// betParticipations - 下注记录 +{ + id: string + betId: string + userId: string + selectedOptionId: string + stakeAmount: number + winAmount: number + status: "pending" | "won" | "lost" +} +``` + +--- + +## 四、核心功能设计 + +### 4.1 用户状态(中文化) + +| 状态值 | 显示 | 图标 | +|--------|------|------| +| idle | 空闲 | 🟢 | +| working | 工作中 | 🔴 | +| in_team | 组队中 | 🔵 | +| away | 离开 | ⚫ | + +### 4.2 握手匹配流程 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 状态管理与匹配 │ +└─────────────────────────────────────────────────────────────┘ + +1. 用户设置状态 + 用户 → 点击"我空了" → status: idle + 用户 → 设置工作时间 → workSchedule 更新 + 定时任务 → 检查 nextWorkTime → 自动变更为 working + +2. 查看空闲成员 + 进入群组 → 显示 members 中 status=idle 的用户 + 实时订阅 → members 状态变更时自动更新列表 + +3. 发起邀请 + 选择空闲成员 → 创建 teamSession (status: recruiting) + 批量创建 invitations (status: pending) + 被邀请者收到实时通知 + +4. 响应邀请 + 接受 → invitation.status: accepted, user.status: in_team + 加入 teamSession.members + 拒绝 → invitation.status: rejected, 填写 rejectReason + 发送者看到拒绝原因 + +5. 组队完成 + 所有受邀者响应 → teamSession.status: playing + 显示临时小组信息 + +6. 解散小组 + 任何人点击"游戏结束" → teamSession.status: dissolved + 所有成员 status 恢复原状 +``` + +### 4.3 工作时间自动管理 + +``` +用户设置: +- workdays: [1,2,3,4,5] (周一到周五) +- workStartTime: "09:00" + +系统计算: +- nextWorkTime = 下一个工作日的 09:00 +- 定时检查: 当前时间 >= nextWorkTime ? 自动变更为 working +- 手动改状态后: nextWorkTime = 下一个工作日的 09:00 +``` + +### 4.4 邀请机制 + +- **一对一邀请** - 逐个发起,可连续邀请 +- **拒绝原因隐私** - 只有发起邀请的人能看到拒绝原因 +- **实时通知** - 使用 PocketBase 实时订阅推送邀请 + +--- + +## 五、前端界面设计 + +### 5.1 页面结构 + +``` +登录/注册页 + ↓ +主布局 +├── 侧边栏 +│ ├── 群组列表 +│ ├── 工作时间设置 +│ └── 个人状态切换 +├── 主内容区 +│ ├── 当前群组视图 +│ │ ├── 空闲成员列表 +│ │ │ └── 每个成员: 头像 + 状态 + "邀请"按钮 +│ │ ├── 游戏选择 +│ │ └── 我的临时小组 +│ │ └── 成员状态 + 游戏结束按钮 +│ └── 游戏库页面 +│ ├── 游戏列表/搜索 +│ └── 热门游戏 +└── 通知中心 + └── 邀请通知 (接受/拒绝) +``` + +### 5.2 核心交互 + +1. **状态切换** + - 顶部状态指示器,点击切换 + - 工作时间弹窗设置 + +2. **邀请流程** + - 点击成员旁"邀请" → 选择游戏 → 创建临时小组 → 发送邀请 + - 已邀请的成员显示"等待响应" + +3. **响应邀请** + - 通知中心弹出邀请卡片 + - 显示:发起人、游戏、其他已加入成员 + - 按钮:接受 / 拒绝(输入原因) + +4. **临时小组** + - 显示所有已加入成员 + - 谁都可以点"游戏结束"解散 + +--- + +## 六、功能分期 + +### 第一期(MVP) + +**目标**: 核心组队功能 + +- [x] 用户认证(注册/登录) +- [x] 状态管理(空闲/工作中/组队中/离开) +- [x] 工作时间设定 +- [x] 群组管理(创建/加入/退出) +- [x] 握手式邀请(发起/接受/拒绝) +- [x] 临时小组(创建/解散) +- [x] 游戏库(列表/搜索/热门) +- [x] 实时状态同步 + +### 第二期 + +**目标**: 预约 + 积分 + 荣誉 + +- [ ] 预约系统(简化投票) +- [ ] 积分系统(获取/记录) +- [ ] 荣誉墙(自动授予) + +### 第三期 + +**目标**: 财务 + 资产 + +- [ ] 账目管理(记录/分摊/结算) +- [ ] 资产管理(登记/借用/归还) + +### 第四期 + +**目标**: 黑名单 + 竞猜 + +- [ ] 黑名单系统(举报/审核) +- [ ] 竞猜系统(创建/下注/开奖) + +--- + +## 七、项目文件结构 + +``` +game-group-v2/ +├── backend/ +│ ├── pocketbase/ +│ │ ├── pb_migrations/ # 数据库迁移文件 +│ │ ├── pb_data/ # 数据存储(SQLite) +│ │ └── pocketbase # 可执行文件 +│ ├── .env # 配置(端口等) +│ └── docker-compose.yml # 可选:容器化部署 +│ +├── frontend/ +│ ├── src/ +│ │ ├── api/ +│ │ │ ├── pocketbase.ts # PB 实例初始化 +│ │ │ ├── users.ts # 用户相关 +│ │ │ ├── groups.ts # 群组相关 +│ │ │ ├── sessions.ts # 临时小组相关 +│ │ │ ├── invitations.ts # 邀请相关 +│ │ │ └── games.ts # 游戏库相关 +│ │ │ ├── appointments.ts # 预约(二期) +│ │ │ ├── ledgers.ts # 账目(三期) +│ │ │ ├── assets.ts # 资产(三期) +│ │ │ ├── blacklist.ts # 黑名单(四期) +│ │ │ └── bets.ts # 竞猜(四期) +│ │ ├── components/ +│ │ │ ├── common/ # 通用组件 +│ │ │ ├── layout/ # 布局组件 +│ │ │ └── team/ # 组队相关组件 +│ │ │ ├── StatusToggle.vue # 状态切换 +│ │ │ ├── IdleMembersList.vue # 空闲成员 +│ │ │ ├── InviteButton.vue # 邀请按钮 +│ │ │ ├── InvitationCard.vue # 邀请卡片 +│ │ │ ├── TeamSessionPanel.vue # 临时小组面板 +│ │ │ └── WorkScheduleModal.vue # 工作时间设置 +│ │ ├── views/ +│ │ │ ├── Login.vue +│ │ │ ├── Register.vue +│ │ │ ├── GroupView.vue # 群组主页 +│ │ │ └── GamesLibrary.vue # 游戏库 +│ │ ├── stores/ +│ │ │ ├── user.ts # 用户状态 +│ │ │ ├── group.ts # 当前群组 +│ │ │ └── team.ts # 当前临时小组 +│ │ ├── types/ +│ │ │ ├── user.ts +│ │ │ ├── group.ts +│ │ │ ├── session.ts +│ │ │ └── game.ts +│ │ ├── router/ +│ │ ├── utils/ +│ │ └── App.vue +│ ├── .env # API_URL 等配置 +│ ├── package.json +│ └── vite.config.ts +│ +└── docs/ + ├── design.md # 本设计文档 + ├── api.md # API 文档 + └── migration.md # 迁移指南 +``` + +--- + +## 八、配置说明 + +### 8.1 后端环境变量 + +```env +# .env +PB_PORT=8090 # PocketBase 端口(可配置) +PB_DATA=./pb_data # 数据存储路径 +PB_MIGRATIONS=./pb_migrations +``` + +### 8.2 前端环境变量 + +```env +# .env +VITE_PB_URL=http://your-nas-ip:8090 +``` + +--- + +## 九、从原项目迁移 + +### 9.1 保留的设计 + +| 功能 | 原实现 | 新实现 | +|------|--------|--------| +| 用户认证 | JWT + NestJS | PocketBase 内置 | +| 权限管理 | 自定义 Guard | PocketBase API Rules | +| 实时通信 | - | PocketBase 实时订阅 | +| 前端框架 | Vue 3 + Element Plus | 保持不变 | + +### 9.2 主要改进 + +| 方面 | 原实现 | 新实现 | +|------|--------|--------| +| 匹配方式 | 自动匹配 | 握手式邀请 | +| 投票 | 复杂投票 | 快速响应(去/可能/不去) | +| 群组 | 单群组 | 多群组 + 临时小组 | +| 数据库 | MySQL | SQLite | +| 后端 | NestJS | PocketBase | + +--- + +## 十、附录 + +### 10.1 状态码对照表 + +| 类别 | 值 | 显示 | +|------|-----|------| +| 用户状态 | idle | 空闲 🟢 | +| | working | 工作中 🔴 | +| | in_team | 组队中 🔵 | +| | away | 离开 ⚫ | +| 临时小组 | recruiting | 招募中 | +| | playing | 游戏中 | +| | finished | 已结束 | +| | dissolved | 已解散 | +| 邀请 | pending | 等待响应 | +| | accepted | 已接受 | +| | rejected | 已拒绝 | + +### 10.2 积分规则(二期) + +| 行为 | 积分 | +|------|------| +| 成功组队 | +10 | +| 完成游戏 | +20 | +| 准时赴约 | +15 | +| 放鸽子 | -30 | + +### 10.3 荣誉徽章(二期) + +| 条件 | 徽章 | +|------|------| +| 组队100次 | 组队达人 | +| 连续10次赴约 | 全勤玩家 | +| 积分TOP1 | 群组之星 | + +--- + +**文档版本**: v1.0 +**更新日期**: 2026-04-17 diff --git a/docs/plans/2026-04-17-mvp-implementation.md b/docs/plans/2026-04-17-mvp-implementation.md new file mode 100644 index 0000000..11d0833 --- /dev/null +++ b/docs/plans/2026-04-17-mvp-implementation.md @@ -0,0 +1,3411 @@ +# Game Group V2 MVP Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a game team matching platform with handshake-style invitations, multi-group support, and temporary team sessions. + +**Architecture:** +- Backend: PocketBase (Go-based BaaS) with SQLite storage +- Frontend: Vue 3 + TypeScript + Element Plus + Tailwind CSS +- Real-time: PocketBase built-in WebSocket subscriptions + +**Tech Stack:** +- PocketBase 0.22+ +- Vue 3.4+, TypeScript 5.3+ +- Element Plus 2.5+, Tailwind CSS 3.4+ +- Vite 5.0+ + +--- + +## Phase 1: Backend Setup (PocketBase) + +### Task 1: Initialize PocketBase Project + +**Files:** +- Create: `backend/.env` +- Create: `backend/pb_migrations/1738717600_init.pb.js` +- Create: `backend/docker-compose.yml` +- Create: `backend/README.md` + +**Step 1: Create .env configuration** + +```bash +# backend/.env +PB_PORT=8090 +PB_DATA=./pb_data +PB_MIGRATIONS=./pb_migrations +``` + +**Step 2: Create initial migration** + +Create `backend/pb_migrations/1738717600_init.pb.js`: + +```javascript +/// https://github.com/pocketbase/pb_migrations +/// Created: users, groups, games, teamSessions, invitations collections + +// Users collection - extended auth collection +migrate((app) => { + const usersCollection = app.findCollectionByName("users") + if (usersCollection) { + // Extend existing users collection + usersCollection.schema.addField(new SchemaField({ + system: false, + id: "status_user", + name: "status", + type: "select", + required: false, + presentable: false, + unique: false, + options: { + maxSelect: 1, + values: ["idle", "working", "in_team", "away"] + } + })) + + usersCollection.schema.addField(new SchemaField({ + system: false, + id: "status_note", + name: "statusNote", + type: "text", + required: false, + presentable: false, + unique: false + })) + + usersCollection.schema.addField(new SchemaField({ + system: false, + id: "max_groups", + name: "maxGroups", + type: "number", + required: false, + presentable: false, + unique: false + })) + + usersCollection.schema.addField(new SchemaField({ + system: false, + id: "workdays_json", + name: "workdays", + type: "json", + required: false, + presentable: false, + unique: false + })) + + usersCollection.schema.addField(new SchemaField({ + system: false, + id: "work_start_time", + name: "workStartTime", + type: "text", + required: false, + presentable: false, + unique: false + })) + + usersCollection.schema.addField(new SchemaField({ + system: false, + id: "next_work_time", + name: "nextWorkTime", + type: "number", + required: false, + presentable: false, + unique: false + })) + + usersCollection.schema.addField(new SchemaField({ + system: false, + id: "points_num", + name: "points", + type: "number", + required: false, + presentable: false, + unique: false + })) + } +}, (app) => { + // Rollback + const usersCollection = app.findCollectionByName("users") + if (usersCollection) { + usersCollection.schema.removeField("status") + usersCollection.schema.removeField("statusNote") + usersCollection.schema.removeField("maxGroups") + usersCollection.schema.removeField("workdays") + usersCollection.schema.removeField("workStartTime") + usersCollection.schema.removeField("nextWorkTime") + usersCollection.schema.removeField("points") + } +}) + +// Groups collection +migrate((app) => { + app.findCollectionByNameOrCreate("groups", (collection) => { + collection.name = "groups" + collection.schema.addField(new SchemaField({ + system: false, + id: "name_group", + name: "name", + type: "text", + required: true, + presentable: true, + unique: false + })) + + collection.schema.addField(new SchemaField({ + system: false, + id: "description_text", + name: "description", + type: "text", + required: false, + presentable: false, + unique: false + })) + + collection.schema.addField(new RelationField({ + system: false, + id: "owner_user", + name: "owner", + type: "relation", + required: true, + presentable: false, + unique: false, + collectionId: "_pb_users_auth_" + })) + + collection.schema.addField(new RelationField({ + system: false, + id: "members_users", + name: "members", + type: "relation", + required: false, + presentable: false, + unique: false, + collectionId: "_pb_users_auth_", + maxSelect: 65535 + })) + + collection.schema.addField(new SchemaField({ + system: false, + id: "max_members", + name: "maxMembers", + type: "number", + required: false, + presentable: false, + unique: false + })) + }) +}, (app) => { + app.findCollectionByName("groups")?.delete() +}) + +// Games collection +migrate((app) => { + app.findCollectionByNameOrCreate("games", (collection) => { + collection.name = "games" + collection.schema.addField(new SchemaField({ + system: false, + id: "name_game", + name: "name", + type: "text", + required: true, + presentable: true, + unique: false + })) + + collection.schema.addField(new SchemaField({ + system: false, + id: "platform_select", + name: "platform", + type: "select", + required: false, + presentable: false, + unique: false, + options: { + maxSelect: 1, + values: ["PC", "PS5", "Xbox", "Switch", "Mobile"] + } + })) + + collection.schema.addField(new SchemaField({ + system: false, + id: "tags_json", + name: "tags", + type: "json", + required: false, + presentable: false, + unique: false + })) + + collection.schema.addField(new SchemaField({ + system: false, + id: "cover_url", + name: "cover", + type: "url", + required: false, + presentable: false, + unique: false + })) + + collection.schema.addField(new SchemaField({ + system: false, + id: "popular_count", + name: "popularCount", + type: "number", + required: false, + presentable: false, + unique: false + })) + }) +}, (app) => { + app.findCollectionByName("games")?.delete() +}) + +// Team Sessions collection +migrate((app) => { + app.findCollectionByNameOrCreate("teamSessions", (collection) => { + collection.name = "teamSessions" + collection.schema.addField(new SchemaField({ + system: false, + id: "name_session", + name: "name", + type: "text", + required: true, + presentable: true, + unique: false + })) + + collection.schema.addField(new RelationField({ + system: false, + id: "source_group", + name: "sourceGroup", + type: "relation", + required: true, + presentable: false, + unique: false, + collectionId: "groups" + })) + + collection.schema.addField(new SchemaField({ + system: false, + id: "game_name", + name: "gameName", + type: "text", + required: true, + presentable: false, + unique: false + })) + + collection.schema.addField(new RelationField({ + system: false, + id: "members_users", + name: "members", + type: "relation", + required: false, + presentable: false, + unique: false, + collectionId: "_pb_users_auth_", + maxSelect: 65535 + })) + + collection.schema.addField(new SchemaField({ + system: false, + id: "status_session", + name: "status", + type: "select", + required: false, + presentable: false, + unique: false, + options: { + maxSelect: 1, + values: ["recruiting", "playing", "finished", "dissolved"] + } + })) + }) +}, (app) => { + app.findCollectionByName("teamSessions")?.delete() +}) + +// Invitations collection +migrate((app) => { + app.findCollectionByNameOrCreate("invitations", (collection) => { + collection.name = "invitations" + collection.schema.addField(new RelationField({ + system: false, + id: "from_user", + name: "from", + type: "relation", + required: true, + presentable: false, + unique: false, + collectionId: "_pb_users_auth_" + })) + + collection.schema.addField(new RelationField({ + system: false, + id: "to_user", + name: "to", + type: "relation", + required: true, + presentable: false, + unique: false, + collectionId: "_pb_users_auth_" + })) + + collection.schema.addField(new RelationField({ + system: false, + id: "team_session", + name: "teamSession", + type: "relation", + required: true, + presentable: false, + unique: false, + collectionId: "teamSessions" + })) + + collection.schema.addField(new SchemaField({ + system: false, + id: "status_invite", + name: "status", + type: "select", + required: false, + presentable: false, + unique: false, + options: { + maxSelect: 1, + values: ["pending", "accepted", "rejected"] + } + })) + + collection.schema.addField(new SchemaField({ + system: false, + id: "reject_reason", + name: "rejectReason", + type: "text", + required: false, + presentable: false, + unique: false + })) + }) +}, (app) => { + app.findCollectionByName("invitations")?.delete() +}) +``` + +**Step 3: Create docker-compose.yml** + +```yaml +# backend/docker-compose.yml +version: '3.8' + +services: + pocketbase: + image: ghcr.io/muchobien/pocketbase:latest + container_name: gamegroup-pb + ports: + - "${PB_PORT:-8090}:8090" + volumes: + - ./pb_data:/pb_data + - ./pb_migrations:/pb_migrations + environment: + - GO_ENV=production + restart: unless-stopped +``` + +**Step 4: Create backend README** + +```markdown +# Game Group V2 Backend + +PocketBase BaaS 服务 + +## 启动 + +```bash +# 方式1: Docker +docker-compose up -d + +# 方式2: 直接运行 +./pocketbase serve --env .env +``` + +访问: http://localhost:8090/_/ +管理后台: http://localhost:8090/_/ (admin/admin) +``` + +**Step 5: Commit** + +```bash +cd backend +git init +git add . +git commit -m "feat: initialize PocketBase backend with migrations" +``` + +--- + +### Task 2: Configure PocketBase API Rules + +**Files:** +- Create: `backend/pb_hooks/main.go` + +**Step 1: Create API rules hook** + +Create `backend/pb_hooks/main.go`: + +```go +package main + +import ( + "log" + "strings" + + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tools/types" +) + +// Custom status enum +const ( + StatusIdle = "idle" + StatusWorking = "working" + StatusInTeam = "in_team" + StatusAway = "away" +) + +func init() { + // Hook called after Pocketbase initialization + pocketbase.OnBeforeServe().Add(func(e *core.ServeEvent) error { + // Register custom endpoints + e.Router.GET("/api/status", func(re *core.RequestEvent) error { + return e.JSON(200, map[string]string{"status": "ok"}) + }) + + return nil + }) +} + +// Middleware to check if user is group member +func isGroupMember(app *pocketbase.PocketBase, groupId string, userId string) bool { + group, err := app.Dao().FindCollectionByNameOrId("groups") + if err != nil { + return false + } + + record, err := app.Dao().FindRecordById(group, groupId) + if err != nil { + return false + } + + members := record.GetStringSlice("members") + for _, m := range members { + if m == userId { + return true + } + } + + return record.GetString("owner") == userId +} + +// Middleware to check if user is group owner +func isGroupOwner(app *pocketbase.PocketBase, groupId string, userId string) bool { + group, err := app.Dao().FindCollectionByNameOrId("groups") + if err != nil { + return false + } + + record, err := app.Dao().FindRecordById(group, groupId) + if err != nil { + return false + } + + return record.GetString("owner") == userId +} + +func main() { + app := pocketbase.New() + + // Groups API Rules + app.OnRecordBeforeCreateRequest("groups").Add(func(e *core.RecordEvent) error { + if e.HttpContext.IsAdmin() { + return nil + } + + authRecord, _ := e.HttpContext.AuthRecord() + if authRecord == nil { + return apis.NewForbiddenError("需要登录", nil) + } + + // Set owner to current user + e.Record.Set("owner", authRecord.Id) + + // Add owner to members + members := e.Record.GetStringSlice("members") + e.Record.Set("members", append(members, authRecord.Id)) + + return nil + }) + + app.OnRecordBeforeUpdateRequest("groups").Add(func(e *core.RecordEvent) error { + if e.HttpContext.IsAdmin() { + return nil + } + + authRecord, _ := e.HttpContext.AuthRecord() + if authRecord == nil { + return apis.NewForbiddenError("需要登录", nil) + } + + // Only owner can update + if e.Record.GetString("owner") != authRecord.Id { + return apis.NewForbiddenError("只有群主可以修改群组", nil) + } + + return nil + }) + + app.OnRecordBeforeDeleteRequest("groups").Add(func(e *core.RecordEvent) error { + if e.HttpContext.IsAdmin() { + return nil + } + + authRecord, _ := e.HttpContext.AuthRecord() + if authRecord == nil { + return apis.NewForbiddenError("需要登录", nil) + } + + if e.Record.GetString("owner") != authRecord.Id { + return apis.NewForbiddenError("只有群主可以解散群组", nil) + } + + return nil + }) + + // Team Sessions API Rules + app.OnRecordBeforeCreateRequest("teamSessions").Add(func(e *core.RecordEvent) error { + authRecord, _ := e.HttpContext.AuthRecord() + if authRecord == nil { + return apis.NewForbiddenError("需要登录", nil) + } + + // Check user is member of source group + groupId := e.Record.GetString("sourceGroup") + if !isGroupMember(app, groupId, authRecord.Id) { + return apis.NewForbiddenError("你不是该群组成员", nil) + } + + // Set initial status + e.Record.Set("status", "recruiting") + + return nil + }) + + // Invitations API Rules + app.OnRecordBeforeCreateRequest("invitations").Add(func(e *core.RecordEvent) error { + authRecord, _ := e.HttpContext.AuthRecord() + if authRecord == nil { + return apis.NewForbiddenError("需要登录", nil) + } + + // Set from to current user + e.Record.Set("from", authRecord.Id) + + // Check team session exists and user is member + teamSessionId := e.Record.GetString("teamSession") + teamSession, err := app.Dao().FindRecordById("teamSessions", teamSessionId) + if err != nil { + return apis.NewNotFoundError("临时小组不存在", nil) + } + + sourceGroup := teamSession.GetString("sourceGroup") + if !isGroupMember(app, sourceGroup, authRecord.Id) { + return apis.NewForbiddenError("你不是该群组成员", nil) + } + + // Set initial status + e.Record.Set("status", "pending") + + return nil + }) + + app.OnRecordAfterCreateRequest("invitations").Add(func(e *core.RecordEvent) error { + // Send real-time notification to recipient + app.Subscriptions().Broadcast("", map[string]any{ + "type": "invitation", + "invitation": e.Record, + }, []string{e.Record.GetString("to")}) + return nil + }) + + if err := app.Start(); err != nil { + log.Fatal(err) + } +} +``` + +**Step 2: Update go.mod if needed** + +```bash +cd backend/pb_hooks +go mod init gamegroup-hooks +go get github.com/pocketbase/pocketbase +``` + +**Step 3: Commit** + +```bash +cd backend +git add pb_hooks/ +git commit -m "feat: add API rules and hooks" +``` + +--- + +## Phase 2: Frontend Setup + +### Task 3: Initialize Vue 3 Project + +**Files:** +- Create: `frontend/` +- Create: `frontend/package.json` +- Create: `frontend/vite.config.ts` +- Create: `frontend/tsconfig.json` +- Create: `frontend/tailwind.config.js` +- Create: `frontend/index.html` +- Create: `frontend/.env` + +**Step 1: Create package.json** + +```json +{ + "name": "gamegroup-v2-frontend", + "version": "2.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.21", + "vue-router": "^4.3.0", + "pinia": "^2.1.7", + "element-plus": "^2.6.3", + "@element-plus/icons-vue": "^2.3.1", + "pocketbase": "^0.21.1" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "typescript": "^5.4.5", + "vue-tsc": "^2.0.11", + "vite": "^5.2.8", + "tailwindcss": "^3.4.3", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38" + } +} +``` + +**Step 2: Create vite.config.ts** + +```typescript +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src') + } + }, + server: { + port: 5173, + proxy: { + '/api': { + target: process.env.VITE_PB_URL || 'http://localhost:8090', + changeOrigin: true + } + } + } +}) +``` + +**Step 3: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} +``` + +**Step 4: Create tsconfig.node.json** + +```json +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} +``` + +**Step 5: Create tailwind.config.js** + +```javascript +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{vue,js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + primary: { + 50: '#f0f9ff', + 100: '#e0f2fe', + 200: '#bae6fd', + 300: '#7dd3fc', + 400: '#38bdf8', + 500: '#0ea5e9', + 600: '#0284c7', + 700: '#0369a1', + 800: '#075985', + 900: '#0c4a6e', + } + } + }, + }, + plugins: [], +} +``` + +**Step 6: Create postcss.config.js** + +```javascript +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} +``` + +**Step 7: Create index.html** + +```html + + + + + + Game Group V2 + + +
+ + + +``` + +**Step 8: Create .env** + +```env +VITE_PB_URL=http://localhost:8090 +``` + +**Step 9: Create src directory structure** + +```bash +mkdir -p frontend/src/{api,components/{common,layout,team},views,stores,types,router,utils,assets} +``` + +**Step 10: Commit** + +```bash +cd frontend +git init +git add . +git commit -m "feat: initialize Vue 3 frontend with Vite, Tailwind, Element Plus" +``` + +--- + +### Task 4: Create PocketBase Client + +**Files:** +- Create: `frontend/src/api/pocketbase.ts` +- Create: `frontend/src/types/index.ts` + +**Step 1: Create PocketBase client** + +```typescript +// src/api/pocketbase.ts +import PocketBase from 'pocketbase' + +const pbUrl = import.meta.env.VITE_PB_URL || 'http://localhost:8090' + +export const pb = new PocketBase(pbUrl) + +// 认证状态持久化 +pb.authStore.loadFromCookie(document.cookie) + +// 保存认证状态到 cookie +pb.authStore.onChange(() => { + document.cookie = pb.authStore.exportToCookie({ httpOnly: false }) +}) + +// 获取当前用户 +export function getCurrentUser() { + return pb.authStore.model +} + +// 检查是否已登录 +export function isAuthenticated(): boolean { + return pb.authStore.isValid +} + +// 登出 +export function logout() { + pb.authStore.clear() + window.location.href = '/login' +} + +export default pb +``` + +**Step 2: Create types** + +```typescript +// src/types/index.ts + +// 用户状态 +export type UserStatus = 'idle' | 'working' | 'in_team' | 'away' + +// 用户状态中文映射 +export const UserStatusMap: Record = { + idle: '空闲', + working: '工作中', + in_team: '组队中', + away: '离开' +} + +// 用户状态图标 +export const UserStatusIcon: Record = { + idle: '🟢', + working: '🔴', + in_team: '🔵', + away: '⚫' +} + +// 临时小组状态 +export type TeamStatus = 'recruiting' | 'playing' | 'finished' | 'dissolved' + +export const TeamStatusMap: Record = { + recruiting: '招募中', + playing: '游戏中', + finished: '已结束', + dissolved: '已解散' +} + +// 邀请状态 +export type InviteStatus = 'pending' | 'accepted' | 'rejected' + +export const InviteStatusMap: Record = { + pending: '等待响应', + accepted: '已接受', + rejected: '已拒绝' +} + +// 游戏平台 +export type GamePlatform = 'PC' | 'PS5' | 'Xbox' | 'Switch' | 'Mobile' + +// 用户 +export interface User { + id: string + username: string + email: string + avatar?: string + status: UserStatus + statusNote?: string + maxGroups: number + workdays: number[] + workStartTime: string + nextWorkTime?: number + points: number + created: string + updated: string +} + +// 群组 +export interface Group { + id: string + name: string + description?: string + owner: string + members: string[] + maxMembers: number + created: string + updated: string + expand?: { + owner?: User + members?: User[] + } +} + +// 临时小组 +export interface TeamSession { + id: string + name: string + sourceGroup: string + gameName: string + members: string[] + status: TeamStatus + created: string + updated: string + expand?: { + members?: User[] + sourceGroup?: Group + } +} + +// 邀请 +export interface Invitation { + id: string + from: string + to: string + teamSession: string + status: InviteStatus + rejectReason?: string + created: string + updated: string + expand?: { + from?: User + to?: User + teamSession?: TeamSession + } +} + +// 游戏 +export interface Game { + id: string + name: string + platform?: GamePlatform + tags?: string[] + cover?: string + popularCount: number + created: string + updated: string +} + +// 工作时间设定 +export interface WorkSchedule { + workdays: number[] + workStartTime: string +} +``` + +**Step 3: Commit** + +```bash +git add src/api/pocketbase.ts src/types/index.ts +git commit -m "feat: add PocketBase client and TypeScript types" +``` + +--- + +### Task 5: Create API Services + +**Files:** +- Create: `frontend/src/api/users.ts` +- Create: `frontend/src/api/groups.ts` +- Create: `frontend/src/api/sessions.ts` +- Create: `frontend/src/api/invitations.ts` +- Create: `frontend/src/api/games.ts` + +**Step 1: Create users API** + +```typescript +// src/api/users.ts +import { pb } from './pocketbase' +import type { User, UserStatus, WorkSchedule } from '@/types' + +// 登录 +export async function login(email: string, password: string) { + return await pb.collection('users').authWithPassword(email, password) +} + +// 注册 +export async function register(email: string, password: string, passwordConfirm: string, username: string) { + return await pb.collection('users').create({ + email, + password, + passwordConfirm, + username, + status: 'idle', + maxGroups: 5, + workdays: [1, 2, 3, 4, 5], + workStartTime: '09:00', + points: 0 + }) +} + +// 获取当前用户 +export async function getCurrentUserFull(): Promise { + const userId = pb.authStore.model?.id + if (!userId) throw new Error('未登录') + return await pb.collection('users').getOne(userId) +} + +// 更新用户状态 +export async function updateUserStatus(status: UserStatus, statusNote?: string) { + const userId = pb.authStore.model?.id + if (!userId) throw new Error('未登录') + return await pb.collection('users').update(userId, { status, statusNote }) +} + +// 更新工作时间设定 +export async function updateWorkSchedule(schedule: WorkSchedule) { + const userId = pb.authStore.model?.id + if (!userId) throw new Error('未登录') + + // 计算下一个工作时间 + const nextWorkTime = calculateNextWorkTime(schedule.workdays, schedule.workStartTime) + + return await pb.collection('users').update(userId, { + workdays: schedule.workdays, + workStartTime: schedule.workStartTime, + nextWorkTime + }) +} + +// 计算下一个工作时间戳 +function calculateNextWorkTime(workdays: number[], startTime: string): number { + const now = new Date() + const [hours, minutes] = startTime.split(':').map(Number) + + // 找到下一个工作日 + let nextDate = new Date(now) + let attempts = 0 + + while (attempts < 7) { + const dayOfWeek = nextDate.getDay() || 7 // 转换为 1-7 (周一到周日) + + if (workdays.includes(dayOfWeek)) { + const workTime = new Date(nextDate) + workTime.setHours(hours, minutes, 0, 0) + + // 如果今天的工作时间还没过,就是今天 + if (workTime > now) { + return Math.floor(workTime.getTime() / 1000) + } + } + + // 加一天 + nextDate.setDate(nextDate.getDate() + 1) + nextDate.setHours(0, 0, 0, 0) + attempts++ + } + + return 0 +} + +// 获取用户详情 +export async function getUserById(id: string): Promise { + return await pb.collection('users').getOne(id) +} + +// 搜索用户 +export async function searchUsers(query: string) { + return await pb.collection('users').getList(1, 20, { + filter: `username ~ "${query}"`, + fields: 'id,username,avatar,status' + }) +} + +// 订阅用户状态变更 +export function subscribeUserStatusChanges(callback: (data: any) => void) { + return pb.collection('users').subscribe('*', callback) +} +``` + +**Step 2: Create groups API** + +```typescript +// src/api/groups.ts +import { pb } from './pocketbase' +import type { Group } from '@/types' + +// 获取我的群组 +export async function getMyGroups() { + const userId = pb.authStore.model?.id + if (!userId) throw new Error('未登录') + + return await pb.collection('groups').getList(1, 50, { + filter: `owner = "${userId}" || members ~ "${userId}"` + }) +} + +// 创建群组 +export async function createGroup(name: string, description?: string, maxMembers = 50) { + const userId = pb.authStore.model?.id + if (!userId) throw new Error('未登录') + + return await pb.collection('groups').create({ + name, + description, + maxMembers + }) +} + +// 获取群组详情 +export async function getGroupById(id: string): Promise { + return await pb.collection('groups').getOne(id, { + expand: 'owner,members' + }) +} + +// 更新群组 +export async function updateGroup(id: string, data: Partial) { + return await pb.collection('groups').update(id, data) +} + +// 解散群组 +export async function deleteGroup(id: string) { + return await pb.collection('groups').delete(id) +} + +// 加入群组 (需要群组代码或其他机制,这里简化) +export async function joinGroup(groupId: string) { + const group = await pb.collection('groups').getOne(groupId) + const userId = pb.authStore.model?.id + if (!userId) throw new Error('未登录') + + const members = group.members || [] + if (members.includes(userId)) { + throw new Error('你已经是该群组成员') + } + + if (members.length >= group.maxMembers) { + throw new Error('群组已满') + } + + return await pb.collection('groups').update(groupId, { + members: [...members, userId] + }) +} + +// 退出群组 +export async function leaveGroup(groupId: string) { + const group = await pb.collection('groups').getOne(groupId) + const userId = pb.authStore.model?.id + if (!userId) throw new Error('未登录') + + if (group.owner === userId) { + throw new Error('群主不能退出群组,请先转让群主或解散群组') + } + + const members = (group.members || []).filter((m: string) => m !== userId) + return await pb.collection('groups').update(groupId, { members }) +} + +// 获取群组内空闲成员 +export async function getIdleGroupMembers(groupId: string) { + const group = await pb.collection('groups').getOne(groupId, { + expand: 'members' + }) + + const members = group.expand?.members || [] + return members.filter((m: any) => m.status === 'idle') +} +``` + +**Step 3: Create sessions API** + +```typescript +// src/api/sessions.ts +import { pb } from './pocketbase' +import type { TeamSession, TeamStatus } from '@/types' + +// 创建临时小组 +export async function createTeamSession(data: { + name: string + sourceGroup: string + gameName: string +}) { + return await pb.collection('teamSessions').create({ + ...data, + status: 'recruiting', + members: [pb.authStore.model?.id] + }) +} + +// 获取群组的活跃临时小组 +export async function getActiveTeamSessions(groupId: string) { + return await pb.collection('teamSessions').getList(1, 10, { + filter: `sourceGroup = "${groupId}" && status != "dissolved"`, + sort: '-created' + }) +} + +// 获取我的临时小组 +export async function getMyTeamSessions() { + const userId = pb.authStore.model?.id + if (!userId) throw new Error('未登录') + + return await pb.collection('teamSessions').getList(1, 10, { + filter: `members ~ "${userId}" && status = "playing"`, + expand: 'members,sourceGroup' + }) +} + +// 更新临时小组状态 +export async function updateTeamStatus(id: string, status: TeamStatus) { + const updateData: any = { status } + + if (status === 'dissolved') { + updateData.dissolvedAt = new Date().toISOString() + } + + return await pb.collection('teamSessions').update(id, updateData) +} + +// 添加成员到临时小组 +export async function addMemberToTeam(teamId: string, userId: string) { + const team = await pb.collection('teamSessions').getOne(teamId) + const members = team.members || [] + if (!members.includes(userId)) { + members.push(userId) + return await pb.collection('teamSessions').update(teamId, { members }) + } + return team +} + +// 解散临时小组 +export async function dissolveTeamSession(id: string) { + return await updateTeamStatus(id, 'dissolved') +} + +// 订阅临时小组变更 +export function subscribeTeamChanges(callback: (data: any) => void) { + return pb.collection('teamSessions').subscribe('*', callback) +} +``` + +**Step 4: Create invitations API** + +```typescript +// src/api/invitations.ts +import { pb } from './pocketbase' +import type { Invitation, InviteStatus } from '@/types' + +// 发送邀请 +export async function sendInvitation(toUserId: string, teamSessionId: string) { + return await pb.collection('invitations').create({ + to: toUserId, + teamSession: teamSessionId + }) +} + +// 获取我的待处理邀请 +export async function getMyPendingInvitations() { + const userId = pb.authStore.model?.id + if (!userId) return { items: [], totalItems: 0 } + + return await pb.collection('invitations').getList(1, 20, { + filter: `to = "${userId}" && status = "pending"`, + expand: 'from,teamSession,teamSession.sourceGroup' + }) +} + +// 获取我发送的邀请 +export async function getMySentInvitations() { + const userId = pb.authStore.model?.id + if (!userId) return { items: [], totalItems: 0 } + + return await pb.collection('invitations').getList(1, 20, { + filter: `from = "${userId}"`, + expand: 'to,teamSession' + }) +} + +// 响应邀请 +export async function respondToInvitation( + invitationId: string, + status: InviteStatus, + rejectReason?: string +) { + const updateData: any = { status } + if (rejectReason) { + updateData.rejectReason = rejectReason + } + + return await pb.collection('invitations').update(invitationId, updateData) +} + +// 接受邀请 +export async function acceptInvitation(invitationId: string) { + const invitation = await pb.collection('invitations').getOne(invitationId, { + expand: 'teamSession' + }) + + // 更新邀请状态 + await respondToInvitation(invitationId, 'accepted') + + // 添加到临时小组 + const teamSession = invitation.expand?.teamSession + if (teamSession) { + const { addMemberToTeam } = await import('./sessions') + const { updateUserStatus } = await import('./users') + + // 添加到小组 + await addMemberToTeam(teamSession.id, pb.authStore.model?.id) + + // 更新用户状态 + await updateUserStatus('in_team') + + // 如果所有人都已响应,更新小组状态 + // (需要额外逻辑检查) + } + + return invitation +} + +// 拒绝邀请 +export async function rejectInvitation(invitationId: string, reason?: string) { + return await respondToInvitation(invitationId, 'rejected', reason) +} + +// 订阅邀请变更 +export function subscribeInvitations(callback: (data: any) => void) { + return pb.collection('invitations').subscribe('*', callback) +} +``` + +**Step 5: Create games API** + +```typescript +// src/api/games.ts +import { pb } from './pocketbase' +import type { Game, GamePlatform } from '@/types' + +// 获取游戏列表 +export async function getGames(page = 1, limit = 20) { + return await pb.collection('games').getList(page, limit, { + sort: '-popularCount' + }) +} + +// 搜索游戏 +export async function searchGames(query: string) { + return await pb.collection('games').getList(1, 20, { + filter: `name ~ "${query}"`, + sort: '-popularCount' + }) +} + +// 按平台筛选游戏 +export async function getGamesByPlatform(platform: GamePlatform) { + return await pb.collection('games').getList(1, 50, { + filter: `platform = "${platform}"`, + sort: '-popularCount' + }) +} + +// 获取热门游戏 +export async function getPopularGames(limit = 10) { + return await pb.collection('games').getList(1, limit, { + sort: '-popularCount' + }) +} + +// 按标签筛选游戏 +export async function getGamesByTag(tag: string) { + return await pb.collection('games').getList(1, 50, { + filter: `tags ~ "${tag}"`, + sort: '-popularCount' + }) +} + +// 获取游戏详情 +export async function getGameById(id: string): Promise { + return await pb.collection('games').getOne(id) +} + +// 创建游戏 (管理员功能) +export async function createGame(game: Partial) { + return await pb.collection('games').create(game) +} + +// 增加游戏热度 +export async function incrementGamePopularity(gameId: string) { + const game = await pb.collection('games').getOne(gameId) + return await pb.collection('games').update(gameId, { + popularCount: (game.popularCount || 0) + 1 + }) +} +``` + +**Step 6: Commit** + +```bash +git add src/api/ +git commit -m "feat: add all API services (users, groups, sessions, invitations, games)" +``` + +--- + +### Task 6: Create Pinia Stores + +**Files:** +- Create: `frontend/src/stores/user.ts` +- Create: `frontend/src/stores/group.ts` +- Create: `frontend/src/stores/team.ts` +- Create: `frontend/src/stores/notification.ts` + +**Step 1: Create user store** + +```typescript +// src/stores/user.ts +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { User, UserStatus, WorkSchedule } from '@/types' +import { getCurrentUser, updateUserStatus, updateWorkSchedule, logout as apiLogout } from '@/api/users' +import { pb } from '@/api/pocketbase' + +export const useUserStore = defineStore('user', () => { + const user = ref(null) + const loading = ref(false) + + const isAuthenticated = computed(() => pb.authStore.isValid) + const userId = computed(() => pb.authStore.model?.id) + const status = computed(() => user.value?.status || 'idle') + + // 初始化用户信息 + async function init() { + if (isAuthenticated.value) { + loading.value = true + try { + user.value = await getCurrentUser() + } catch (error) { + console.error('获取用户信息失败:', error) + } finally { + loading.value = false + } + } + } + + // 设置状态 + async function setStatus(newStatus: UserStatus, note?: string) { + loading.value = true + try { + await updateUserStatus(newStatus, note) + if (user.value) { + user.value.status = newStatus + user.value.statusNote = note + } + } catch (error) { + console.error('更新状态失败:', error) + throw error + } finally { + loading.value = false + } + } + + // 设置工作时间 + async function setWorkSchedule(schedule: WorkSchedule) { + loading.value = true + try { + await updateWorkSchedule(schedule) + if (user.value) { + user.value.workdays = schedule.workdays + user.value.workStartTime = schedule.workStartTime + } + } catch (error) { + console.error('更新工作时间失败:', error) + throw error + } finally { + loading.value = false + } + } + + // 登出 + function logout() { + user.value = null + apiLogout() + } + + return { + user, + loading, + isAuthenticated, + userId, + status, + init, + setStatus, + setWorkSchedule, + logout + } +}) +``` + +**Step 2: Create group store** + +```typescript +// src/stores/group.ts +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { Group } from '@/types' +import { getMyGroups, getGroupById } from '@/api/groups' + +export const useGroupStore = defineStore('group', () => { + const myGroups = ref([]) + const currentGroup = ref(null) + const loading = ref(false) + + const currentGroupId = computed(() => currentGroup.value?.id) + + // 加载我的群组 + async function loadMyGroups() { + loading.value = true + try { + const result = await getMyGroups() + myGroups.value = result.items as Group[] + } catch (error) { + console.error('加载群组失败:', error) + } finally { + loading.value = false + } + } + + // 设置当前群组 + async function setCurrentGroup(groupId: string) { + loading.value = true + try { + currentGroup.value = await getGroupById(groupId) + } catch (error) { + console.error('加载群组详情失败:', error) + } finally { + loading.value = false + } + } + + // 清除当前群组 + function clearCurrentGroup() { + currentGroup.value = null + } + + return { + myGroups, + currentGroup, + currentGroupId, + loading, + loadMyGroups, + setCurrentGroup, + clearCurrentGroup + } +}) +``` + +**Step 3: Create team store** + +```typescript +// src/stores/team.ts +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { TeamSession } from '@/types' +import { getMyTeamSessions, dissolveTeamSession } from '@/api/sessions' + +export const useTeamStore = defineStore('team', () => { + const myActiveTeam = ref(null) + const loading = ref(false) + + const hasActiveTeam = computed(() => !!myActiveTeam.value) + const teamStatus = computed(() => myActiveTeam.value?.status) + const teamMembers = computed(() => myActiveTeam.value?.members || []) + + // 加载我的活跃临时小组 + async function loadMyActiveTeam() { + loading.value = true + try { + const result = await getMyTeamSessions() + myActiveTeam.value = result.items[0] as TeamSession || null + } catch (error) { + console.error('加载临时小组失败:', error) + } finally { + loading.value = false + } + } + + // 设置当前临时小组 + function setActiveTeam(team: TeamSession) { + myActiveTeam.value = team + } + + // 清除当前临时小组 + function clearActiveTeam() { + myActiveTeam.value = null + } + + // 解散临时小组 + async function dissolveTeam() { + if (!myActiveTeam.value) return + + loading.value = true + try { + await dissolveTeamSession(myActiveTeam.value.id) + clearActiveTeam() + } catch (error) { + console.error('解散临时小组失败:', error) + throw error + } finally { + loading.value = false + } + } + + return { + myActiveTeam, + hasActiveTeam, + teamStatus, + teamMembers, + loading, + loadMyActiveTeam, + setActiveTeam, + clearActiveTeam, + dissolveTeam + } +}) +``` + +**Step 4: Create notification store** + +```typescript +// src/stores/notification.ts +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { Invitation } from '@/types' +import { subscribeInvitations } from '@/api/invitations' +import { subscribeUserStatusChanges } from '@/api/users' + +export interface Notification { + id: string + type: 'invitation' | 'status' | 'system' + title: string + message: string + data?: any + read: boolean + createdAt: Date +} + +export const useNotificationStore = defineStore('notification', () => { + const notifications = ref([]) + const pendingInvitations = ref([]) + + // 添加通知 + function addNotification(notification: Omit) { + const id = Date.now().toString() + notifications.value.unshift({ + ...notification, + id, + read: false, + createdAt: new Date() + }) + } + + // 标记通知已读 + function markAsRead(id: string) { + const notification = notifications.value.find(n => n.id === id) + if (notification) { + notification.read = true + } + } + + // 清除所有通知 + function clearAll() { + notifications.value = [] + pendingInvitations.value = [] + } + + // 初始化实时订阅 + function initSubscriptions() { + // 订阅邀请 + subscribeInvitations((data) => { + if (data.action === 'create' && data.record?.to === localStorage.getItem('userId')) { + pendingInvitations.value.push(data.record as Invitation) + addNotification({ + type: 'invitation', + title: '新邀请', + message: `${data.record.expand?.from?.username} 邀请你加入游戏`, + data: data.record + }) + } + }) + + // 订阅用户状态变更 + subscribeUserStatusChanges((data) => { + if (data.action === 'update') { + addNotification({ + type: 'status', + title: '状态更新', + message: `${data.record.username} 状态变更为 ${data.record.status}`, + data: data.record + }) + } + }) + } + + return { + notifications, + pendingInvitations, + addNotification, + markAsRead, + clearAll, + initSubscriptions + } +}) +``` + +**Step 5: Commit** + +```bash +git add src/stores/ +git commit -m "feat: add Pinia stores (user, group, team, notification)" +``` + +--- + +### Task 7: Create Router + +**Files:** +- Create: `frontend/src/router/index.ts` + +**Step 1: Create router** + +```typescript +// src/router/index.ts +import { createRouter, createWebHistory } from 'vue-router' +import type { RouteRecordRaw } from 'vue-router' +import { isAuthenticated } from '@/api/pocketbase' + +const routes: RouteRecordRaw[] = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { requiresAuth: false } + }, + { + path: '/register', + name: 'Register', + component: () => import('@/views/Register.vue'), + meta: { requiresAuth: false } + }, + { + path: '/', + component: () => import('@/components/layout/MainLayout.vue'), + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'Home', + component: () => import('@/views/GroupView.vue') + }, + { + path: 'groups/:id', + name: 'GroupDetail', + component: () => import('@/views/GroupView.vue') + }, + { + path: 'games', + name: 'GamesLibrary', + component: () => import('@/views/GamesLibrary.vue') + } + ] + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 路由守卫 +router.beforeEach((to, from, next) => { + const requiresAuth = to.meta.requiresAuth !== false + + if (requiresAuth && !isAuthenticated()) { + next({ name: 'Login', query: { redirect: to.fullPath } }) + } else if (!requiresAuth && isAuthenticated() && (to.name === 'Login' || to.name === 'Register')) { + next({ name: 'Home' }) + } else { + next() + } +}) + +export default router +``` + +**Step 2: Commit** + +```bash +git add src/router/ +git commit -m "feat: add Vue Router with auth guard" +``` + +--- + +### Task 8: Create Layout Components + +**Files:** +- Create: `frontend/src/components/layout/MainLayout.vue` +- Create: `frontend/src/components/layout/AppSidebar.vue` +- Create: `frontend/src/components/layout/AppHeader.vue` + +**Step 1: Create MainLayout** + +```vue + + + + +``` + +**Step 2: Create AppSidebar** + +```vue + + + + +``` + +**Step 3: Create AppHeader** + +```vue + + + + +``` + +**Step 4: Commit** + +```bash +git add src/components/layout/ +git commit -m "feat: add layout components (MainLayout, AppSidebar, AppHeader)" +``` + +--- + +### Task 9: Create Team Components + +**Files:** +- Create: `frontend/src/components/team/StatusToggle.vue` +- Create: `frontend/src/components/team/IdleMembersList.vue` +- Create: `frontend/src/components/team/InviteButton.vue` +- Create: `frontend/src/components/team/InvitationCard.vue` +- Create: `frontend/src/components/team/TeamSessionPanel.vue` +- Create: `frontend/src/components/team/WorkScheduleModal.vue` + +**Step 1: Create StatusToggle** + +```vue + + + + +``` + +**Step 2: Create IdleMembersList** + +```vue + + + + +``` + +**Step 3: Create InviteButton** + +```vue + + + + +``` + +**Step 4: Create InvitationCard** + +```vue + + + + +``` + +**Step 5: Create TeamSessionPanel** + +```vue + + + + +``` + +**Step 6: Create WorkScheduleModal** + +```vue + + + + +``` + +**Step 7: Commit** + +```bash +git add src/components/team/ +git commit -m "feat: add team components (StatusToggle, IdleMembersList, InviteButton, etc.)" +``` + +--- + +### Task 10: Create Page Components + +**Files:** +- Create: `frontend/src/views/Login.vue` +- Create: `frontend/src/views/Register.vue` +- Create: `frontend/src/views/GroupView.vue` +- Create: `frontend/src/views/GamesLibrary.vue` +- Create: `frontend/src/components/common/NotificationPanel.vue` +- Create: `frontend/src/components/team/GameSelectDialog.vue` + +**Step 1: Create Login page** + +```vue + + + + +``` + +**Step 2: Create Register page** + +```vue + + + + +``` + +**Step 3: Create GroupView page** + +```vue + + + + +``` + +**Step 4: Create GamesLibrary page** + +```vue + + + + +``` + +**Step 5: Create NotificationPanel component** + +```vue + + + + +``` + +**Step 6: Create GameSelectDialog component** + +```vue + + + + +``` + +**Step 7: Update main.ts** + +```typescript +// src/main.ts +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import './assets/main.css' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +const pinia = createPinia() + +app.use(pinia) +app.use(router) +app.use(ElementPlus) + +app.mount('#app') +``` + +**Step 8: Create App.vue** + +```vue + + + + +``` + +**Step 9: Create main.css** + +```css +/* src/assets/main.css */ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom styles */ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; +} +``` + +**Step 10: Commit** + +```bash +git add src/views/ src/components/common/ src/components/team/GameSelectDialog.vue src/main.ts src/App.vue src/assets/ +git commit -m "feat: add page components (Login, Register, GroupView, GamesLibrary)" +``` + +--- + +### Task 11: Create Entry Point + +**Files:** +- Update: `frontend/index.html` + +**Step 1: Update index.html** + +```html + + + + + + Game Group V2 + + + +
+ + + +``` + +**Step 2: Commit** + +```bash +git add index.html +git commit -m "feat: update entry point" +``` + +--- + +## Phase 3: Testing + +### Task 12: Test User Flow + +**Step 1: Start PocketBase** + +```bash +cd backend +./pocketbase serve +``` + +**Step 2: Start Frontend** + +```bash +cd frontend +npm install +npm run dev +``` + +**Step 3: Test Flow** + +1. 访问 http://localhost:5173 +2. 注册新用户 +3. 登录 +4. 创建群组 +5. 切换状态为"空闲" +6. 注册另一个用户,登录 +7. 加入同一个群组 +8. 第一个用户邀请第二个用户 +9. 第二个用户接受邀请 +10. 查看临时小组创建成功 +11. 点击"游戏结束"解散小组 + +--- + +## Deployment + +### Task 13: Deploy to NAS + +**Step 1: Build frontend** + +```bash +cd frontend +npm run build +``` + +**Step 2: Copy to NAS** + +```bash +# 复制前端构建文件到 NAS web 目录 +scp -r frontend/dist/* user@nas:/path/to/web/ + +# 复制 PocketBase 到 NAS +scp -r backend/* user@nas:/path/to/pocketbase/ +``` + +**Step 3: Update .env on NAS** + +```env +PB_PORT=8090 +``` + +**Step 4: Start service** + +```bash +ssh user@nas +cd /path/to/pocketbase +docker-compose up -d +``` + +--- + +## Summary + +This implementation plan covers: + +**Phase 1: Backend Setup** +- PocketBase initialization +- Database migrations +- API rules and hooks + +**Phase 2: Frontend Setup** +- Vue 3 + Vite project +- API services +- Pinia stores +- Router with auth guard + +**Phase 3: Components** +- Layout components +- Team components +- Page components + +**Phase 4: Testing & Deployment** + +Total tasks: 13 + +--- + +**Ready to implement! Use superpowers:executing-plans or superpowers:subagent-driven-development to execute.**