4d1a53fc69
- 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>
3412 lines
81 KiB
Markdown
3412 lines
81 KiB
Markdown
# 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.**
|