diff --git a/backend/pb_migrations/1776421603_created_games.js b/backend/pb_migrations/1776421603_created_games.js new file mode 100644 index 0000000..8e80bbf --- /dev/null +++ b/backend/pb_migrations/1776421603_created_games.js @@ -0,0 +1,99 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "x5adjlc0txf16r8", + "created": "2026-04-17 10:26:43.079Z", + "updated": "2026-04-17 10:26:43.079Z", + "name": "games", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "name_field", + "name": "name", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 100, + "pattern": "" + } + }, + { + "system": false, + "id": "platform_field", + "name": "platform", + "type": "select", + "required": false, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "PC", + "PS5", + "Xbox", + "Switch", + "Mobile" + ] + } + }, + { + "system": false, + "id": "tags_field", + "name": "tags", + "type": "json", + "required": false, + "presentable": false, + "unique": false, + "options": { + "maxSize": 5000 + } + }, + { + "system": false, + "id": "cover_field", + "name": "cover", + "type": "url", + "required": false, + "presentable": false, + "unique": false, + "options": { + "exceptDomains": null, + "onlyDomains": null + } + }, + { + "system": false, + "id": "popular_field", + "name": "popularCount", + "type": "number", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "noDecimal": true + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776421645_created_groups.js b/backend/pb_migrations/1776421645_created_groups.js new file mode 100644 index 0000000..2802996 --- /dev/null +++ b/backend/pb_migrations/1776421645_created_groups.js @@ -0,0 +1,101 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "es63bkyiblpnxdf", + "created": "2026-04-17 10:27:25.626Z", + "updated": "2026-04-17 10:27:25.626Z", + "name": "groups", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "sf_name", + "name": "name", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 50, + "pattern": "" + } + }, + { + "system": false, + "id": "sf_desc", + "name": "description", + "type": "text", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 500, + "pattern": "" + } + }, + { + "system": false, + "id": "sf_owner", + "name": "owner", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_members", + "name": "members", + "type": "relation", + "required": false, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": null, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_max", + "name": "maxMembers", + "type": "number", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": 2, + "max": 100, + "noDecimal": true + } + } + ], + "indexes": [], + "listRule": "owner = @request.auth.id || members.id = @request.auth.id", + "viewRule": "owner = @request.auth.id || members.id = @request.auth.id", + "createRule": "@request.auth.id != \"\"", + "updateRule": "owner = @request.auth.id", + "deleteRule": "owner = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776421664_created_team_sessions.js b/backend/pb_migrations/1776421664_created_team_sessions.js new file mode 100644 index 0000000..b5ae9b5 --- /dev/null +++ b/backend/pb_migrations/1776421664_created_team_sessions.js @@ -0,0 +1,118 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "sac8t6o9rspld8p", + "created": "2026-04-17 10:27:44.146Z", + "updated": "2026-04-17 10:27:44.146Z", + "name": "team_sessions", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "sf_name", + "name": "name", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 100, + "pattern": "" + } + }, + { + "system": false, + "id": "sf_group", + "name": "sourceGroup", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "es63bkyiblpnxdf", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_game", + "name": "gameName", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 100, + "pattern": "" + } + }, + { + "system": false, + "id": "sf_members", + "name": "members", + "type": "relation", + "required": false, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": null, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_status", + "name": "status", + "type": "select", + "required": false, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "recruiting", + "playing", + "finished", + "dissolved" + ] + } + }, + { + "system": false, + "id": "sf_dissolved", + "name": "dissolvedAt", + "type": "date", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": "", + "max": "" + } + } + ], + "indexes": [], + "listRule": "sourceGroup.owner = @request.auth.id || sourceGroup.members.id = @request.auth.id", + "viewRule": "sourceGroup.owner = @request.auth.id || sourceGroup.members.id = @request.auth.id", + "createRule": "@request.auth.id != \"\"", + "updateRule": "sourceGroup.owner = @request.auth.id", + "deleteRule": "sourceGroup.owner = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("sac8t6o9rspld8p"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776424573_created_invitations.js b/backend/pb_migrations/1776424573_created_invitations.js new file mode 100644 index 0000000..c1bd483 --- /dev/null +++ b/backend/pb_migrations/1776424573_created_invitations.js @@ -0,0 +1,119 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "u7crt7qlso24l77", + "created": "2026-04-17 11:16:13.166Z", + "updated": "2026-04-17 11:16:13.166Z", + "name": "invitations", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "sf_from", + "name": "from", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_to", + "name": "to", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_session", + "name": "teamSession", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "sac8t6o9rspld8p", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_status", + "name": "status", + "type": "select", + "required": false, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "pending", + "accepted", + "rejected" + ] + } + }, + { + "system": false, + "id": "sf_reason", + "name": "rejectReason", + "type": "text", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 500, + "pattern": "" + } + }, + { + "system": false, + "id": "sf_responded", + "name": "respondedAt", + "type": "date", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": "", + "max": "" + } + } + ], + "indexes": [], + "listRule": "from = @request.auth.id || to = @request.auth.id", + "viewRule": "from = @request.auth.id || to = @request.auth.id", + "createRule": "@request.auth.id != \"\"", + "updateRule": "to = @request.auth.id", + "deleteRule": "from = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("u7crt7qlso24l77"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776424601_updated_users.js b/backend/pb_migrations/1776424601_updated_users.js new file mode 100644 index 0000000..82e53be --- /dev/null +++ b/backend/pb_migrations/1776424601_updated_users.js @@ -0,0 +1,194 @@ +/// +migrate((db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("_pb_users_auth_") + + // add + collection.schema.addField(new SchemaField({ + "system": false, + "id": "sf_status", + "name": "status", + "type": "select", + "required": false, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "idle", + "working", + "in_team", + "away" + ] + } + })) + + // add + collection.schema.addField(new SchemaField({ + "system": false, + "id": "sf_note", + "name": "statusNote", + "type": "text", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 200, + "pattern": "" + } + })) + + // add + collection.schema.addField(new SchemaField({ + "system": false, + "id": "sf_maxgroups", + "name": "maxGroups", + "type": "number", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": 1, + "max": 20, + "noDecimal": true + } + })) + + // add + collection.schema.addField(new SchemaField({ + "system": false, + "id": "sf_workdays", + "name": "workdays", + "type": "json", + "required": false, + "presentable": false, + "unique": false, + "options": { + "maxSize": 500 + } + })) + + // add + collection.schema.addField(new SchemaField({ + "system": false, + "id": "sf_workstart", + "name": "workStartTime", + "type": "text", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 5, + "pattern": "" + } + })) + + // add + collection.schema.addField(new SchemaField({ + "system": false, + "id": "sf_nextwork", + "name": "nextWorkTime", + "type": "number", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "noDecimal": true + } + })) + + // add + collection.schema.addField(new SchemaField({ + "system": false, + "id": "sf_points", + "name": "points", + "type": "number", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "noDecimal": true + } + })) + + // update + collection.schema.addField(new SchemaField({ + "system": false, + "id": "users_avatar", + "name": "avatar", + "type": "file", + "required": false, + "presentable": false, + "unique": false, + "options": { + "mimeTypes": [ + "image/png", + "image/jpeg", + "image/svg+xml", + "image/gif" + ], + "thumbs": null, + "maxSelect": 1, + "maxSize": 5242880, + "protected": false + } + })) + + return dao.saveCollection(collection) +}, (db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("_pb_users_auth_") + + // remove + collection.schema.removeField("sf_status") + + // remove + collection.schema.removeField("sf_note") + + // remove + collection.schema.removeField("sf_maxgroups") + + // remove + collection.schema.removeField("sf_workdays") + + // remove + collection.schema.removeField("sf_workstart") + + // remove + collection.schema.removeField("sf_nextwork") + + // remove + collection.schema.removeField("sf_points") + + // update + collection.schema.addField(new SchemaField({ + "system": false, + "id": "users_avatar", + "name": "avatar", + "type": "file", + "required": false, + "presentable": false, + "unique": false, + "options": { + "mimeTypes": [ + "image/jpeg", + "image/png", + "image/svg+xml", + "image/gif", + "image/webp" + ], + "thumbs": null, + "maxSelect": 1, + "maxSize": 5242880, + "protected": false + } + })) + + return dao.saveCollection(collection) +}) diff --git a/docs/plans/2026-04-17-fix-and-beautify.md b/docs/plans/2026-04-17-fix-and-beautify.md new file mode 100644 index 0000000..c98fd91 --- /dev/null +++ b/docs/plans/2026-04-17-fix-and-beautify.md @@ -0,0 +1,881 @@ +# 一期修复 + UI 美化实施计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 修复后端核心流程断裂和安全问题,补齐缺失组件,全面美化前端 UI 使其达到可用的 MVP 水平。 + +**Architecture:** 后端修复 PocketBase hooks 中无效 API 调用和缺失的业务逻辑,补全 migration 和访问控制。前端基于 Element Plus + CSS 变量建立统一设计系统,重构 Layout 为侧边栏布局,美化所有页面。 + +**Tech Stack:** PocketBase 0.22.4, Vue 3 + TypeScript, Element Plus, Tailwind CSS (已安装但未启用) + +--- + +## Phase 1: 后端 Critical 修复(5 个 Task) + +### Task 1: 恢复 Migration 文件 + +**Files:** +- Move: `backend/pb_migrations.bak/go.mod` → `backend/pb_hooks/go.mod`(如需要) +- Move: `backend/pb_migrations.bak/main.go` → 确认是否为旧 hook 代码 +- Create: `backend/pb_migrations/1738717600_init.pb.js` + +**Step 1: 清理 pb_migrations.bak** + +`pb_migrations.bak/` 里的 `go.mod` 和 `main.go` 是错误放置的文件。确认 `pb_hooks/` 目录已有完整的 `go.mod` 和 `main.go`,则 `.bak` 内容可忽略。 + +**Step 2: 创建正式 migration 文件** + +创建 `backend/pb_migrations/1738717600_init.pb.js`,基于 PocketBase 0.22.4 的 JS migration API。注意:PocketBase 0.22.x 使用 `migrate()` 函数和 `app.save(collection)` 而非旧版 API。实际集合已在 `pb_data/data.db` 中存在,此 migration 作为可重建备份。 + +但由于当前数据库已包含集合定义,**不建议用 JS migration 重建**。更好的方案: + +1. 确认 `pb_data/data.db` 中集合定义完整 +2. 在 README 中记录"数据库通过管理后台创建,首次需手动建表" +3. 清空 `pb_migrations.bak/` 目录 + +**Step 3: 提交** + +```bash +git add -A backend/ +git commit -m "fix: clean up migration directory structure" +``` + +--- + +### Task 2: 修复后端 Hooks — 删除无效 API + 修复 Invitation 逻辑 + +**Files:** +- Modify: `backend/pb_hooks/main.go` (全文重写) + +**Step 1: 重写 main.go** + +删除所有 `app.Subscriptions().*` 调用(PocketBase 0.22.x 无此 API)。修复 invitation 创建逻辑:将 `group` 字段引用改为 `teamSession`。添加 invitation accept hook。 + +```go +package main + +import ( + "log" + + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" +) + +func main() { + app := pocketbase.New() + + // ── Groups ── + + app.OnRecordBeforeCreateRequest("groups").Add(func(e *core.RecordCreateEvent) error { + authRecord, _ := e.HttpContext.AuthRecord() + if authRecord == nil { + return apis.NewForbiddenError("需要登录", nil) + } + e.Record.Set("owner", authRecord.Id) + e.Record.Set("members", []string{authRecord.Id}) + return nil + }) + + app.OnRecordBeforeUpdateRequest("groups").Add(func(e *core.RecordUpdateEvent) error { + authRecord, _ := e.HttpContext.AuthRecord() + if authRecord == nil { + return apis.NewForbiddenError("需要登录", nil) + } + // 保护 owner 字段不被篡改 + originalOwner, _ := app.Dao().FindRecordById("groups", e.Record.Id) + if originalOwner.GetString("owner") != authRecord.Id { + return apis.NewForbiddenError("只有群组所有者可以更新群组", nil) + } + e.Record.Set("owner", originalOwner.GetString("owner")) + + // 验证 members 数量 + members := e.Record.GetStringSlice("members") + maxMembers := e.Record.GetInt("maxMembers") + if maxMembers > 0 && len(members) > maxMembers { + return apis.NewBadRequestError("成员数量超过上限", nil) + } + return nil + }) + + app.OnRecordBeforeDeleteRequest("groups").Add(func(e *core.RecordDeleteEvent) error { + authRecord, _ := e.HttpContext.AuthRecord() + if authRecord == nil { + return apis.NewForbiddenError("需要登录", nil) + } + isOwner, err := isGroupOwner(app, e.Record.Id, authRecord.Id) + if err != nil || !isOwner { + return apis.NewForbiddenError("只有群组所有者可以删除群组", nil) + } + return nil + }) + + // ── Team Sessions ── + + app.OnRecordBeforeCreateRequest("team_sessions").Add(func(e *core.RecordCreateEvent) error { + authRecord, _ := e.HttpContext.AuthRecord() + if authRecord == nil { + return apis.NewForbiddenError("需要登录", nil) + } + groupId := e.Record.GetString("sourceGroup") + isMember, err := isGroupMember(app, groupId, authRecord.Id) + if err != nil || !isMember { + return apis.NewForbiddenError("只有群组成员可以创建团队会话", nil) + } + return nil + }) + + // ── Invitations ── + + app.OnRecordBeforeCreateRequest("invitations").Add(func(e *core.RecordCreateEvent) error { + authRecord, _ := e.HttpContext.AuthRecord() + if authRecord == nil { + return apis.NewForbiddenError("需要登录", nil) + } + // 强制设置 from 为当前用户 + e.Record.Set("from", authRecord.Id) + e.Record.Set("status", "pending") + return nil + }) + + // 接受邀请:自动加入群组成员 + app.OnRecordBeforeUpdateRequest("invitations").Add(func(e *core.RecordUpdateEvent) error { + authRecord, _ := e.HttpContext.AuthRecord() + if authRecord == nil { + return apis.NewForbiddenError("需要登录", nil) + } + // 只有 recipient 可以更新邀请 + if e.Record.GetString("to") != authRecord.Id { + return apis.NewForbiddenError("无权操作此邀请", nil) + } + + newStatus := e.Record.GetString("status") + if newStatus == "accepted" { + teamSessionId := e.Record.GetString("teamSession") + teamSession, err := app.Dao().FindRecordById("team_sessions", teamSessionId) + if err != nil { + return apis.NewNotFoundError("临时小组不存在", nil) + } + // 将用户加入临时小组 members + members := teamSession.GetStringSlice("members") + for _, m := range members { + if m == authRecord.Id { + goto afterJoin + } + } + members = append(members, authRecord.Id) + teamSession.Set("members", members) + if err := app.Dao().SaveRecord(teamSession); err != nil { + return apis.NewBadRequestError("加入临时小组失败", nil) + } + afterJoin: + + // 更新用户状态为 in_team + user, err := app.Dao().FindRecordById("users", authRecord.Id) + if err == nil { + user.Set("status", "in_team") + app.Dao().SaveRecord(user) + } + } + return nil + }) + + if err := app.Start(); err != nil { + log.Fatal(err) + } +} + +func isGroupMember(app *pocketbase.PocketBase, groupId string, userId string) (bool, error) { + group, err := app.Dao().FindRecordById("groups", groupId) + if err != nil { + return false, err + } + members := group.GetStringSlice("members") + for _, member := range members { + if member == userId { + return true, nil + } + } + return false, nil +} + +func isGroupOwner(app *pocketbase.PocketBase, groupId string, userId string) (bool, error) { + group, err := app.Dao().FindRecordById("groups", groupId) + if err != nil { + return false, err + } + return group.GetString("owner") == userId, nil +} +``` + +**Step 2: 移除 README 中的默认凭据** + +修改 `backend/README.md`,删除 `(admin/admin)` 部分。 + +**Step 3: 提交** + +```bash +git add backend/pb_hooks/main.go backend/README.md +git commit -m "fix: rewrite hooks - remove invalid API calls, fix invitation accept flow, add access control" +``` + +--- + +### Task 3: 修复 Invitation 字段不一致 + +**Files:** +- Verify: `backend/pb_data/data.db` 中 `invitations` 集合的字段名 + +**Step 1: 确认数据库 schema** + +通过 PocketBase 管理后台确认 `invitations` 集合中: +- `from` 字段存在且为 relation → users +- `to` 字段存在且为 relation → users +- `teamSession` 字段存在且为 relation → team_sessions +- `status` 为 select (pending/accepted/rejected) +- `rejectReason` 为 text + +**Step 2: 确认 team_sessions 集合** + +确认字段名是 `team_sessions`(PocketBase 会将 camelCase 转为 snake_case)。前端 API 中使用 `teamSessions`,需要确认 PocketBase 客户端会自动转换。 + +**Step 3: 提交** + +如有字段名修复: +```bash +git add backend/ +git commit -m "fix: verify and align collection field names" +``` + +--- + +### Task 4: 修复前端 API 层与后端对齐 + +**Files:** +- Modify: `frontend/src/api/invitations.ts` +- Modify: `frontend/src/api/sessions.ts` + +**Step 1: 修复 invitations.ts** + +移除客户端权限校验(后端已处理)。移除 `respondInvitation` 中直接操作 `teamSession.members` 的代码(后端 hook 已处理)。 + +```typescript +// 响应邀请 +export async function respondInvitation( + invitationId: string, + response: 'accepted' | 'rejected', + rejectReason?: string +) { + const updateData: Record = { + status: response, + respondedAt: new Date().toISOString() + } + + if (response === 'rejected' && rejectReason) { + updateData.rejectReason = rejectReason + } + + await pb.collection('invitations').update(invitationId, updateData) + + if (response === 'accepted') { + // 后端 hook 会自动处理:加入 team members + 更新用户状态 + // 前端只需刷新本地状态 + } + + return updateData +} +``` + +**Step 2: 修复 sessions.ts 中的 endGame** + +`endGame` 函数直接更新所有成员状态,但后端无对应权限。改为仅解散小组,状态恢复由后端处理或由各用户自行操作。 + +```typescript +export async function endGame(sessionId: string) { + return updateTeamStatus(sessionId, 'dissolved') +} +``` + +**Step 3: 提交** + +```bash +git add frontend/src/api/invitations.ts frontend/src/api/sessions.ts +git commit -m "fix: align frontend API with backend hooks, remove client-side permission checks" +``` + +--- + +### Task 5: 修复前端组件 Bug + +**Files:** +- Modify: `frontend/src/views/Home.vue` +- Modify: `frontend/src/views/GroupView.vue` +- Modify: `frontend/src/components/team/IdleMembersList.vue` + +**Step 1: 修复 Home.vue 中的组件名** + +```html + + + + + +``` + +**Step 2: 修复 GroupView.vue 中的组件名** + +```html + + + + + + + +``` + +**Step 3: 修复 IdleMembersList.vue 中 inviteMember 的空 teamSession** + +当前 `inviteMember` 传入 `teamSession: ''`,需要先检查或创建 team session。暂时改为:如果没有活跃 team session,提示用户先在 Home 页面创建。 + +```typescript +async function inviteMember(userId: string, username: string) { + // 检查是否有活跃的 team session + const { getActiveTeamSession } = await import('@/api/sessions') + const session = await getActiveTeamSession() + + if (!session) { + ElMessage.warning('请先在首页创建临时小组') + return + } + + try { + await sendInvitation({ + to: userId, + teamSession: session.id + }) + ElMessage.success(`已邀请 ${username}`) + } catch (error: any) { + ElMessage.error(error.message || '邀请失败') + } +} +``` + +**Step 4: 提交** + +```bash +git add frontend/src/views/Home.vue frontend/src/views/GroupView.vue frontend/src/components/team/IdleMembersList.vue +git commit -m "fix: component naming, invitation flow requires active team session" +``` + +--- + +## Phase 2: 补齐缺失组件(3 个 Task) + +### Task 6: 创建 Notification Store 和 NotificationPanel 组件 + +**Files:** +- Create: `frontend/src/stores/notification.ts` +- Create: `frontend/src/components/common/NotificationPanel.vue` + +**Step 1: 创建 notification store** + +```typescript +// src/stores/notification.ts +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { Invitation } from '@/types' +import { getPendingInvitations } from '@/api/invitations' + +export const useNotificationStore = defineStore('notification', () => { + const pendingInvitations = ref([]) + const loading = ref(false) + + const unreadCount = computed(() => pendingInvitations.value.length) + + async function loadPendingInvitations() { + try { + loading.value = true + pendingInvitations.value = await getPendingInvitations() + } catch (error) { + console.error('加载待处理邀请失败:', error) + } finally { + loading.value = false + } + } + + function removeInvitation(invitationId: string) { + pendingInvitations.value = pendingInvitations.value.filter(i => i.id !== invitationId) + } + + return { + pendingInvitations, + loading, + unreadCount, + loadPendingInvitations, + removeInvitation + } +}) +``` + +**Step 2: 创建 NotificationPanel 组件** + +使用 `ElDrawer` 显示待处理邀请列表,内嵌 `InvitationCard` 组件。 + +**Step 3: 集成到 Layout.vue** + +在 Layout 中导入 NotificationStore,通知铃铛按钮打开 NotificationPanel。 + +**Step 4: 提交** + +```bash +git add frontend/src/stores/notification.ts frontend/src/components/common/NotificationPanel.vue frontend/src/views/Layout.vue +git commit -m "feat: add notification store and panel with invitation handling" +``` + +--- + +### Task 7: 创建 GameSelectDialog 组件 + +**Files:** +- Create: `frontend/src/components/team/GameSelectDialog.vue` + +**Step 1: 创建游戏选择弹窗** + +使用 `ElDialog` + 搜索框 + 游戏列表。允许用户选择游戏或手动输入游戏名。搜索调用 `searchGames` API,结果以列表形式展示。 + +**Step 2: 提交** + +```bash +git add frontend/src/components/team/GameSelectDialog.vue +git commit -m "feat: add GameSelectDialog for team creation flow" +``` + +--- + +### Task 8: 创建 InviteButton 组件 + +**Files:** +- Create: `frontend/src/components/team/InviteButton.vue` + +**Step 1: 创建邀请按钮组件** + +逻辑: +1. 如果有活跃 team session → 直接发送邀请 +2. 如果没有 → 打开 GameSelectDialog → 创建 team session → 发送邀请 + +**Step 2: 更新 IdleMembersList 使用 InviteButton** + +替换当前的直接邀请按钮,改为使用 `InviteButton` 组件。 + +**Step 3: 提交** + +```bash +git add frontend/src/components/team/InviteButton.vue frontend/src/components/team/IdleMembersList.vue +git commit -m "feat: add InviteButton with auto team session creation" +``` + +--- + +## Phase 3: UI 美化 — 设计系统 + Layout(3 个 Task) + +### Task 9: 建立全局设计系统和主题变量 + +**Files:** +- Create: `frontend/src/assets/design.css` +- Modify: `frontend/src/main.ts`(引入 design.css) +- Modify: `frontend/tailwind.config.js`(启用 Tailwind 并配置主题色) + +**Step 1: 创建 design.css** + +定义 CSS 自定义属性:主色调、圆角、阴影、间距、渐变等。覆盖 Element Plus 的默认主题变量使其与游戏主题协调。 + +```css +:root { + /* 主色调:深空蓝 + 霓虹紫 */ + --gg-primary: #6366f1; + --gg-primary-light: #818cf8; + --gg-primary-dark: #4f46e5; + --gg-accent: #a855f7; + --gg-bg: #0f172a; + --gg-bg-card: #1e293b; + --gg-bg-hover: #334155; + --gg-text: #f1f5f9; + --gg-text-secondary: #94a3b8; + --gg-border: #334155; + --gg-success: #22c55e; + --gg-danger: #ef4444; + --gg-warning: #f59e0b; + + --gg-radius-sm: 8px; + --gg-radius-md: 12px; + --gg-radius-lg: 16px; + --gg-shadow: 0 4px 24px rgba(0, 0, 0, 0.3); + --gg-gradient: linear-gradient(135deg, #6366f1 0%, #a855f7 100%); + + /* 覆盖 Element Plus 变量 */ + --el-color-primary: #6366f1; + --el-color-primary-light-3: #818cf8; + --el-color-primary-light-5: #a5b4fc; + --el-color-primary-light-7: #c7d2fe; + --el-color-primary-light-9: #eef2ff; + --el-color-primary-dark-2: #4f46e5; + --el-bg-color: #1e293b; + --el-bg-color-page: #0f172a; + --el-bg-color-overlay: #1e293b; + --el-text-color-primary: #f1f5f9; + --el-text-color-regular: #e2e8f0; + --el-text-color-secondary: #94a3b8; + --el-border-color: #334155; + --el-fill-color: #334155; + --el-fill-color-light: #1e293b; + --el-fill-color-lighter: #1e293b; +} + +/* 全局暗色模式基础 */ +body { + background: var(--gg-bg); + color: var(--gg-text); + font-family: 'Inter', system-ui, -apple-system, sans-serif; +} + +/* Element Plus 暗色模式覆盖 */ +.el-card { + background: var(--gg-bg-card) !important; + border-color: var(--gg-border) !important; + color: var(--gg-text) !important; +} + +.el-dialog { + background: var(--gg-bg-card) !important; + border-color: var(--gg-border) !important; +} + +.el-input__wrapper { + background: var(--gg-bg) !important; + box-shadow: 0 0 0 1px var(--gg-border) inset !important; +} + +.el-input__inner { + color: var(--gg-text) !important; +} + +.el-dropdown-menu { + background: var(--gg-bg-card) !important; + border-color: var(--gg-border) !important; +} + +.el-dropdown-menu__item { + color: var(--gg-text) !important; +} + +.el-dropdown-menu__item:hover { + background: var(--gg-bg-hover) !important; + color: var(--gg-primary) !important; +} + +/* 状态颜色 */ +.status-idle { color: #22c55e; } +.status-working { color: #ef4444; } +.status-in_team { color: #3b82f6; } +.status-away { color: #6b7280; } +``` + +**Step 2: 更新 tailwind.config.js** + +启用深色主题色系,与 CSS 变量对齐。 + +**Step 3: 在 main.ts 中引入** + +```typescript +import './assets/design.css' +``` + +**Step 4: 提交** + +```bash +git add frontend/src/assets/design.css frontend/src/main.ts frontend/tailwind.config.js +git commit -m "feat: establish dark theme design system with Element Plus overrides" +``` + +--- + +### Task 10: 重构 Layout 为游戏风格侧边栏布局 + +**Files:** +- Rewrite: `frontend/src/views/Layout.vue` + +**Step 1: 重写 Layout.vue** + +布局结构改为: + +``` +┌─────────┬──────────────────────────────────┐ +│ Sidebar │ Header │ +│ (240px) │──────────────────────────────────│ +│ │ │ +│ Logo │ Main Content │ +│ Nav │ │ +│ Groups │ │ +│ Status │ │ +│ User │ │ +└─────────┴──────────────────────────────────┘ +``` + +左侧边栏包含: +- Logo(渐变文字) +- 导航菜单(首页、游戏库) +- 群组列表 +- 底部:用户状态 + 工作时间按钮 + 用户头像 + +右侧:顶部通知栏 + 通知面板,下方 router-view + +全部使用深色主题配色。 + +**Step 2: 提交** + +```bash +git add frontend/src/views/Layout.vue +git commit -m "feat: redesign Layout with game-style dark sidebar" +``` + +--- + +### Task 11: 美化登录/注册页面 + +**Files:** +- Rewrite: `frontend/src/views/Login.vue` +- Rewrite: `frontend/src/views/Register.vue` + +**Step 1: 美化 Login.vue** + +保持深色主题:全屏渐变背景 + 毛玻璃卡片 + 游戏风格图标/标题。表单输入框使用深色风格。登录按钮使用渐变色。 + +关键设计元素: +- 背景:深空渐变 `#0f172a → #1e1b4b` 带粒子/星星 CSS 效果 +- 卡片:半透明 `backdrop-filter: blur` 毛玻璃效果 +- 标题:带发光效果的 "Game Group" logo +- 输入框:深色背景 + 紫色聚焦边框 +- 按钮:渐变色 + hover 发光 + +**Step 2: 同样美化 Register.vue** + +与 Login 保持一致的视觉风格。 + +**Step 3: 提交** + +```bash +git add frontend/src/views/Login.vue frontend/src/views/Register.vue +git commit -m "feat: beautify login/register with dark gaming theme" +``` + +--- + +## Phase 4: UI 美化 — 内容页面(4 个 Task) + +### Task 12: 美化 Home 页面 + +**Files:** +- Rewrite: `frontend/src/views/Home.vue` + +**Step 1: 重构 Home 布局** + +从三栏改为更现代的卡片式布局: + +``` +┌────────────────────────────────────────┐ +│ 欢迎回来, {username}! [状态指示器] │ +├────────────────────┬───────────────────┤ +│ 我的群组卡片 │ 当前临时小组 │ +│ (网格 2 列) │ (TeamSession) │ +│ │ │ +├────────────────────┴───────────────────┤ +│ 热门游戏 (横向滚动卡片) │ +└────────────────────────────────────────┘ +``` + +设计要点: +- 群组卡片:深色底 + 悬浮发光边框 + 成员头像叠放 +- 游戏卡片:封面图 + 渐变遮罩 + 游戏名 +- 使用 CSS Grid 和 Flexbox + +**Step 2: 提交** + +```bash +git add frontend/src/views/Home.vue +git commit -m "feat: redesign Home with card-based layout" +``` + +--- + +### Task 13: 美化 GroupView 页面 + +**Files:** +- Rewrite: `frontend/src/views/GroupView.vue` + +**Step 1: 重构 GroupView** + +``` +┌────────────────────────────────────────────┐ +│ {群组名} [群主标记] │ +│ {描述} · {N} 成员 │ +├──────────────────────┬─────────────────────┤ +│ 空闲成员列表 │ 当前临时小组 │ +│ (卡片列表) │ (TeamSession) │ +│ - 头像 + 状态 │ - 成员头像列表 │ +│ - 邀请按钮 │ - 游戏名 │ +│ │ - 结束按钮 │ +├──────────────────────┤ │ +│ 所有成员 │ │ +│ (按状态分组显示) │ │ +└──────────────────────┴─────────────────────┘ +``` + +设计要点: +- 成员按状态分组(空闲/组队中/工作中/离开),每组不同颜色标识 +- 邀请按钮用紫色渐变 + 悬浮动画 +- 临时小组面板用发光边框 + +**Step 2: 提交** + +```bash +git add frontend/src/views/GroupView.vue +git commit -m "feat: redesign GroupView with status-grouped members" +``` + +--- + +### Task 14: 美化组件库 + +**Files:** +- Rewrite: `frontend/src/components/team/TeamSessionPanel.vue` +- Rewrite: `frontend/src/components/team/IdleMembersList.vue` +- Rewrite: `frontend/src/components/team/InvitationCard.vue` +- Rewrite: `frontend/src/components/team/StatusToggle.vue` +- Rewrite: `frontend/src/components/team/WorkScheduleModal.vue` + +**Step 1: 美化 TeamSessionPanel** + +- 深色卡片 + 紫色发光边框 +- 成员头像使用在线状态指示灯 +- 游戏结束按钮改为红色渐变 + 确认动画 + +**Step 2: 美化 IdleMembersList** + +- 成员卡片使用深色悬浮效果 +- 邀请按钮使用紫色渐变 +- 空状态使用游戏风格图标 + +**Step 3: 美化 InvitationCard** + +- 使用深色卡片 + 发光效果 +- 接受按钮绿色渐变,拒绝按钮灰色 +- 游戏信息区域使用渐变遮罩 + +**Step 4: 美化 StatusToggle** + +- 下拉菜单使用深色主题 +- 当前状态显示带颜色的指示灯(非 emoji,改用 CSS 圆点) + +**Step 5: 美化 WorkScheduleModal** + +- 弹窗深色背景 +- 复选框和时间选择器使用自定义样式 + +**Step 6: 提交** + +```bash +git add frontend/src/components/team/ +git commit -m "feat: beautify all team components with dark gaming theme" +``` + +--- + +### Task 15: 美化其他页面 + +**Files:** +- Rewrite: `frontend/src/views/GamesLibrary.vue` +- Rewrite: `frontend/src/views/Profile.vue` +- Rewrite: `frontend/src/views/Settings.vue` +- Rewrite: `frontend/src/views/NotFound.vue` + +**Step 1: 美化 GamesLibrary** + +- 游戏卡片使用深色底 + 封面图 + 渐变遮罩文字 +- 搜索框深色风格 +- 平台筛选标签使用彩色徽章 + +**Step 2: 美化 Profile** + +- 深色卡片布局 +- 头像区域使用渐变背景 +- 状态显示使用彩色指示灯 + +**Step 3: 美化 Settings** + +- 设置项使用深色卡片 + 紫色强调色 +- 按钮使用渐变色 + +**Step 4: 美化 NotFound** + +- 游戏风格的 404 页面 +- 返回按钮使用渐变色 + +**Step 5: 提交** + +```bash +git add frontend/src/views/GamesLibrary.vue frontend/src/views/Profile.vue frontend/src/views/Settings.vue frontend/src/views/NotFound.vue +git commit -m "feat: beautify remaining pages with dark gaming theme" +``` + +--- + +## Phase 5: 集成验证(1 个 Task) + +### Task 16: 全流程验证 + +**Step 1: 启动后端** + +```bash +cd backend && docker-compose up -d +``` + +**Step 2: 启动前端** + +```bash +cd frontend && npm run dev +``` + +**Step 3: 验证核心流程** + +1. 注册新用户 → 确认注册页面正常 +2. 登录 → 确认跳转到首页 +3. 创建群组 → 确认群组出现在侧边栏 +4. 切换状态 → 确认状态更新 +5. 查看空闲成员 → 确认列表正确 +6. 邀请成员 → 确认邀请流程(创建 team session → 发送邀请) +7. 接受邀请 → 确认用户加入 team session + 状态变为 in_team +8. 结束游戏 → 确认 team session 解散 +9. 检查所有页面 UI → 确认深色主题一致性 + +**Step 4: 修复验证中发现的问题** + +**Step 5: 最终提交** + +```bash +git add -A +git commit -m "fix: address issues found during integration testing" +``` + +--- + +## 执行优先级 + +| 优先级 | Phase | 说明 | +|--------|-------|------| +| P0 | Phase 1 | 后端 Critical 修复,不修则核心流程不通 | +| P1 | Phase 2 | 补齐缺失组件,否则前端功能不完整 | +| P2 | Phase 3-4 | UI 美化,提升可用性和观感 | +| P3 | Phase 5 | 集成验证 | + +**预计工作量:** 16 个 Task,建议分 3-4 个执行批次。