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

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

3412 lines
81 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
/// <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**
```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
<!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**
```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<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**
```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<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**
```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<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**
```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<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**
```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<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**
```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<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**
```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<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**
```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<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**
```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
<!-- 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**
```vue
<!-- 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**
```vue
<!-- 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**
```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
<!-- 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**
```vue
<!-- 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**
```vue
<!-- 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**
```vue
<!-- 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**
```vue
<!-- 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**
```vue
<!-- 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**
```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
<!-- 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**
```vue
<!-- 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**
```vue
<!-- 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**
```vue
<!-- 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**
```vue
<!-- 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**
```vue
<!-- 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**
```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
<!-- src/App.vue -->
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
```
**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
<!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**
```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.**