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 @@
+
+
+
+
+
+
+ {{ status === 'idle' ? '空闲成员' : '成员列表' }}
+ ({{ idleMembers.length }})
+
+
+
+ {{ status === 'idle' ? '暂无空闲成员' : '暂无成员' }}
+
+
+
+
+
![]()
+
+ {{ member.username }}
+ {{ member.statusNote }}
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+ {{ invitation.expand.teamSession.gameName }}
+ {{ invitation.expand.teamSession.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/team/StatusToggle.vue b/frontend/src/components/team/StatusToggle.vue
new file mode 100644
index 0000000..71f8497
--- /dev/null
+++ b/frontend/src/components/team/StatusToggle.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+ {{ statusIcon }}
+ {{ statusText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/team/TeamSessionPanel.vue b/frontend/src/components/team/TeamSessionPanel.vue
new file mode 100644
index 0000000..9df1ffb
--- /dev/null
+++ b/frontend/src/components/team/TeamSessionPanel.vue
@@ -0,0 +1,188 @@
+
+
+
+
+
+
+
+
+ 游戏:
+ {{ session.gameName }}
+
+
+
+
成员 ({{ memberDetails.length }})
+
+
+
![]()
+
{{ member.username }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/team/WorkScheduleModal.vue b/frontend/src/components/team/WorkScheduleModal.vue
new file mode 100644
index 0000000..736635e
--- /dev/null
+++ b/frontend/src/components/team/WorkScheduleModal.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
diff --git a/frontend/src/composables/useRealtime.ts b/frontend/src/composables/useRealtime.ts
new file mode 100644
index 0000000..d19ca41
--- /dev/null
+++ b/frontend/src/composables/useRealtime.ts
@@ -0,0 +1,102 @@
+// src/composables/useRealtime.ts
+import { onUnmounted } from 'vue'
+import { pb } from '@/api/pocketbase'
+import { useUserStore } from '@/stores/user'
+import { useGroupStore } from '@/stores/group'
+import { useTeamStore } from '@/stores/team'
+
+type UnsubscribeFunc = () => Promise
+
+export function useRealtime() {
+ const userStore = useUserStore()
+ const groupStore = useGroupStore()
+ const teamStore = useTeamStore()
+
+ const subscriptions: UnsubscribeFunc[] = []
+
+ // 订阅用户状态变更(群组成员)
+ async function subscribeGroupMembers() {
+ if (!groupStore.currentGroup) return
+
+ const unsub = await pb.collection('users').subscribe('*', (payload) => {
+ const userId = payload.record.id
+ const isMember = groupStore.currentGroup?.members.includes(userId)
+
+ if (isMember) {
+ // 更新成员状态 - 重新加载成员列表
+ groupStore.setCurrentGroup(groupStore.currentGroupId)
+ }
+ })
+
+ subscriptions.push(unsub)
+ }
+
+ // 订阅邀请变更
+ async function subscribeInvitations() {
+ const userId = userStore.userId
+ if (!userId) return
+
+ const unsub = await pb.collection('invitations').subscribe('*', async () => {
+ // 更新邀请数量
+ await teamStore.getPendingCount()
+ // TODO: 更新 UI 显示邀请数量
+ })
+
+ subscriptions.push(unsub)
+ }
+
+ // 订阅临时小组变更
+ async function subscribeTeamSession() {
+ const session = teamStore.currentSession
+ if (!session) return
+
+ const unsub = await pb.collection('teamSessions').subscribe('*', (payload) => {
+ if (payload.record.id === session.id) {
+ // 更新临时小组状态
+ teamStore.loadActiveSession()
+ }
+ })
+
+ subscriptions.push(unsub)
+ }
+
+ // 订阅群组变更
+ async function subscribeGroup(groupId: string) {
+ const unsub = await pb.collection('groups').subscribe('*', (payload) => {
+ if (payload.record.id === groupId) {
+ // 更新群组信息
+ groupStore.loadGroups()
+ if (groupStore.currentGroupId === groupId) {
+ groupStore.setCurrentGroup(groupId)
+ }
+ }
+ })
+
+ subscriptions.push(unsub)
+ }
+
+ // 清理所有订阅
+ async function unsubscribeAll() {
+ for (const sub of subscriptions) {
+ try {
+ await sub()
+ } catch (error) {
+ console.error('取消订阅失败:', error)
+ }
+ }
+ subscriptions.length = 0
+ }
+
+ // 组件卸载时自动清理
+ onUnmounted(() => {
+ unsubscribeAll()
+ })
+
+ return {
+ subscribeGroupMembers,
+ subscribeInvitations,
+ subscribeTeamSession,
+ subscribeGroup,
+ unsubscribeAll
+ }
+}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
new file mode 100644
index 0000000..cd7b801
--- /dev/null
+++ b/frontend/src/main.ts
@@ -0,0 +1,22 @@
+// src/main.ts
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import * as ElementPlusIconsVue from '@element-plus/icons-vue'
+import App from './App.vue'
+import router from './router'
+
+const app = createApp(App)
+const pinia = createPinia()
+
+// 注册所有 Element Plus 图标
+for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
+ app.component(key, component)
+}
+
+app.use(pinia)
+app.use(router)
+app.use(ElementPlus)
+
+app.mount('#app')
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
new file mode 100644
index 0000000..fe96587
--- /dev/null
+++ b/frontend/src/router/index.ts
@@ -0,0 +1,85 @@
+// 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: { requiresGuest: true }
+ },
+ {
+ path: '/register',
+ name: 'Register',
+ component: () => import('@/views/Register.vue'),
+ meta: { requiresGuest: true }
+ },
+ {
+ path: '/',
+ component: () => import('@/views/Layout.vue'),
+ meta: { requiresAuth: true },
+ children: [
+ {
+ path: '',
+ name: 'Home',
+ component: () => import('@/views/Home.vue')
+ },
+ {
+ path: 'group/:id',
+ name: 'GroupView',
+ component: () => import('@/views/GroupView.vue'),
+ props: true
+ },
+ {
+ path: 'games',
+ name: 'GamesLibrary',
+ component: () => import('@/views/GamesLibrary.vue')
+ },
+ {
+ path: 'profile',
+ name: 'Profile',
+ component: () => import('@/views/Profile.vue')
+ },
+ {
+ path: 'settings',
+ name: 'Settings',
+ component: () => import('@/views/Settings.vue')
+ }
+ ]
+ },
+ {
+ path: '/:pathMatch(.*)*',
+ name: 'NotFound',
+ component: () => import('@/views/NotFound.vue')
+ }
+]
+
+// 创建路由实例
+const router = createRouter({
+ history: createWebHistory(import.meta.env.BASE_URL),
+ routes
+})
+
+// 路由守卫
+router.beforeEach((to, _from, next) => {
+ const authenticated = isAuthenticated()
+
+ // 需要登录的页面
+ if (to.meta.requiresAuth && !authenticated) {
+ next({ name: 'Login', query: { redirect: to.fullPath } })
+ return
+ }
+
+ // 已登录用户访问登录/注册页
+ if (to.meta.requiresGuest && authenticated) {
+ next({ name: 'Home' })
+ return
+ }
+
+ next()
+})
+
+export default router
diff --git a/frontend/src/stores/group.ts b/frontend/src/stores/group.ts
new file mode 100644
index 0000000..6b94a27
--- /dev/null
+++ b/frontend/src/stores/group.ts
@@ -0,0 +1,88 @@
+// src/stores/group.ts
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import type { Group, User } from '@/types'
+import { getUserGroups, getGroup, getGroupMembers } from '@/api/groups'
+
+export const useGroupStore = defineStore('group', () => {
+ // 状态
+ const groups = ref([])
+ const currentGroup = ref(null)
+ const currentMembers = ref([])
+ const loading = ref(false)
+
+ // 计算属性
+ const currentGroupId = computed(() => currentGroup.value?.id || '')
+ const isGroupOwner = computed(() => {
+ const userId = localStorage.getItem('userId')
+ return currentGroup.value?.owner === userId
+ })
+
+ // 加载用户的群组列表
+ async function loadGroups() {
+ try {
+ loading.value = true
+ groups.value = await getUserGroups()
+ } catch (error) {
+ console.error('加载群组列表失败:', error)
+ } finally {
+ loading.value = false
+ }
+ }
+
+ // 设置当前群组
+ async function setCurrentGroup(groupId: string) {
+ try {
+ loading.value = true
+ currentGroup.value = await getGroup(groupId)
+ currentMembers.value = await getGroupMembers(groupId) as unknown as User[]
+ } catch (error) {
+ console.error('加载群组详情失败:', error)
+ throw error
+ } finally {
+ loading.value = false
+ }
+ }
+
+ // 清除当前群组
+ function clearCurrentGroup() {
+ currentGroup.value = null
+ currentMembers.value = []
+ }
+
+ // 更新群组成员列表
+ function updateMembers(members: User[]) {
+ currentMembers.value = members
+ }
+
+ // 添加群组到列表
+ function addGroup(group: Group) {
+ groups.value.push(group)
+ }
+
+ // 从列表中移除群组
+ function removeGroup(groupId: string) {
+ const index = groups.value.findIndex(g => g.id === groupId)
+ if (index !== -1) {
+ groups.value.splice(index, 1)
+ }
+ if (currentGroup.value?.id === groupId) {
+ clearCurrentGroup()
+ }
+ }
+
+ return {
+ groups,
+ currentGroup,
+ currentMembers,
+ loading,
+ currentGroupId,
+ isGroupOwner,
+ loadGroups,
+ setCurrentGroup,
+ clearCurrentGroup,
+ updateMembers,
+ addGroup,
+ removeGroup
+ }
+})
diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts
new file mode 100644
index 0000000..b598d04
--- /dev/null
+++ b/frontend/src/stores/index.ts
@@ -0,0 +1,4 @@
+// src/stores/index.ts
+export { useUserStore } from './user'
+export { useGroupStore } from './group'
+export { useTeamStore } from './team'
diff --git a/frontend/src/stores/team.ts b/frontend/src/stores/team.ts
new file mode 100644
index 0000000..ea8c4fb
--- /dev/null
+++ b/frontend/src/stores/team.ts
@@ -0,0 +1,117 @@
+// src/stores/team.ts
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import type { TeamSession, TeamStatus } from '@/types'
+import { getActiveTeamSession, createTeamSession, updateTeamStatus, endGame } from '@/api/sessions'
+import { getPendingInvitations, sendBulkInvitations } from '@/api/invitations'
+
+export const useTeamStore = defineStore('team', () => {
+ // 状态
+ const currentSession = ref(null)
+ const loading = ref(false)
+
+ // 计算属性
+ const isInTeam = computed(() => currentSession.value !== null)
+ const teamStatus = computed(() => currentSession.value?.status || 'dissolved')
+ const teamMembers = computed(() => currentSession.value?.members || [])
+
+ // 加载活跃的临时小组
+ async function loadActiveSession() {
+ try {
+ loading.value = true
+ const session = await getActiveTeamSession()
+ currentSession.value = session
+ return session
+ } catch (error) {
+ console.error('加载临时小组失败:', error)
+ currentSession.value = null
+ return null
+ } finally {
+ loading.value = false
+ }
+ }
+
+ // 创建临时小组
+ async function createSession(data: {
+ sourceGroup: string
+ name: string
+ gameName: string
+ members: string[]
+ }) {
+ try {
+ loading.value = true
+ const session = await createTeamSession(data)
+ currentSession.value = session
+
+ // 发送邀请
+ await sendBulkInvitations(data.members, session.id)
+
+ return session
+ } catch (error: any) {
+ console.error('创建临时小组失败:', error)
+ throw error
+ } finally {
+ loading.value = false
+ }
+ }
+
+ // 更新小组状态
+ async function updateStatus(status: TeamStatus) {
+ if (!currentSession.value) return
+
+ try {
+ const updated = await updateTeamStatus(currentSession.value.id, status)
+ currentSession.value = updated
+ return updated
+ } catch (error: any) {
+ console.error('更新小组状态失败:', error)
+ throw error
+ }
+ }
+
+ // 结束游戏
+ async function finishGame() {
+ if (!currentSession.value) return
+
+ try {
+ loading.value = true
+ await endGame(currentSession.value.id)
+ currentSession.value = null
+ } catch (error: any) {
+ console.error('结束游戏失败:', error)
+ throw error
+ } finally {
+ loading.value = false
+ }
+ }
+
+ // 清除当前小组
+ function clearSession() {
+ currentSession.value = null
+ }
+
+ // 获取待处理邀请数量
+ async function getPendingCount() {
+ try {
+ const invitations = await getPendingInvitations()
+ return invitations.length
+ } catch (error) {
+ console.error('获取邀请数量失败:', error)
+ return 0
+ }
+ }
+
+ return {
+ currentSession,
+ loading,
+ isInTeam,
+ teamStatus,
+ teamMembers,
+ loadActiveSession,
+ createSession,
+ updateStatus,
+ finishGame,
+ clearSession,
+ getPendingCount
+ }
+})
diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts
new file mode 100644
index 0000000..f7c0050
--- /dev/null
+++ b/frontend/src/stores/user.ts
@@ -0,0 +1,123 @@
+// src/stores/user.ts
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import type { User, UserStatus, WorkSchedule } from '@/types'
+import { pb, getCurrentUser, isAuthenticated, logout as pbLogout } from '@/api/pocketbase'
+import { getUser, updateUserStatus, updateWorkSchedule } from '@/api/users'
+
+export const useUserStore = defineStore('user', () => {
+ // 状态
+ const user = ref(null)
+ const loading = ref(false)
+
+ // 计算属性
+ const isLoggedIn = computed(() => isAuthenticated() && user.value !== null)
+ const userStatus = computed(() => user.value?.status || 'away')
+ const userId = computed(() => user.value?.id || '')
+
+ // 初始化用户信息
+ async function initUser() {
+ if (!isAuthenticated()) {
+ user.value = null
+ return
+ }
+
+ try {
+ loading.value = true
+ const authUser = getCurrentUser() as User
+ // 获取完整用户信息
+ user.value = await getUser(authUser.id)
+ } catch (error) {
+ console.error('初始化用户信息失败:', error)
+ user.value = null
+ } finally {
+ loading.value = false
+ }
+ }
+
+ // 登录
+ async function login(email: string, password: string) {
+ try {
+ loading.value = true
+ await pb.collection('users').authWithPassword(email, password)
+ await initUser()
+ } catch (error: any) {
+ throw new Error(error.message || '登录失败')
+ } finally {
+ loading.value = false
+ }
+ }
+
+ // 注册
+ async function register(data: { email: string; password: string; passwordConfirm: string; username: string }) {
+ try {
+ loading.value = true
+ await pb.collection('users').create(data)
+ await login(data.email, data.password)
+ } catch (error: any) {
+ throw new Error(error.message || '注册失败')
+ } finally {
+ loading.value = false
+ }
+ }
+
+ // 登出
+ function logout() {
+ pbLogout()
+ user.value = null
+ }
+
+ // 更新状态
+ async function setStatus(status: UserStatus, note?: string) {
+ try {
+ const updated = await updateUserStatus(status, note)
+ if (user.value) {
+ user.value.status = status
+ user.value.statusNote = note
+ }
+ return updated
+ } catch (error: any) {
+ throw new Error(error.message || '更新状态失败')
+ }
+ }
+
+ // 更新工作时间
+ async function setWorkSchedule(schedule: WorkSchedule) {
+ try {
+ const updated = await updateWorkSchedule(schedule)
+ if (user.value) {
+ user.value.workdays = schedule.workdays
+ user.value.workStartTime = schedule.workStartTime
+ user.value.nextWorkTime = updated.nextWorkTime
+ }
+ return updated
+ } catch (error: any) {
+ throw new Error(error.message || '更新工作时间失败')
+ }
+ }
+
+ // 检查并自动更新工作时间状态
+ function checkWorkSchedule() {
+ if (!user.value || !user.value.nextWorkTime) return
+
+ const now = Math.floor(Date.now() / 1000)
+ if (now >= user.value.nextWorkTime && user.value.status === 'idle') {
+ setStatus('working')
+ }
+ }
+
+ return {
+ user,
+ loading,
+ isLoggedIn,
+ userStatus,
+ userId,
+ initUser,
+ login,
+ register,
+ logout,
+ setStatus,
+ setWorkSchedule,
+ checkWorkSchedule
+ }
+})
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
new file mode 100644
index 0000000..803d2f2
--- /dev/null
+++ b/frontend/src/types/index.ts
@@ -0,0 +1,128 @@
+// src/types/index.ts
+
+// 用户状态
+export type UserStatus = 'idle' | 'working' | 'in_team' | 'away'
+
+// 用户状态中文映射
+export const UserStatusMap: Record = {
+ idle: '空闲',
+ working: '工作中',
+ in_team: '组队中',
+ away: '离开'
+}
+
+// 用户状态图标
+export const UserStatusIcon: Record = {
+ idle: '🟢',
+ working: '🔴',
+ in_team: '🔵',
+ away: '⚫'
+}
+
+// 临时小组状态
+export type TeamStatus = 'recruiting' | 'playing' | 'finished' | 'dissolved'
+
+export const TeamStatusMap: Record = {
+ recruiting: '招募中',
+ playing: '游戏中',
+ finished: '已结束',
+ dissolved: '已解散'
+}
+
+// 邀请状态
+export type InviteStatus = 'pending' | 'accepted' | 'rejected'
+
+export const InviteStatusMap: Record = {
+ pending: '等待响应',
+ accepted: '已接受',
+ rejected: '已拒绝'
+}
+
+// 游戏平台
+export type GamePlatform = 'PC' | 'PS5' | 'Xbox' | 'Switch' | 'Mobile'
+
+// 用户
+export interface User {
+ id: string
+ username: string
+ email: string
+ avatar?: string
+ status: UserStatus
+ statusNote?: string
+ maxGroups: number
+ workdays: number[]
+ workStartTime: string
+ nextWorkTime?: number
+ points: number
+ created: string
+ updated: string
+}
+
+// 群组
+export interface Group {
+ id: string
+ name: string
+ description?: string
+ owner: string
+ members: string[]
+ maxMembers: number
+ created: string
+ updated: string
+ expand?: {
+ owner?: User
+ members?: User[]
+ }
+}
+
+// 临时小组
+export interface TeamSession {
+ id: string
+ name: string
+ sourceGroup: string
+ gameName: string
+ members: string[]
+ status: TeamStatus
+ dissolvedAt?: string
+ created: string
+ updated: string
+ expand?: {
+ members?: User[]
+ sourceGroup?: Group
+ }
+}
+
+// 邀请
+export interface Invitation {
+ id: string
+ from: string
+ to: string
+ teamSession: string
+ status: InviteStatus
+ rejectReason?: string
+ respondedAt?: 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
+}
diff --git a/frontend/src/views/GamesLibrary.vue b/frontend/src/views/GamesLibrary.vue
new file mode 100644
index 0000000..1db9b3b
--- /dev/null
+++ b/frontend/src/views/GamesLibrary.vue
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+
+
加载中...
+
+
+
+
+
+
+
+
{{ game.name }}
+
{{ game.platform }}
+
+ {{ tag }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/GroupView.vue b/frontend/src/views/GroupView.vue
new file mode 100644
index 0000000..46063e2
--- /dev/null
+++ b/frontend/src/views/GroupView.vue
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue
new file mode 100644
index 0000000..2b04d75
--- /dev/null
+++ b/frontend/src/views/Home.vue
@@ -0,0 +1,248 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 热门游戏
+
+
+ 加载中...
+
+
+
+
![]()
+
+ {{ game.name }}
+ {{ game.platform }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue
new file mode 100644
index 0000000..0e945f4
--- /dev/null
+++ b/frontend/src/views/Layout.vue
@@ -0,0 +1,197 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue
new file mode 100644
index 0000000..e3be83d
--- /dev/null
+++ b/frontend/src/views/Login.vue
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+
登录 Game Group
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/NotFound.vue b/frontend/src/views/NotFound.vue
new file mode 100644
index 0000000..bc3b66c
--- /dev/null
+++ b/frontend/src/views/NotFound.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
404
+
抱歉,您访问的页面不存在
+
+
+
+
+
+
diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue
new file mode 100644
index 0000000..97e43aa
--- /dev/null
+++ b/frontend/src/views/Profile.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Register.vue b/frontend/src/views/Register.vue
new file mode 100644
index 0000000..d40cdad
--- /dev/null
+++ b/frontend/src/views/Register.vue
@@ -0,0 +1,213 @@
+
+
+
+
+
+
+
注册 Game Group
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue
new file mode 100644
index 0000000..fff7a4c
--- /dev/null
+++ b/frontend/src/views/Settings.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
设置
+
+
+
+
+
工作时间
+
设置工作日和工作开始时间,系统会自动将你的状态设置为"工作中"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..80da540
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,15 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_PB_URL: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
+
+declare module '*.vue' {
+ import type { DefineComponent } from 'vue'
+ const component: DefineComponent<{}, {}, any>
+ export default component
+}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..e160696
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,26 @@
+/** @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: [],
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..4e5ea1b
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "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" }]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..42872c5
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..14d868d
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import path from 'node:path'
+
+export default defineConfig({
+ plugins: [vue()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src')
+ }
+ },
+ server: {
+ port: Number(process.env.VITE_PORT) || 5173,
+ proxy: {
+ '/api': {
+ target: process.env.VITE_PB_URL || 'http://192.168.1.14:8711',
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/api/, '')
+ }
+ }
+ }
+})
diff --git a/stop-all.sh b/stop-all.sh
new file mode 100644
index 0000000..57e938c
--- /dev/null
+++ b/stop-all.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+# 停止所有服务
+
+echo "🛑 停止所有服务..."
+
+docker compose -f docker-compose.backend.yml down
+docker compose -f docker-compose.dev.yml down
+docker compose -f docker-compose.uat.yml down
+
+echo "✅ 所有服务已停止"