diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a01a963 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Dependencies +node_modules/ +package-lock.json + +# Build outputs +dist/ +build/ + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# PocketBase +backend/pb_data/ +backend/pb_migrations.bak/ + +# Temporary files +*.tmp +.cache/ \ No newline at end of file diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..62fc77d --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,60 @@ +# Game Group V2 部署指南 + +## 端口分配 + +| 服务 | 端口 | +|------|------| +| PocketBase | 8711 | +| Dev 前端 | 7033 | +| UAT 前端 | 7034 | + +## 部署脚本 + +```bash +# 部署 PocketBase 后端 +./deploy-backend.sh + +# 部署 Dev 前端 +./deploy-dev.sh + +# 部署 UAT 前端 +./deploy-uat.sh + +# 停止所有服务 +./stop-all.sh +``` + +## 访问地址 + +| 服务 | 地址 | +|------|------| +| PocketBase API | http://192.168.1.14:8711/api/ | +| PocketBase 管理面板 | http://192.168.1.14:8711/_/ | +| Dev 环境 | http://192.168.1.14:7033 | +| UAT 环境 | http://192.168.1.14:7034 | + +## 手动操作 + +```bash +# 只启动后端 +docker compose -f docker-compose.backend.yml up -d + +# 只启动 Dev +docker compose -f docker-compose.dev.yml up -d --build + +# 只启动 UAT +docker compose -f docker-compose.uat.yml up -d --build +``` + +## 查看日志 + +```bash +# 后端日志 +docker logs -f gamegroup-pb + +# Dev 日志 +docker logs -f gamegroup-frontend-dev + +# UAT 日志 +docker logs -f gamegroup-frontend-uat +``` diff --git a/backend/.env b/backend/.env index 7af3b7f..dcb6aa6 100644 --- a/backend/.env +++ b/backend/.env @@ -1,3 +1,5 @@ PB_PORT=8090 PB_DATA=./pb_data PB_MIGRATIONS=./pb_migrations +PB_UID=1000 +PB_GID=1000 diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index b2dfe80..de01039 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -2,10 +2,11 @@ version: '3.8' services: pocketbase: - image: ghcr.io/muchobien/pocketbase:v0.22.4 + image: ghcr.io/muchobien/pocketbase:0.22.4 container_name: gamegroup-pb + user: "${PB_UID:-1000}:${PB_GID:-1000}" ports: - - "${PB_PORT:-8090}:8090" + - "${PB_PORT:-8711}:8090" volumes: - ./pb_data:/pb_data - ./pb_migrations:/pb_migrations diff --git a/backend/pb_migrations/1738717600_init.pb.js b/backend/pb_migrations/1738717600_init.pb.js deleted file mode 100644 index 441e6bb..0000000 --- a/backend/pb_migrations/1738717600_init.pb.js +++ /dev/null @@ -1,781 +0,0 @@ -/// - -migrate((app) => { - // Users collection - app.deleteCollection("users"); - app.createCollection("users", { - id: "users", - name: "users", - type: "auth", - fields: [ - { - id: "new_aQr3td8dJl", - name: "username", - type: "text", - system: false, - required: true, - unique: true, - options: { - min: 3, - max: 20, - pattern: "^[a-zA-Z0-9_]+$", - } - }, - { - id: "new_Jx4zWq9kLm", - name: "displayName", - type: "text", - system: false, - required: true, - unique: false, - options: { - min: null, - max: 50, - pattern: "", - } - }, - { - id: "new_Xp7vR2sT4u", - name: "avatar", - type: "url", - system: false, - required: false, - unique: false, - options: { - exceptDomains: null, - onlyDomains: null, - } - }, - { - id: "new_Nh8gD3fF6g", - name: "bio", - type: "text", - system: false, - required: false, - unique: false, - options: { - min: null, - max: 500, - pattern: "", - } - }, - { - id: "new_Vc5bH8kJ7o", - name: "region", - type: "select", - system: false, - required: true, - unique: false, - options: { - maxSelect: 1, - values: [ - "cn", - "us", - "eu", - "asia", - "other" - ], - } - }, - { - id: "new_Tf6rK9mP8q", - name: "preferences", - type: "json", - system: false, - required: false, - unique: false, - options: { - maxSize: 10000, - } - }, - { - id: "new_Dg7hL0nR9s", - name: "stats", - type: "json", - system: false, - required: false, - unique: false, - options: { - maxSize: 10000, - } - }, - { - id: "new_Wm8jO1pS0t", - name: "isActive", - type: "bool", - system: false, - required: false, - unique: false, - options: {} - }, - { - id: "new_Yn9kQ2qT1u", - name: "lastSeen", - type: "date", - system: false, - required: false, - unique: false, - options: { - min: "", - max: "", - } - }, - ], - indexes: [ - "create index users_username_idx on users (username)", - "create index users_region_idx on users (region)", - "create index users_isActive_idx on users (isActive)", - ], - listRule: "id = @request.auth.id", - viewRule: "id = @request.auth.id", - createRule: null, - updateRule: "id = @request.auth.id", - deleteRule: "id = @request.auth.id", - options: { - allowEmailAuth: true, - allowOAuth2Auth: false, - allowUsernameAuth: false, - exceptEmailDomains: null, - manageRule: null, - minPasswordLength: 8, - onlyEmailDomains: null, - requireEmail: false, - } - }); - - // Games collection - app.deleteCollection("games"); - app.createCollection("games", { - id: "games", - name: "games", - type: "base", - fields: [ - { - id: "new_Zp3wT8sV5x", - name: "name", - type: "text", - system: false, - required: true, - unique: true, - options: { - min: 1, - max: 100, - pattern: "", - } - }, - { - id: "new_Bq4xU9tW6y", - name: "nameEn", - type: "text", - system: false, - required: false, - unique: false, - options: { - min: null, - max: 100, - pattern: "", - } - }, - { - id: "new_Cr5yV0uX7z", - name: "cover", - type: "url", - system: false, - required: false, - unique: false, - options: { - exceptDomains: null, - onlyDomains: null, - } - }, - { - id: "new_Ds6zW1vY8a", - name: "description", - type: "editor", - system: false, - required: false, - unique: false, - options: { - convertUrls: false, - } - }, - { - id: "new_Et7aX2wZ9b", - name: "type", - type: "select", - system: false, - required: true, - unique: false, - options: { - maxSelect: 1, - values: [ - "fps", - "moba", - "rpg", - "strategy", - "racing", - "sports", - "casual", - "other" - ], - } - }, - { - id: "new_Fu8bY3xA0c", - name: "platform", - type: "select", - system: false, - required: true, - unique: false, - options: { - maxSelect: 1, - values: [ - "pc", - "mobile", - "console", - "crossplay" - ], - } - }, - { - id: "new_Gv9cZ4yB1d", - name: "maxTeamSize", - type: "number", - system: false, - required: true, - unique: false, - options: { - min: 2, - max: 100, - noDecimal: true, - } - }, - { - id: "new_Hw0dA5zC2e", - name: "tags", - type: "json", - system: false, - required: false, - unique: false, - options: { - maxSize: 5000, - } - }, - { - id: "new_Ix1eB6aD3f", - name: "isActive", - type: "bool", - system: false, - required: false, - unique: false, - options: {} - }, - ], - indexes: [ - "create index games_name_idx on games (name)", - "create index games_type_idx on games (type)", - "create index games_platform_idx on games (platform)", - "create index games_isActive_idx on games (isActive)", - ], - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - options: { - allowEmailAuth: false, - allowOAuth2Auth: false, - allowUsernameAuth: false, - exceptEmailDomains: null, - manageRule: null, - onlyEmailDomains: null, - requireEmail: false, - } - }); - - // Groups collection - app.deleteCollection("groups"); - app.createCollection("groups", { - id: "groups", - name: "groups", - type: "base", - fields: [ - { - id: "new_Jy2fC7bE4g", - name: "name", - type: "text", - system: false, - required: true, - unique: false, - options: { - min: 3, - max: 50, - pattern: "", - } - }, - { - id: "new_Kz3gD8cF5h", - name: "description", - type: "text", - system: false, - required: false, - unique: false, - options: { - min: null, - max: 500, - pattern: "", - } - }, - { - id: "new_La4hE9dG6i", - name: "avatar", - type: "url", - system: false, - required: false, - unique: false, - options: { - exceptDomains: null, - onlyDomains: null, - } - }, - { - id: "new_Mb5iF0eH7j", - name: "owner", - type: "relation", - system: false, - required: true, - unique: false, - options: { - collectionId: "users", - cascadeDelete: true, - minSelect: null, - maxSelect: 1, - displayFields: [ - "username", - "displayName" - ], - } - }, - { - id: "new_Nc6jG1fI8k", - name: "game", - type: "relation", - system: false, - required: true, - unique: false, - options: { - collectionId: "games", - cascadeDelete: false, - minSelect: null, - maxSelect: 1, - displayFields: [ - "name" - ], - } - }, - { - id: "new_Od7kH2gJ9l", - name: "members", - type: "relation", - system: false, - required: false, - unique: false, - options: { - collectionId: "users", - cascadeDelete: false, - minSelect: null, - maxSelect: null, - displayFields: [ - "username", - "displayName" - ], - } - }, - { - id: "new_Pe8lI3hK0m", - name: "maxMembers", - type: "number", - system: false, - required: true, - unique: false, - options: { - min: 2, - max: 100, - noDecimal: true, - } - }, - { - id: "new_Qf9mJ4iL1n", - name: "status", - type: "select", - system: false, - required: true, - unique: false, - options: { - maxSelect: 1, - values: [ - "recruiting", - "full", - "inactive" - ], - } - }, - { - id: "new_Rg0nK5jM2o", - name: "tags", - type: "json", - system: false, - required: false, - unique: false, - options: { - maxSize: 5000, - } - }, - { - id: "new_Sh1oL6kN3p", - name: "requirements", - type: "json", - system: false, - required: false, - unique: false, - options: { - maxSize: 10000, - } - }, - { - id: "new_Ti2pM7lO4q", - name: "stats", - type: "json", - system: false, - required: false, - unique: false, - options: { - maxSize: 10000, - } - }, - ], - indexes: [ - "create index groups_name_idx on groups (name)", - "create index groups_owner_idx on groups (owner)", - "create index groups_game_idx on groups (game)", - "create index groups_status_idx on groups (status)", - ], - listRule: "", - viewRule: "", - createRule: "@request.auth.id != \"\"", - updateRule: "owner = @request.auth.id", - deleteRule: "owner = @request.auth.id", - options: { - allowEmailAuth: false, - allowOAuth2Auth: false, - allowUsernameAuth: false, - exceptEmailDomains: null, - manageRule: null, - onlyEmailDomains: null, - requireEmail: false, - } - }); - - // Team Sessions collection - app.deleteCollection("teamSessions"); - app.createCollection("teamSessions", { - id: "teamSessions", - name: "teamSessions", - type: "base", - fields: [ - { - id: "new_Uj3qN8mP5r", - name: "group", - type: "relation", - system: false, - required: true, - unique: false, - options: { - collectionId: "groups", - cascadeDelete: true, - minSelect: null, - maxSelect: 1, - displayFields: [ - "name" - ], - } - }, - { - id: "new_Vk4rO9nQ6s", - name: "host", - type: "relation", - system: false, - required: true, - unique: false, - options: { - collectionId: "users", - cascadeDelete: false, - minSelect: null, - maxSelect: 1, - displayFields: [ - "username", - "displayName" - ], - } - }, - { - id: "new_Wl5sP0oR7t", - name: "participants", - type: "relation", - system: false, - required: false, - unique: false, - options: { - collectionId: "users", - cascadeDelete: false, - minSelect: null, - maxSelect: null, - displayFields: [ - "username", - "displayName" - ], - } - }, - { - id: "new_Xm6tQ1pS8u", - name: "status", - type: "select", - system: false, - required: true, - unique: false, - options: { - maxSelect: 1, - values: [ - "waiting", - "playing", - "completed", - "cancelled" - ], - } - }, - { - id: "new_Yn7uR2qT9v", - name: "voiceChat", - type: "bool", - system: false, - required: false, - unique: false, - options: {} - }, - { - id: "new_Zo8vS3rU0w", - name: "roomInfo", - type: "json", - system: false, - required: false, - unique: false, - options: { - maxSize: 10000, - } - }, - { - id: "new_Ap9wT4sV1x", - name: "notes", - type: "text", - system: false, - required: false, - unique: false, - options: { - min: null, - max: 1000, - pattern: "", - } - }, - { - id: "new_Bq0xU5tW2y", - name: "scheduledAt", - type: "date", - system: false, - required: false, - unique: false, - options: { - min: "", - max: "", - } - }, - { - id: "new_Cr1yV6uX3z", - name: "startedAt", - type: "date", - system: false, - required: false, - unique: false, - options: { - min: "", - max: "", - } - }, - { - id: "new_Ds2zW7vY4a", - name: "endedAt", - type: "date", - system: false, - required: false, - unique: false, - options: { - min: "", - max: "", - } - }, - ], - indexes: [ - "create index teamSessions_group_idx on teamSessions (group)", - "create index teamSessions_host_idx on teamSessions (host)", - "create index teamSessions_status_idx on teamSessions (status)", - "create index teamSessions_scheduledAt_idx on teamSessions (scheduledAt)", - ], - listRule: "group.owner = @request.auth.id || group.members.id = @request.auth.id", - viewRule: "group.owner = @request.auth.id || group.members.id = @request.auth.id", - createRule: "@request.auth.id != \"\"", - updateRule: "group.owner = @request.auth.id", - deleteRule: "group.owner = @request.auth.id", - options: { - allowEmailAuth: false, - allowOAuth2Auth: false, - allowUsernameAuth: false, - exceptEmailDomains: null, - manageRule: null, - onlyEmailDomains: null, - requireEmail: false, - } - }); - - // Invitations collection - app.deleteCollection("invitations"); - app.createCollection("invitations", { - id: "invitations", - name: "invitations", - type: "base", - fields: [ - { - id: "new_Et3yZ8wB5c", - name: "group", - type: "relation", - system: false, - required: true, - unique: false, - options: { - collectionId: "groups", - cascadeDelete: true, - minSelect: null, - maxSelect: 1, - displayFields: [ - "name" - ], - } - }, - { - id: "new_Fu4zA9xC6d", - name: "sender", - type: "relation", - system: false, - required: true, - unique: false, - options: { - collectionId: "users", - cascadeDelete: false, - minSelect: null, - maxSelect: 1, - displayFields: [ - "username", - "displayName" - ], - } - }, - { - id: "new_Gv5aB0yD7e", - name: "recipient", - type: "relation", - system: false, - required: true, - unique: false, - options: { - collectionId: "users", - cascadeDelete: false, - minSelect: null, - maxSelect: 1, - displayFields: [ - "username", - "displayName" - ], - } - }, - { - id: "new_Hw6bC1zE8f", - name: "status", - type: "select", - system: false, - required: true, - unique: false, - options: { - maxSelect: 1, - values: [ - "pending", - "accepted", - "rejected", - "cancelled" - ], - } - }, - { - id: "new_Ix7cD2aF9g", - name: "message", - type: "text", - system: false, - required: false, - unique: false, - options: { - min: null, - max: 500, - pattern: "", - } - }, - { - id: "new_Jy8dE3bG0h", - name: "respondedAt", - type: "date", - system: false, - required: false, - unique: false, - options: { - min: "", - max: "", - } - }, - ], - indexes: [ - "create index invitations_group_idx on invitations (group)", - "create index invitations_sender_idx on invitations (sender)", - "create index invitations_recipient_idx on invitations (recipient)", - "create index invitations_status_idx on invitations (status)", - "create unique index invitations_unique_pending on invitations (group, recipient) where status = 'pending'", - ], - listRule: "sender = @request.auth.id || recipient = @request.auth.id || group.owner = @request.auth.id", - viewRule: "sender = @request.auth.id || recipient = @request.auth.id || group.owner = @request.auth.id", - createRule: "group.owner = @request.auth.id", - updateRule: "recipient = @request.auth.id", - deleteRule: "sender = @request.auth.id || group.owner = @request.auth.id", - options: { - allowEmailAuth: false, - allowOAuth2Auth: false, - allowUsernameAuth: false, - exceptEmailDomains: null, - manageRule: null, - onlyEmailDomains: null, - requireEmail: false, - } - }); - -}, (app) => { - // Rollback - app.deleteCollection("invitations"); - app.deleteCollection("teamSessions"); - app.deleteCollection("groups"); - app.deleteCollection("games"); - app.deleteCollection("users"); -}); diff --git a/deploy-backend.sh b/deploy-backend.sh new file mode 100644 index 0000000..6c18e47 --- /dev/null +++ b/deploy-backend.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# 部署 PocketBase 后端 + +echo "🚀 部署 PocketBase 后端..." + +docker compose -f docker-compose.backend.yml up -d --force-recreate + +echo "✅ PocketBase 已启动" +echo "📡 API 地址: http://192.168.1.14:8711/api/" +echo "🔧 管理面板: http://192.168.1.14:8711/_/" diff --git a/deploy-dev.sh b/deploy-dev.sh new file mode 100644 index 0000000..c630f82 --- /dev/null +++ b/deploy-dev.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# 部署 Dev 前端 + +echo "🚀 部署 Dev 前端..." + +# 确保网络存在 +docker network create gamegroup-net 2>/dev/null || true + +docker compose -f docker-compose.dev.yml up -d --build --force-recreate + +echo "✅ Dev 环境已启动" +echo "🌐 访问地址: http://192.168.1.14:7033" diff --git a/deploy-uat.sh b/deploy-uat.sh new file mode 100644 index 0000000..516eeee --- /dev/null +++ b/deploy-uat.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# 部署 UAT 前端 + +echo "🚀 部署 UAT 前端..." + +# 确保网络存在 +docker network create gamegroup-net 2>/dev/null || true + +docker compose -f docker-compose.uat.yml up -d --build --force-recreate + +echo "✅ UAT 环境已启动" +echo "🌐 访问地址: http://192.168.1.14:7034" diff --git a/docker-compose.backend.yml b/docker-compose.backend.yml new file mode 100644 index 0000000..049f47d --- /dev/null +++ b/docker-compose.backend.yml @@ -0,0 +1,24 @@ +services: + pocketbase: + image: ghcr.io/muchobien/pocketbase:0.22.4 + container_name: gamegroup-pb + ports: + - "8711:8090" + volumes: + - ./backend/pb_data:/pb_data + - ./backend/pb_migrations:/pb_migrations + environment: + - GO_ENV=production + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - gamegroup-net + +networks: + gamegroup-net: + driver: bridge diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..70b7c15 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,18 @@ +services: + frontend-dev: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: gamegroup-frontend-dev + ports: + - "7033:80" + environment: + - NODE_ENV=production + restart: unless-stopped + networks: + - gamegroup-net + +networks: + gamegroup-net: + driver: bridge + external: true diff --git a/docker-compose.uat.yml b/docker-compose.uat.yml new file mode 100644 index 0000000..b53e2c0 --- /dev/null +++ b/docker-compose.uat.yml @@ -0,0 +1,18 @@ +services: + frontend-uat: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: gamegroup-frontend-uat + ports: + - "7034:80" + environment: + - NODE_ENV=production + restart: unless-stopped + networks: + - gamegroup-net + +networks: + gamegroup-net: + driver: bridge + external: true diff --git a/frontend/.env.dev b/frontend/.env.dev new file mode 100644 index 0000000..69fec0f --- /dev/null +++ b/frontend/.env.dev @@ -0,0 +1,3 @@ +# Dev Environment +VITE_PB_URL=http://192.168.1.14:8711 +VITE_PORT=7033 diff --git a/frontend/.env.uat b/frontend/.env.uat new file mode 100644 index 0000000..603558c --- /dev/null +++ b/frontend/.env.uat @@ -0,0 +1,3 @@ +# UAT Environment +VITE_PB_URL=http://192.168.1.14:8711 +VITE_PORT=7034 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c3f4824 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,30 @@ +# 构建阶段 +FROM node:20-alpine AS builder + +WORKDIR /app + +# 复制 package 文件 +COPY package*.json ./ +RUN npm ci + +# 复制源码 +COPY . . + +# 设置构建参数并构建(默认使用相对路径,由 nginx 代理) +ARG VITE_PB_URL=/api +ENV VITE_PB_URL=$VITE_PB_URL + +RUN npm run build + +# 生产阶段 +FROM nginx:alpine + +# 复制构建产物 +COPY --from=builder /app/dist /usr/share/nginx/html + +# 复制 nginx 配置 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ca8701b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + Game Group V2 + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..d92ef32 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,33 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # 开启 gzip 压缩 + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + location / { + try_files $uri $uri/ /index.html; + } + + # API 代理到局域网 PocketBase + location /api/ { + proxy_pass http://192.168.1.14:8711/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..251b097 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "gamegroup-v2-frontend", + "version": "2.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "dev:dev": "bash scripts/start-dev.sh", + "dev:uat": "bash scripts/start-uat.sh", + "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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/scripts/start-dev.sh b/frontend/scripts/start-dev.sh new file mode 100644 index 0000000..dc4be5e --- /dev/null +++ b/frontend/scripts/start-dev.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# 启动 Dev 环境 + +cd "$(dirname "$0")/../" + +export $(cat .env.dev | xargs) + +echo "🚀 启动 Dev 环境" +echo "📡 PocketBase: $VITE_PB_URL" +echo "🌐 Frontend Port: $VITE_PORT" + +npm run dev diff --git a/frontend/scripts/start-uat.sh b/frontend/scripts/start-uat.sh new file mode 100644 index 0000000..42be38d --- /dev/null +++ b/frontend/scripts/start-uat.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# 启动 UAT 环境 + +cd "$(dirname "$0")/../" + +export $(cat .env.uat | xargs) + +echo "🚀 启动 UAT 环境" +echo "📡 PocketBase: $VITE_PB_URL" +echo "🌐 Frontend Port: $VITE_PORT" + +npm run dev diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..14c6b67 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,27 @@ + + + + + + diff --git a/frontend/src/api/games.ts b/frontend/src/api/games.ts new file mode 100644 index 0000000..e71dc9a --- /dev/null +++ b/frontend/src/api/games.ts @@ -0,0 +1,98 @@ +// src/api/games.ts +import pb from './pocketbase' +import type { Game, GamePlatform } from '@/types' + +// 获取游戏列表 +export async function getGames(options?: { + page?: number + limit?: number + platform?: GamePlatform + search?: string +}): Promise<{ items: Game[], total: number }> { + const { page = 1, limit = 20, platform, search } = options || {} + + let filter = '' + if (platform) { + filter = `platform="${platform}"` + } + if (search) { + const searchFilter = `name ~ "${search}"` + filter = filter ? `${filter} && ${searchFilter}` : searchFilter + } + + const result = await pb.collection('games').getList(page, limit, { + filter, + sort: '-popularCount' + }) + + return { + items: result.items as unknown as Game[], + total: result.totalItems + } +} + +// 获取热门游戏 +export async function getPopularGames(limit = 10): Promise { + const result = await pb.collection('games').getList(1, limit, { + sort: '-popularCount' + }) + + return result.items as unknown as Game[] +} + +// 搜索游戏 +export async function searchGames(query: string, limit = 20): Promise { + if (!query.trim()) return [] + + const result = await pb.collection('games').getList(1, limit, { + filter: `name ~ "${query}"`, + sort: '-popularCount' + }) + + return result.items as unknown as Game[] +} + +// 添加游戏(需要管理员权限) +export async function addGame(data: { + name: string + platform: GamePlatform + tags?: string[] + cover?: string +}) { + return pb.collection('games').create(data) +} + +// 更新游戏热度 +export async function incrementGamePopularity(gameId: string) { + const game = await pb.collection('games').getOne(gameId) + return pb.collection('games').update(gameId, { + popularCount: (game.popularCount || 0) + 1 + }) +} + +// 获取游戏详情 +export async function getGame(gameId: string): Promise { + return pb.collection('games').getOne(gameId) as unknown as Game +} + +// 按平台获取游戏 +export async function getGamesByPlatform(platform: GamePlatform): Promise { + const result = await pb.collection('games').getList(1, 50, { + filter: `platform="${platform}"`, + sort: '-popularCount' + }) + + return result.items as unknown as Game[] +} + +// 获取所有平台 +export function getAllPlatforms(): GamePlatform[] { + return ['PC', 'PS5', 'Xbox', 'Switch', 'Mobile'] +} + +// 订阅游戏变更 +export function subscribeGames(callback: (game: Game) => void) { + return pb.collection('games').subscribe('*', (payload) => { + callback(payload.record as unknown as Game) + }) +} diff --git a/frontend/src/api/groups.ts b/frontend/src/api/groups.ts new file mode 100644 index 0000000..8dc16d1 --- /dev/null +++ b/frontend/src/api/groups.ts @@ -0,0 +1,111 @@ +// src/api/groups.ts +import pb from './pocketbase' +import type { Group } from '@/types' + +// 创建群组 +export async function createGroup(data: { + name: string + description: string + maxMembers: number +}) { + const user = pb.authStore.model + if (!user) throw new Error('未登录') + + return pb.collection('groups').create({ + ...data, + owner: user.id, + members: [user.id] + }) +} + +// 获取用户的群组列表 +export async function getUserGroups(): Promise { + const user = pb.authStore.model + if (!user) return [] + + // 通过 members 字段过滤 + return pb.collection('groups').getList(1, 50, { + filter: `members ~ "${user.id}"` + }).then(res => res.items as unknown as Group[]) +} + +// 获取群组详情 +export async function getGroup(groupId: string): Promise { + return pb.collection('groups').getOne(groupId, { + expand: 'members' + }) as unknown as Group +} + +// 加入群组 +export async function joinGroup(groupId: string) { + const user = pb.authStore.model + if (!user) throw new Error('未登录') + + const group = await pb.collection('groups').getOne(groupId) + const members = group.members as string[] + + if (members.length >= group.maxMembers) { + throw new Error('群组已满') + } + + if (members.includes(user.id)) { + throw new Error('已是群组成员') + } + + return pb.collection('groups').update(groupId, { + members: [...members, user.id] + }) +} + +// 退出群组 +export async function leaveGroup(groupId: string) { + const user = pb.authStore.model + if (!user) throw new Error('未登录') + + const group = await pb.collection('groups').getOne(groupId) + + if (group.owner === user.id) { + throw new Error('群主不能退出群组,请先转让群主或解散群组') + } + + const members = group.members as string[] + return pb.collection('groups').update(groupId, { + members: members.filter(id => id !== user.id) + }) +} + +// 解散群组 +export async function dissolveGroup(groupId: string) { + const user = pb.authStore.model + if (!user) throw new Error('未登录') + + const group = await pb.collection('groups').getOne(groupId) + + if (group.owner !== user.id) { + throw new Error('只有群主可以解散群组') + } + + return pb.collection('groups').delete(groupId) +} + +// 订阅群组变更 +export function subscribeGroup(groupId: string, callback: (group: Group) => void) { + return pb.collection('groups').subscribe('*', (payload) => { + if (payload.record.id === groupId) { + callback(payload.record as unknown as Group) + } + }) +} + +// 获取群组成员 +export async function getGroupMembers(groupId: string) { + const group = await getGroup(groupId) + const members = group.members as string[] + + // 批量获取用户信息 + const users = await pb.collection('users').getList(1, 50, { + filter: members.map(id => `id="${id}"`).join(' || ') + }) + + return users.items +} diff --git a/frontend/src/api/invitations.ts b/frontend/src/api/invitations.ts new file mode 100644 index 0000000..caa6b64 --- /dev/null +++ b/frontend/src/api/invitations.ts @@ -0,0 +1,116 @@ +// src/api/invitations.ts +import pb from './pocketbase' +import type { Invitation, InviteStatus } from '@/types' +import { joinTeamSession } from './sessions' +import { updateUserStatus } from './users' + +// 发送邀请 +export async function sendInvitation(data: { + to: string + teamSession: string +}) { + const user = pb.authStore.model + if (!user) throw new Error('未登录') + + // 检查是否已有待处理邀请 + const existing = await pb.collection('invitations').getList(1, 1, { + filter: `from="${user.id}" && to="${data.to}" && teamSession="${data.teamSession}" && status="pending"` + }) + + if (existing.items.length > 0) { + throw new Error('已有待处理的邀请') + } + + return pb.collection('invitations').create({ + ...data, + from: user.id, + status: 'pending' + }) +} + +// 批量发送邀请 +export async function sendBulkInvitations(recipients: string[], teamSessionId: string) { + const promises = recipients.map(to => + sendInvitation({ to, teamSession: teamSessionId }) + ) + + return Promise.allSettled(promises) +} + +// 获取用户的待处理邀请 +export async function getPendingInvitations(): Promise { + const user = pb.authStore.model + if (!user) return [] + + const result = await pb.collection('invitations').getList(1, 50, { + filter: `to="${user.id}" && status="pending"`, + sort: '-created', + expand: 'from,teamSession' + }) + + return result.items as unknown as Invitation[] +} + +// 获取我发送的邀请 +export async function getMySentInvitations(): Promise { + const user = pb.authStore.model + if (!user) return [] + + const result = await pb.collection('invitations').getList(1, 50, { + filter: `from="${user.id}"`, + sort: '-created', + expand: 'to,teamSession' + }) + + return result.items as unknown as Invitation[] +} + +// 响应邀请 +export async function respondInvitation( + invitationId: string, + response: 'accepted' | 'rejected', + rejectReason?: string +) { + const user = pb.authStore.model + if (!user) throw new Error('未登录') + + const invitation = await pb.collection('invitations').getOne(invitationId) + + if (invitation.to !== user.id) { + throw new Error('无权操作此邀请') + } + + const updateData: Partial = { + status: response as InviteStatus, + respondedAt: new Date().toISOString() + } + + if (response === 'rejected' && rejectReason) { + updateData.rejectReason = rejectReason + } + + // 更新邀请状态 + await pb.collection('invitations').update(invitationId, updateData) + + // 如果接受,加入临时小组 + if (response === 'accepted') { + await joinTeamSession(invitation.teamSession) + // 更新用户状态 + await updateUserStatus('in_team') + } + + return updateData +} + +// 订阅邀请变更 +export function subscribeInvitations(callback: (invitation: Invitation) => void) { + const user = pb.authStore.model + if (!user) return () => {} + + return pb.collection('invitations').subscribe('*', (payload) => { + const invite = payload.record as unknown as Invitation + if (invite.to === user.id || invite.from === user.id) { + callback(invite) + } + }) +} diff --git a/frontend/src/api/pocketbase.ts b/frontend/src/api/pocketbase.ts new file mode 100644 index 0000000..3e44728 --- /dev/null +++ b/frontend/src/api/pocketbase.ts @@ -0,0 +1,32 @@ +// src/api/pocketbase.ts +import PocketBase from 'pocketbase' + +const pbUrl = import.meta.env.VITE_PB_URL || '/api' + +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 diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts new file mode 100644 index 0000000..b952713 --- /dev/null +++ b/frontend/src/api/sessions.ts @@ -0,0 +1,92 @@ +// src/api/sessions.ts +import pb from './pocketbase' +import type { TeamSession, TeamStatus } from '@/types' + +// 创建临时小组 +export async function createTeamSession(data: { + sourceGroup: string + name: string + gameName: string + members: string[] +}): Promise { + return pb.collection('teamSessions').create({ + ...data, + status: 'recruiting' + }) as unknown as TeamSession +} + +// 获取用户的活跃临时小组 +export async function getActiveTeamSession(): Promise { + const user = pb.authStore.model + if (!user) return null + + const result = await pb.collection('teamSessions').getList(1, 1, { + filter: `members ~ "${user.id}" && status != "dissolved" && status != "finished"`, + sort: '-created' + }) + + return (result.items[0] as unknown as TeamSession) || null +} + +// 获取群组的临时小组列表 +export async function getGroupTeamSessions(groupId: string): Promise { + const result = await pb.collection('teamSessions').getList(1, 20, { + filter: `sourceGroup="${groupId}"`, + sort: '-created' + }) + + return result.items as unknown as TeamSession[] +} + +// 更新临时小组状态 +export async function updateTeamStatus(sessionId: string, status: TeamStatus): Promise { + const updateData: Partial = { status } + + if (status === 'dissolved') { + updateData.dissolvedAt = new Date().toISOString() + } + + return pb.collection('teamSessions').update(sessionId, updateData) as unknown as TeamSession +} + +// 结束游戏(解散临时小组) +export async function endGame(sessionId: string) { + const session = await pb.collection('teamSessions').getOne(sessionId) + + // 将所有成员状态恢复为 idle + const members = session.members as string[] + const updatePromises = members.map(userId => + pb.collection('users').update(userId, { status: 'idle' }) + ) + + await Promise.all(updatePromises) + + // 解散临时小组 + return updateTeamStatus(sessionId, 'dissolved') +} + +// 加入临时小组 +export async function joinTeamSession(sessionId: string) { + const user = pb.authStore.model + if (!user) throw new Error('未登录') + + const session = await pb.collection('teamSessions').getOne(sessionId) as { members: string[] } + const members = session.members as string[] + + if (members.includes(user.id)) { + throw new Error('已在小组中') + } + + return pb.collection('teamSessions').update(sessionId, { + members: [...members, user.id] + }) +} + +// 订阅临时小组变更 +export function subscribeTeamSession(sessionId: string, callback: (session: TeamSession) => void) { + return pb.collection('teamSessions').subscribe('*', (payload) => { + if (payload.record.id === sessionId) { + callback(payload.record as unknown as TeamSession) + } + }) +} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..92f3091 --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,79 @@ +// src/api/users.ts +import pb, { getCurrentUser } from './pocketbase' +import type { User, UserStatus, WorkSchedule } from '@/types' + +// 更新用户状态 +export async function updateUserStatus(status: UserStatus, note?: string) { + const user = getCurrentUser() + if (!user) throw new Error('未登录') + + return pb.collection('users').update(user.id, { + status, + statusNote: note || '' + }) +} + +// 更新工作时间设置 +export async function updateWorkSchedule(schedule: WorkSchedule) { + const user = getCurrentUser() + if (!user) throw new Error('未登录') + + // 计算下次工作时间 + const nextWorkTime = calculateNextWorkTime(schedule.workdays, schedule.workStartTime) + + return pb.collection('users').update(user.id, { + 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) + + for (let i = 0; i < 7; i++) { + const checkDate = new Date(now) + checkDate.setDate(now.getDate() + i) + checkDate.setHours(hours, minutes, 0, 0) + + const dayOfWeek = checkDate.getDay() || 7 // 转换为 1-7 (周一到周日) + + if (workdays.includes(dayOfWeek)) { + // 如果是今天,检查时间是否已过 + if (i === 0 && checkDate <= now) { + continue + } + return Math.floor(checkDate.getTime() / 1000) + } + } + + // 默认返回下周 + const nextWeek = new Date(now) + nextWeek.setDate(now.getDate() + 7) + nextWeek.setHours(hours, minutes, 0, 0) + return Math.floor(nextWeek.getTime() / 1000) +} + +// 获取用户信息 +export async function getUser(userId: string): Promise { + return pb.collection('users').getOne(userId) +} + +// 更新用户资料 +export async function updateProfile(data: Partial>) { + const user = getCurrentUser() + if (!user) throw new Error('未登录') + + return pb.collection('users').update(user.id, data) +} + +// 订阅用户状态变更 +export function subscribeUserStatus(userId: string, callback: (user: User) => void) { + return pb.collection('users').subscribe('*', (payload) => { + if (payload.record.id === userId) { + callback(payload.record as unknown as User) + } + }) +} diff --git a/frontend/src/components/common/PasswordInput.vue b/frontend/src/components/common/PasswordInput.vue new file mode 100644 index 0000000..ac9a262 --- /dev/null +++ b/frontend/src/components/common/PasswordInput.vue @@ -0,0 +1,92 @@ + + + + + + diff --git a/frontend/src/components/team/IdleMembersList.vue b/frontend/src/components/team/IdleMembersList.vue new file mode 100644 index 0000000..56432d2 --- /dev/null +++ b/frontend/src/components/team/IdleMembersList.vue @@ -0,0 +1,161 @@ + + + + + + diff --git a/frontend/src/components/team/InvitationCard.vue b/frontend/src/components/team/InvitationCard.vue new file mode 100644 index 0000000..72680bf --- /dev/null +++ b/frontend/src/components/team/InvitationCard.vue @@ -0,0 +1,195 @@ + + + +