diff --git a/CLAUDE.md b/CLAUDE.md index 3012169..199561a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,71 +9,80 @@ Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队 ## 开发命令 ```bash -# 前端开发(默认连接 localhost:8090 的 PocketBase) -cd frontend && npm run dev - -# 前端开发环境(连接远程后端 192.168.1.14:8711,端口 7033) -cd frontend && npm run dev:dev - -# 前端 UAT 环境(端口 7034) -cd frontend && npm run dev:uat - # 构建前端 cd frontend && npm run build -# 启动后端(PocketBase,端口 8711) -cd backend && docker-compose up -d +# 本地开发(一般不用,用 Docker 部署代替) +cd frontend && npm run dev # 部署脚本(根目录) -./deploy-backend.sh # 部署后端 -./deploy-dev.sh # 部署 Dev 前端 -./deploy-uat.sh # 部署 UAT 前端 +./deploy-backend.sh # 部署 PocketBase 后端 +./deploy-dev.sh # 构建 + 部署 Dev 前端 (端口 7033) +./deploy-uat.sh # 构建 + 部署 UAT 前端 + 后端 (端口 7034/8712) ./stop-all.sh # 停止所有服务 ``` +**重要**: 不要在本地启动 vite dev server,使用 Docker 部署后通过端口访问测试。Dev 环境在 `http://192.168.1.14:7033`。部署到 UAT 前必须等用户确认。 + ## 技术栈 - **后端**: PocketBase 0.22.4 (Docker, `ghcr.io/muchobien/pocketbase`) — 无自定义 JS hooks,业务逻辑全在前端 - **前端**: Vue 3 + TypeScript + Pinia + Element Plus + Tailwind CSS + Vite -- **API 通信**: PocketBase JS SDK (`pocketbase` npm 包),cookie 认证 +- **API 通信**: PocketBase JS SDK (`pocketbase` npm 包),localStorage 持久化认证 - **实时通信**: PocketBase realtime subscriptions +- **样式**: 自定义 CSS 变量 (`--gg-*` 前缀, `design.css`) + Tailwind + Element Plus,绿色主题 + +## 环境与端口 + +| 服务 | Dev | UAT | +|------|-----|-----| +| 前端 (nginx) | 7033 | 7034 | +| PocketBase | 8090 | 8712 | + +Docker Compose 文件:`docker-compose.backend.yml`、`docker-compose.dev.yml`、`docker-compose.uat.yml`,共享 `gamegroup-net` 网络。 ## 架构 -### 前端目录结构 (`frontend/src/`) +### 前端核心流程 -- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`),封装 PocketBase CRUD 和过滤逻辑。`pocketbase.ts` 初始化客户端并导出认证工具函数 -- **`stores/`** — Pinia stores(`user`, `group`, `team`, `notification`),组合式 API 风格(`defineStore('name', () => {...})`) +``` +pocketbase.ts (PB 客户端初始化) + → router guards (isAuthenticated 检查) + → stores (user/group/team/notification) + → api/ (PocketBase CRUD 封装) + → components + views +``` + +- **`api/pocketbase.ts`** — 单例 PocketBase 客户端,导出 `pb`、`getCurrentUser()`、`isAuthenticated()`、`logout()` +- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`),封装 CRUD 和过滤逻辑 +- **`stores/`** — Pinia stores,组合式 API 风格(`defineStore('name', () => {...})`) - **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理 -- **`views/`** — 页面级组件,路由懒加载 -- **`components/`** — 按领域分子目录:`common/`, `game/`, `group/`, `layout/`, `team/` -- **`types/index.ts`** — 所有 TypeScript 接口和类型定义集中在一个文件 +- **`types/index.ts`** — 所有接口集中定义 + `displayName()` 工具函数 + +### 认证流程 + +- 注册:用户输入中文昵称存 `name` 字段,`username` 自动生成 ASCII 标识(`'u' + Date.now().toString(36) + random`) +- 登录:支持昵称/邮箱/username 登录。输入不含 `@` 时查询 `users` collection 的 `name`/`username` 字段,获取 `username` 后调用 `authWithPassword(username, password)` +- 路由守卫:`requiresAuth` 跳转登录页,`requiresGuest` 跳转首页 + +### Vite 代理 vs Nginx + +开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。 ### 数据模型(PocketBase Collections) -- **users** — 用户,含状态(idle/working/in_team/away)、工作时间设定、积分 -- **groups** — 群组,owner + members 关系,支持审核加入(requireApproval) -- **team_sessions** — 临时组队,关联 sourceGroup,状态流转:recruiting → playing → finished/dissolved -- **invitations** — 组队邀请,from/to 用户,pending/accepted/rejected +- **users** — 认证集合。`username` 是系统字段(不可改,仅 `[a-z0-9_-]`),中文昵称存 `name` 字段。状态:idle/working/in_team/away +- **groups** — owner + members 关系,支持审核加入(requireApproval) +- **team_sessions** — 临时组队,状态流转:recruiting → playing → finished/dissolved +- **invitations** — 组队邀请,pending/accepted/rejected - **games** — 游戏库,归属 group,含平台、标签、封面 -- **game_comments** / **game_favorites** — 游戏评论和收藏 -- **join_requests** — 入群申请,pending/approved/rejected +- **game_comments** / **game_favorites** — 评论和收藏 +- **join_requests** — 入群申请 -### 关键模式 +### PocketBase 注意事项 -- **Vite 代理**: 开发时 `/api` 代理到 PocketBase,路径重写去掉 `/api` 前缀(`vite.config.ts`) -- **认证流程**: `pocketbase.ts` → cookie 持久化 → 路由守卫检查 `isAuthenticated()` → 未登录跳转 `/login` -- **实时订阅**: 通过 `useRealtime` composable 和 `subscribe*` 函数订阅 PocketBase 变更事件,各 store 自行刷新数据 -- **样式系统**: 自定义 CSS 变量(`design.css`,`--gg-*` 前缀)+ Tailwind + Element Plus,主题色为绿色系 - -### 后端 - -- PocketBase 数据迁移在 `backend/pb_migrations/`,由管理面板操作自动生成 -- `backend/pb_hooks/main.js` 为占位文件,当前镜像不支持 JS VM -- 所有业务逻辑(如入群审批、组队邀请)在前端 API 层实现 - -## 环境配置 - -前端通过 `.env` 文件配置: -- `VITE_PB_URL` — PocketBase 地址(默认 `window.location.origin`) -- `VITE_PORT` — 开发服务器端口 +- `users` collection 的 `listRule`/`viewRule` 设为空字符串(公开),以支持登录页查询用户 +- Auth collection 的 `email` 字段不对未认证请求暴露,登录查找用 `username` 替代 +- 数据迁移在 `backend/pb_migrations/`,由管理面板操作自动生成。**不要**为 `username` 等系统字段创建 `addField` 迁移,会导致 `duplicate column` 错误 +- PocketBase 管理面板:`admin@example.com` / `admin123456` +- 前端 `.env` 文件:`VITE_PB_URL` 配置后端地址,`VITE_PORT` 配置开发端口 diff --git a/backend/pb_migrations/1776500001_created_polls.js b/backend/pb_migrations/1776500001_created_polls.js new file mode 100644 index 0000000..5209fea --- /dev/null +++ b/backend/pb_migrations/1776500001_created_polls.js @@ -0,0 +1,151 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "polls_collection", + "created": "2026-04-18 00:00:01.000Z", + "updated": "2026-04-18 00:00:01.000Z", + "name": "polls", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "sf_group", + "name": "group", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "es63bkyiblpnxdf", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_creator", + "name": "creator", + "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_title", + "name": "title", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 200, + "pattern": "" + } + }, + { + "system": false, + "id": "sf_type", + "name": "type", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "minSelect": null, + "maxSelect": 1, + "values": ["option", "rollcall"] + } + }, + { + "system": false, + "id": "sf_anonymous", + "name": "anonymous", + "type": "bool", + "required": false, + "presentable": false, + "unique": false, + "options": {} + }, + { + "system": false, + "id": "sf_deadline", + "name": "deadline", + "type": "date", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": "", + "max": "" + } + }, + { + "system": false, + "id": "sf_maxp", + "name": "maxParticipants", + "type": "number", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": 1, + "max": null, + "noDecimal": true + } + }, + { + "system": false, + "id": "sf_status", + "name": "status", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "minSelect": null, + "maxSelect": 1, + "values": ["active", "settled"] + } + }, + { + "system": false, + "id": "sf_settled", + "name": "settledAt", + "type": "date", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": "", + "max": "" + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "updateRule": "creator = @request.auth.id", + "deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("polls_collection"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776500002_created_poll_options.js b/backend/pb_migrations/1776500002_created_poll_options.js new file mode 100644 index 0000000..cc1c0a9 --- /dev/null +++ b/backend/pb_migrations/1776500002_created_poll_options.js @@ -0,0 +1,71 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "poll_options_collection", + "created": "2026-04-18 00:00:02.000Z", + "updated": "2026-04-18 00:00:02.000Z", + "name": "poll_options", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "sf_poll", + "name": "poll", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "polls_collection", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_content", + "name": "content", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 200, + "pattern": "" + } + }, + { + "system": false, + "id": "sf_order", + "name": "order", + "type": "number", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": 0, + "max": null, + "noDecimal": true + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "poll.creator = @request.auth.id", + "deleteRule": "poll.creator = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("poll_options_collection"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776500003_created_poll_votes.js b/backend/pb_migrations/1776500003_created_poll_votes.js new file mode 100644 index 0000000..ef47586 --- /dev/null +++ b/backend/pb_migrations/1776500003_created_poll_votes.js @@ -0,0 +1,75 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "poll_votes_collection", + "created": "2026-04-18 00:00:03.000Z", + "updated": "2026-04-18 00:00:03.000Z", + "name": "poll_votes", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "sf_poll", + "name": "poll", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "polls_collection", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_option", + "name": "option", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "poll_options_collection", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_user", + "name": "user", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + } + ], + "indexes": ["CREATE UNIQUE INDEX idx_poll_user ON poll_votes (poll, user)"], + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\" && user = @request.auth.id && poll.group.members ~ @request.auth.id", + "updateRule": "user = @request.auth.id", + "deleteRule": "user = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("poll_votes_collection"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776500004_created_memories.js b/backend/pb_migrations/1776500004_created_memories.js new file mode 100644 index 0000000..3c32029 --- /dev/null +++ b/backend/pb_migrations/1776500004_created_memories.js @@ -0,0 +1,128 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "memories_collection", + "created": "2026-04-18 10:00:04.000Z", + "updated": "2026-04-18 10:00:04.000Z", + "name": "memories", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "sf_group", + "name": "group", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "es63bkyiblpnxdf", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sf_uploader", + "name": "uploader", + "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_title", + "name": "title", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 200, + "pattern": "" + } + }, + { + "system": false, + "id": "sf_desc", + "name": "description", + "type": "text", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "sf_file", + "name": "file", + "type": "file", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "maxSize": 524288000, + "mimeTypes": ["image/*", "video/*", "audio/*", "application/pdf", "application/msword", "application/vnd.*", "text/*", "application/zip", "application/x-rar-compressed"] + } + }, + { + "system": false, + "id": "sf_ft", + "name": "fileType", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": ["image", "video", "audio", "document", "other"] + } + }, + { + "system": false, + "id": "sf_size", + "name": "size", + "type": "number", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": 0, + "max": null, + "noDecimal": false + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "updateRule": "uploader = @request.auth.id", + "deleteRule": "uploader = @request.auth.id || group.owner = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("memories_collection"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776500005_created_notifications.js b/backend/pb_migrations/1776500005_created_notifications.js new file mode 100644 index 0000000..9bbe3fd --- /dev/null +++ b/backend/pb_migrations/1776500005_created_notifications.js @@ -0,0 +1,121 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "notifications_collection", + "created": "2026-04-18 10:00:05.000Z", + "updated": "2026-04-18 10:00:05.000Z", + "name": "notifications", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "sf_user", + "name": "user", + "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_type", + "name": "type", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": ["poll_new", "poll_deadline", "poll_result", "team_invite", "team_starting", "join_request", "member_joined"] + } + }, + { + "system": false, + "id": "sf_title", + "name": "title", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 200, + "pattern": "" + } + }, + { + "system": false, + "id": "sf_content", + "name": "content", + "type": "text", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "sf_read", + "name": "read", + "type": "bool", + "required": false, + "presentable": false, + "unique": false, + "options": {} + }, + { + "system": false, + "id": "sf_rid", + "name": "relatedId", + "type": "text", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "sf_rtype", + "name": "relatedType", + "type": "select", + "required": false, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": ["poll", "team", "group"] + } + } + ], + "indexes": [], + "listRule": "user = @request.auth.id", + "viewRule": "user = @request.auth.id", + "createRule": "@request.auth.id != \"\" && user = @request.auth.id", + "updateRule": "user = @request.auth.id", + "deleteRule": "user = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("notifications_collection"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776500006_created_point_logs.js b/backend/pb_migrations/1776500006_created_point_logs.js new file mode 100644 index 0000000..d1b1348 --- /dev/null +++ b/backend/pb_migrations/1776500006_created_point_logs.js @@ -0,0 +1,84 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "point_logs_collection", + "created": "2026-04-18 10:00:06.000Z", + "updated": "2026-04-18 10:00:06.000Z", + "name": "point_logs", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "sf_user", + "name": "user", + "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_action", + "name": "action", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": ["vote", "team", "memory"] + } + }, + { + "system": false, + "id": "sf_points", + "name": "points", + "type": "number", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": 1, + "max": null, + "noDecimal": false + } + }, + { + "system": false, + "id": "sf_rid", + "name": "relatedId", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + } + ], + "indexes": [], + "listRule": "user = @request.auth.id", + "viewRule": "user = @request.auth.id", + "createRule": "@request.auth.id != \"\" && user = @request.auth.id", + "updateRule": null, + "deleteRule": null, + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("point_logs_collection"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776503475_created_polls.js b/backend/pb_migrations/1776503475_created_polls.js new file mode 100644 index 0000000..8964fd8 --- /dev/null +++ b/backend/pb_migrations/1776503475_created_polls.js @@ -0,0 +1,155 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "vfk07d8w8tl2d75", + "created": "2026-04-18 09:11:15.379Z", + "updated": "2026-04-18 09:11:15.379Z", + "name": "polls", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "bdmbfbno", + "name": "group", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "es63bkyiblpnxdf", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "hvnldxgq", + "name": "creator", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "iuuorixx", + "name": "title", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 200, + "pattern": "" + } + }, + { + "system": false, + "id": "g6ht5xdc", + "name": "type", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "option", + "rollcall" + ] + } + }, + { + "system": false, + "id": "0y0jzy14", + "name": "anonymous", + "type": "bool", + "required": false, + "presentable": false, + "unique": false, + "options": {} + }, + { + "system": false, + "id": "f0airh3m", + "name": "deadline", + "type": "date", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": "", + "max": "" + } + }, + { + "system": false, + "id": "4le4t2ht", + "name": "maxParticipants", + "type": "number", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": 1, + "max": null, + "noDecimal": true + } + }, + { + "system": false, + "id": "081izbpf", + "name": "status", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "active", + "settled" + ] + } + }, + { + "system": false, + "id": "lqsw4nqu", + "name": "settledAt", + "type": "date", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": "", + "max": "" + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "updateRule": "creator = @request.auth.id", + "deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("vfk07d8w8tl2d75"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776503495_created_poll_options.js b/backend/pb_migrations/1776503495_created_poll_options.js new file mode 100644 index 0000000..4f79e5e --- /dev/null +++ b/backend/pb_migrations/1776503495_created_poll_options.js @@ -0,0 +1,71 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "w30law0vgssvgxm", + "created": "2026-04-18 09:11:35.737Z", + "updated": "2026-04-18 09:11:35.737Z", + "name": "poll_options", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "r2ztzdoo", + "name": "poll", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "vfk07d8w8tl2d75", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "klakmukb", + "name": "content", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 200, + "pattern": "" + } + }, + { + "system": false, + "id": "m0pd1wfk", + "name": "order", + "type": "number", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": 0, + "max": null, + "noDecimal": true + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\" && poll.creator = @request.auth.id", + "updateRule": "poll.creator = @request.auth.id", + "deleteRule": "poll.creator = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("w30law0vgssvgxm"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776503533_created_poll_votes.js b/backend/pb_migrations/1776503533_created_poll_votes.js new file mode 100644 index 0000000..23260da --- /dev/null +++ b/backend/pb_migrations/1776503533_created_poll_votes.js @@ -0,0 +1,77 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "liqeya2lycibs4y", + "created": "2026-04-18 09:12:13.979Z", + "updated": "2026-04-18 09:12:13.979Z", + "name": "poll_votes", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "e7aygbae", + "name": "poll", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "vfk07d8w8tl2d75", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "uaalzgys", + "name": "option", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "w30law0vgssvgxm", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "dvr0tpcl", + "name": "user", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_poll_user` ON `poll_votes` (\n `poll`,\n `user`\n)" + ], + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\" && user = @request.auth.id && poll.group.members ~ @request.auth.id", + "updateRule": "user = @request.auth.id", + "deleteRule": "user = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("liqeya2lycibs4y"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776503534_created_memories.js b/backend/pb_migrations/1776503534_created_memories.js new file mode 100644 index 0000000..8ac0fe8 --- /dev/null +++ b/backend/pb_migrations/1776503534_created_memories.js @@ -0,0 +1,136 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "e7tjqmsm5ck66xl", + "created": "2026-04-18 09:12:14.033Z", + "updated": "2026-04-18 09:12:14.033Z", + "name": "memories", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "itx7thzd", + "name": "group", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "es63bkyiblpnxdf", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "gezpwnor", + "name": "uploader", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "h51c22eh", + "name": "title", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 200, + "pattern": "" + } + }, + { + "system": false, + "id": "mbzu9zlc", + "name": "description", + "type": "text", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "yfa85qjr", + "name": "file", + "type": "file", + "required": true, + "presentable": false, + "unique": false, + "options": { + "mimeTypes": null, + "thumbs": null, + "maxSelect": 1, + "maxSize": 524288000, + "protected": false + } + }, + { + "system": false, + "id": "tfclnicu", + "name": "fileType", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "image", + "video", + "audio", + "document", + "other" + ] + } + }, + { + "system": false, + "id": "pdok0jhi", + "name": "size", + "type": "number", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": 0, + "max": null, + "noDecimal": false + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "updateRule": "uploader = @request.auth.id", + "deleteRule": "uploader = @request.auth.id || group.owner = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("e7tjqmsm5ck66xl"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776503534_created_notifications.js b/backend/pb_migrations/1776503534_created_notifications.js new file mode 100644 index 0000000..083adfa --- /dev/null +++ b/backend/pb_migrations/1776503534_created_notifications.js @@ -0,0 +1,133 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "s63vtbeeqlv1xzu", + "created": "2026-04-18 09:12:14.062Z", + "updated": "2026-04-18 09:12:14.062Z", + "name": "notifications", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "elgovwo1", + "name": "user", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "ghhe48ku", + "name": "type", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "poll_new", + "poll_deadline", + "poll_result", + "team_invite", + "team_starting", + "join_request", + "member_joined" + ] + } + }, + { + "system": false, + "id": "gw88luj3", + "name": "title", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": 200, + "pattern": "" + } + }, + { + "system": false, + "id": "qmazbl4u", + "name": "content", + "type": "text", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "1qdnqn2w", + "name": "read", + "type": "bool", + "required": false, + "presentable": false, + "unique": false, + "options": {} + }, + { + "system": false, + "id": "i4xrijz1", + "name": "relatedId", + "type": "text", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "w1lzqcjc", + "name": "relatedType", + "type": "select", + "required": false, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "poll", + "team", + "group" + ] + } + } + ], + "indexes": [], + "listRule": "user = @request.auth.id", + "viewRule": "user = @request.auth.id", + "createRule": "@request.auth.id != \"\" && user = @request.auth.id", + "updateRule": "user = @request.auth.id", + "deleteRule": "user = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("s63vtbeeqlv1xzu"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776503534_created_point_logs.js b/backend/pb_migrations/1776503534_created_point_logs.js new file mode 100644 index 0000000..caa98dc --- /dev/null +++ b/backend/pb_migrations/1776503534_created_point_logs.js @@ -0,0 +1,88 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "h5adxdw9gm0aw8s", + "created": "2026-04-18 09:12:14.095Z", + "updated": "2026-04-18 09:12:14.095Z", + "name": "point_logs", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "xeqacyc7", + "name": "user", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sd27cbh8", + "name": "action", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "vote", + "team", + "memory" + ] + } + }, + { + "system": false, + "id": "iszqa13h", + "name": "points", + "type": "number", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": 1, + "max": null, + "noDecimal": false + } + }, + { + "system": false, + "id": "pnipfzbd", + "name": "relatedId", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + } + ], + "indexes": [], + "listRule": "user = @request.auth.id", + "viewRule": "user = @request.auth.id", + "createRule": "@request.auth.id != \"\" && user = @request.auth.id", + "updateRule": null, + "deleteRule": null, + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("h5adxdw9gm0aw8s"); + + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776504407_updated_poll_options.js b/backend/pb_migrations/1776504407_updated_poll_options.js new file mode 100644 index 0000000..fb90f6c --- /dev/null +++ b/backend/pb_migrations/1776504407_updated_poll_options.js @@ -0,0 +1,16 @@ +/// +migrate((db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("w30law0vgssvgxm") + + collection.createRule = "@request.auth.id != \"\"" + + return dao.saveCollection(collection) +}, (db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("w30law0vgssvgxm") + + collection.createRule = "@request.auth.id != \"\" && poll.creator = @request.auth.id" + + return dao.saveCollection(collection) +}) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index d563242..912c294 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -32,6 +32,7 @@ server { # API 代理到局域网 PocketBase location /api/ { + client_max_body_size 500m; proxy_pass http://192.168.1.14:8090; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; @@ -43,8 +44,8 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # 静态资源缓存 - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + # 静态资源缓存(排除 /api/ 路径) + location ~* ^/(?!api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|mp4|mkv|webm|mp3|ogg|pdf)$ { expires 1y; add_header Cache-Control "public, immutable"; } diff --git a/frontend/src/api/memories.ts b/frontend/src/api/memories.ts new file mode 100644 index 0000000..82d9324 --- /dev/null +++ b/frontend/src/api/memories.ts @@ -0,0 +1,99 @@ +import { pb } from './pocketbase' +import type { Memory } from '@/types' + +export const GROUP_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024 + +export function detectFileType(file: File): 'image' | 'video' | 'audio' | 'document' | 'other' { + const mime = file.type || '' + if (mime.startsWith('image/')) return 'image' + if (mime.startsWith('video/')) return 'video' + if (mime.startsWith('audio/')) return 'audio' + if ( + mime.startsWith('application/pdf') || + mime.startsWith('application/msword') || + mime.startsWith('application/vnd.') || + mime.startsWith('text/') + ) return 'document' + return 'other' +} + +export async function uploadMemory( + groupId: string, + file: File, + meta?: { title?: string; description?: string; tags?: string[] } +) { + const used = await getGroupStorageUsed(groupId) + if (used + file.size > GROUP_STORAGE_LIMIT) { + throw new Error('群组存储空间不足,无法上传') + } + + const user = pb.authStore.model + const formData = new FormData() + formData.append('group', groupId) + formData.append('uploader', user?.id || '') + formData.append('file', file) + formData.append('fileType', detectFileType(file)) + formData.append('size', file.size.toString()) + formData.append('title', meta?.title || file.name) + if (meta?.description) formData.append('description', meta.description) + + return pb.collection('memories').create(formData) +} + +export async function listMemories( + groupId: string, + options?: { + page?: number + limit?: number + fileType?: string + } +): Promise<{ items: Memory[]; total: number }> { + const { page = 1, limit = 30, fileType } = options || {} + let filter = `group="${groupId}"` + if (fileType) filter += ` && fileType="${fileType}"` + + const result = await pb.collection('memories').getList(page, limit, { + filter, + sort: '-created', + expand: 'uploader' + }) + return { items: result.items as unknown as Memory[], total: result.totalItems } +} + +export async function deleteMemory(memoryId: string) { + return pb.collection('memories').delete(memoryId) +} + +export async function getGroupStorageUsed(groupId: string): Promise { + let total = 0 + let page = 1 + const batchSize = 500 + let hasMore = true + + while (hasMore) { + const result = await pb.collection('memories').getList(page, batchSize, { + filter: `group="${groupId}"`, + fields: 'size' + }) + total += result.items.reduce((sum: number, r: any) => sum + (r.size || 0), 0) + hasMore = result.items.length === batchSize && page * batchSize < result.totalItems + page++ + } + + return total +} + +export function getGroupStorageLimit(): number { + return GROUP_STORAGE_LIMIT +} + +export function subscribeMemories( + groupId: string, + callback: (data: any) => void +) { + return pb.collection('memories').subscribe('*', (data) => { + if (data.record?.group === groupId) { + callback(data) + } + }) +} diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts new file mode 100644 index 0000000..beadf4e --- /dev/null +++ b/frontend/src/api/notifications.ts @@ -0,0 +1,89 @@ +import { pb } from './pocketbase' +import type { AppNotification } from '@/types' + +export async function listNotifications(options?: { + page?: number + limit?: number + unreadOnly?: boolean +}): Promise<{ items: AppNotification[], total: number }> { + const { page = 1, limit = 50, unreadOnly = false } = options || {} + const user = pb.authStore.model + if (!user) return { items: [], total: 0 } + + let filter = `user="${user.id}"` + if (unreadOnly) filter += ' && read=false' + + const result = await pb.collection('notifications').getList(page, limit, { + filter, + sort: '-created' + }) + return { items: result.items as unknown as AppNotification[], total: result.totalItems } +} + +export async function markAsRead(notificationId: string): Promise { + return pb.collection('notifications').update(notificationId, { read: true }) as unknown as Promise +} + +export async function markAllAsRead(): Promise { + const user = pb.authStore.model + if (!user) return + + let page = 1 + const batchSize = 50 + let hasMore = true + + while (hasMore) { + const result = await pb.collection('notifications').getList(page, batchSize, { + filter: `user="${user.id}" && read=false` + }) + + if (result.items.length === 0) break + + await Promise.all( + result.items.map(item => pb.collection('notifications').update(item.id, { read: true })) + ) + + hasMore = result.items.length === batchSize && page * batchSize < result.totalItems + page++ + } +} + +export async function deleteNotification(notificationId: string): Promise { + await pb.collection('notifications').delete(notificationId) +} + +export async function batchDelete(notificationIds: string[]): Promise { + const batchSize = 20 + for (let i = 0; i < notificationIds.length; i += batchSize) { + const batch = notificationIds.slice(i, i + batchSize) + await Promise.all(batch.map(id => pb.collection('notifications').delete(id))) + } +} + +export async function createNotification(data: { + user: string + type: string + title: string + content?: string + relatedId?: string + relatedType?: string +}): Promise { + return pb.collection('notifications').create(data) as unknown as Promise +} + +export async function subscribeNotifications( + callback: (data: { action: string, record: AppNotification }) => void +): Promise<() => void> { + const user = pb.authStore.model + if (!user) return () => {} + + await pb.collection('notifications').subscribe('*', (e) => { + if (e.record?.user === user.id) { + callback({ action: e.action, record: e.record as unknown as AppNotification }) + } + }) + + return () => { + pb.collection('notifications').unsubscribe('*') + } +} diff --git a/frontend/src/api/points.ts b/frontend/src/api/points.ts new file mode 100644 index 0000000..aaf8785 --- /dev/null +++ b/frontend/src/api/points.ts @@ -0,0 +1,92 @@ +import { pb } from './pocketbase' +import type { PointLog, PointAction } from '@/types' + +const POINT_MAP: Record = { + vote: 1, + team: 2, + memory: 1 +} + +export async function awardPoints(action: PointAction, relatedId: string): Promise { + const user = pb.authStore.model + if (!user) return + + const existing = await pb.collection('point_logs').getList(1, 1, { + filter: `user="${user.id}" && action="${action}" && relatedId="${relatedId}"`, + $autoCancel: false + }) + if (existing.items.length > 0) return + + const points = POINT_MAP[action] + + try { + await pb.collection('point_logs').create({ + user: user.id, + action, + points, + relatedId + }) + } catch (error: any) { + if (error?.response?.data?.user || error?.message?.includes('unique')) { + return + } + throw error + } + + const currentUser = await pb.collection('users').getOne(user.id) + await pb.collection('users').update(user.id, { + points: ((currentUser as any).points || 0) + points + }) +} + +export async function deductPoints(action: PointAction, relatedId: string): Promise { + const user = pb.authStore.model + if (!user) return + + const existing = await pb.collection('point_logs').getList(1, 1, { + filter: `user="${user.id}" && action="${action}" && relatedId="${relatedId}"`, + $autoCancel: false + }) + + if (existing.items.length === 0) return + const log = existing.items[0] + + try { + await pb.collection('point_logs').delete(log.id) + } catch (error: any) { + if (error?.status !== 404) { + throw error + } + } + + const pointsToDeduct = POINT_MAP[action] + const currentUser = await pb.collection('users').getOne(user.id) + const currentPoints = (currentUser as any).points || 0 + const newPoints = Math.max(0, currentPoints - pointsToDeduct) + + await pb.collection('users').update(user.id, { + points: newPoints + }) +} + +export async function getUserPointLogs(userId: string): Promise { + const result = await pb.collection('point_logs').getList(1, 100, { + filter: `user="${userId}"`, + sort: '-created', + $autoCancel: false + }) + return result.items as unknown as PointLog[] +} + +export async function getGroupMemberRanking(groupId: string, limit = 20) { + const group = await pb.collection('groups').getOne(groupId, { + expand: 'members', + $autoCancel: false + }) as any + + const members: any[] = group.expand?.members || [] + return members + .map((m: any) => ({ userId: m.id, points: m.points || 0, name: m.name || m.username })) + .sort((a: any, b: any) => b.points - a.points) + .slice(0, limit) +} diff --git a/frontend/src/api/polls.ts b/frontend/src/api/polls.ts new file mode 100644 index 0000000..31630c2 --- /dev/null +++ b/frontend/src/api/polls.ts @@ -0,0 +1,220 @@ +import { pb } from './pocketbase' +import { awardPoints, deductPoints } from './points' +import type { Poll, PollOption, PollVote } from '@/types' + +export async function createPoll(data: { + group: string + title: string + type?: 'option' | 'rollcall' + anonymous?: boolean + deadline?: string + maxParticipants?: number + options: string[] +}): Promise { + const user = pb.authStore.model + if (!user) throw new Error('未登录') + + const poll = await pb.collection('polls').create({ + group: data.group, + title: data.title, + type: data.type || 'option', + anonymous: data.anonymous || false, + deadline: data.deadline || '', + maxParticipants: data.type === 'rollcall' ? data.maxParticipants : null, + status: 'active', + creator: user.id, + }) + + for (let i = 0; i < data.options.length; i++) { + await pb.collection('poll_options').create({ + poll: poll.id, + content: data.options[i], + order: i + 1, + }) + } + + return poll as unknown as Poll +} + +export async function listPolls( + groupId: string, + status?: string +): Promise { + let filter = `group="${groupId}"` + if (status) filter += ` && status="${status}"` + + const result = await pb.collection('polls').getFullList({ + filter, + sort: '-created', + expand: 'creator', + $autoCancel: false, + }) + return result as unknown as Poll[] +} + +export async function getPoll(pollId: string): Promise { + const result = await pb.collection('polls').getOne(pollId, { + expand: 'creator', + }) + return result as unknown as Poll +} + +export async function getPollOptions(pollId: string): Promise { + const result = await pb.collection('poll_options').getFullList({ + filter: `poll="${pollId}"`, + sort: 'order', + $autoCancel: false, + }) + return result as unknown as PollOption[] +} + +export async function getPollVotes(pollId: string): Promise { + const result = await pb.collection('poll_votes').getFullList({ + filter: `poll="${pollId}"`, + expand: 'user,option', + $autoCancel: false, + }) + return result as unknown as PollVote[] +} + +export async function votePoll( + pollId: string, + optionId: string +): Promise { + const user = pb.authStore.model + if (!user) throw new Error('未登录') + + const poll = await pb.collection('polls').getOne(pollId) + if (poll.status !== 'active') { + throw new Error('投票已结束') + } + + const existing = await pb.collection('poll_votes').getList(1, 1, { + filter: `poll="${pollId}" && user="${user.id}"`, + }) + if (existing.items.length > 0) { + throw new Error('你已经投过票了') + } + + try { + const vote = await pb.collection('poll_votes').create({ + poll: pollId, + option: optionId, + user: user.id, + }) + + await awardPoints('vote', pollId) + + return vote as unknown as PollVote + } catch (error: any) { + if (error?.response?.data?.poll || error?.message?.includes('unique')) { + throw new Error('你已经投过票了') + } + throw error + } +} + +export async function cancelVote(pollId: string): Promise { + const user = pb.authStore.model + if (!user) throw new Error('未登录') + + const poll = await pb.collection('polls').getOne(pollId) + if (poll.status !== 'active') { + throw new Error('投票已结束,无法取消') + } + + const existing = await pb.collection('poll_votes').getFullList({ + filter: `poll="${pollId}" && user="${user.id}"`, + }) + + for (const vote of existing) { + await pb.collection('poll_votes').delete(vote.id) + } + + await deductPoints('vote', pollId) +} + +export async function settlePoll(pollId: string): Promise { + const result = await pb.collection('polls').update(pollId, { + status: 'settled', + settledAt: new Date().toISOString(), + }) + return result as unknown as Poll +} + +export async function updatePoll(pollId: string, data: { + title?: string + deadline?: string + maxParticipants?: number | null +}): Promise { + const result = await pb.collection('polls').update(pollId, data) + return result as unknown as Poll +} + +export async function addPollOption(pollId: string, content: string): Promise { + const existing = await pb.collection('poll_options').getFullList({ + filter: `poll="${pollId}"`, + sort: '-order', + $autoCancel: false, + }) + const maxOrder = existing.reduce((max: number, o: any) => Math.max(max, o.order || 0), 0) + const result = await pb.collection('poll_options').create({ + poll: pollId, + content, + order: maxOrder + 1, + }) + return result as unknown as PollOption +} + +export async function updatePollOption(optionId: string, content: string): Promise { + const result = await pb.collection('poll_options').update(optionId, { content }) + return result as unknown as PollOption +} + +export async function deletePollOption(optionId: string): Promise { + await pb.collection('poll_options').delete(optionId) +} + +export async function getUserVote(pollId: string): Promise { + const user = pb.authStore.model + if (!user) return null + + const result = await pb.collection('poll_votes').getList(1, 1, { + filter: `poll="${pollId}" && user="${user.id}"`, + expand: 'option', + $autoCancel: false, + }) + return result.items.length > 0 + ? (result.items[0] as unknown as PollVote) + : null +} + +export async function subscribePolls( + groupId: string, + callback: (data: any) => void +): Promise<() => void> { + await pb.collection('polls').subscribe('*', (data) => { + if (data.record?.group === groupId) { + callback(data) + } + }) + + return () => { + pb.collection('polls').unsubscribe('*') + } +} + +export async function subscribePollVotes( + pollId: string, + callback: (data: any) => void +): Promise<() => void> { + await pb.collection('poll_votes').subscribe('*', (data) => { + if (data.record?.poll === pollId) { + callback(data) + } + }) + + return () => { + pb.collection('poll_votes').unsubscribe('*') + } +} diff --git a/frontend/src/components/memory/MemoryGrid.vue b/frontend/src/components/memory/MemoryGrid.vue new file mode 100644 index 0000000..c55f220 --- /dev/null +++ b/frontend/src/components/memory/MemoryGrid.vue @@ -0,0 +1,531 @@ + + + + + diff --git a/frontend/src/components/memory/MemoryUploadDialog.vue b/frontend/src/components/memory/MemoryUploadDialog.vue new file mode 100644 index 0000000..278f668 --- /dev/null +++ b/frontend/src/components/memory/MemoryUploadDialog.vue @@ -0,0 +1,236 @@ + + + + + + + diff --git a/frontend/src/components/poll/CreatePollDialog.vue b/frontend/src/components/poll/CreatePollDialog.vue new file mode 100644 index 0000000..18adbc8 --- /dev/null +++ b/frontend/src/components/poll/CreatePollDialog.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/frontend/src/components/poll/EditPollDialog.vue b/frontend/src/components/poll/EditPollDialog.vue new file mode 100644 index 0000000..a345e46 --- /dev/null +++ b/frontend/src/components/poll/EditPollDialog.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/frontend/src/components/poll/PollCard.vue b/frontend/src/components/poll/PollCard.vue new file mode 100644 index 0000000..252f781 --- /dev/null +++ b/frontend/src/components/poll/PollCard.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/frontend/src/components/poll/PollDetail.vue b/frontend/src/components/poll/PollDetail.vue new file mode 100644 index 0000000..893792b --- /dev/null +++ b/frontend/src/components/poll/PollDetail.vue @@ -0,0 +1,460 @@ + + + + + diff --git a/frontend/src/components/poll/PollList.vue b/frontend/src/components/poll/PollList.vue new file mode 100644 index 0000000..10e0dbb --- /dev/null +++ b/frontend/src/components/poll/PollList.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/frontend/src/components/stats/GroupStatsPanel.vue b/frontend/src/components/stats/GroupStatsPanel.vue new file mode 100644 index 0000000..ec5a167 --- /dev/null +++ b/frontend/src/components/stats/GroupStatsPanel.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/frontend/src/stores/memory.ts b/frontend/src/stores/memory.ts new file mode 100644 index 0000000..fee7df1 --- /dev/null +++ b/frontend/src/stores/memory.ts @@ -0,0 +1,82 @@ +// src/stores/memory.ts +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { Memory } from '@/types' +import { + listMemories, + uploadMemory, + deleteMemory, + getGroupStorageUsed, + getGroupStorageLimit +} from '@/api/memories' + +export const useMemoryStore = defineStore('memory', () => { + const memories = ref([]) + const storageUsed = ref(0) + const storageLimit = ref(getGroupStorageLimit()) + const loading = ref(false) + + // 计算属性:已用/总容量百分比 + const storagePercent = computed(() => { + if (storageLimit.value === 0) return 0 + return Math.round((storageUsed.value / storageLimit.value) * 10000) / 100 + }) + + // 加载记忆列表 + async function loadMemories(groupId: string, fileType?: string) { + try { + loading.value = true + const result = await listMemories(groupId, { fileType }) + memories.value = result.items + // 同时刷新存储用量 + storageUsed.value = await getGroupStorageUsed(groupId) + } catch (error) { + console.error('加载记忆列表失败:', error) + } finally { + loading.value = false + } + } + + // 上传记忆文件 + async function upload(groupId: string, file: File, meta?: { title?: string; description?: string; tags?: string[] }) { + try { + loading.value = true + await uploadMemory(groupId, file, meta) + // 上传后刷新列表和容量 + await loadMemories(groupId) + } catch (error) { + console.error('上传记忆失败:', error) + throw error + } finally { + loading.value = false + } + } + + // 删除记忆 + async function remove(memoryId: string, groupId: string) { + try { + await deleteMemory(memoryId) + // 从本地列表中移除 + const index = memories.value.findIndex(m => m.id === memoryId) + if (index !== -1) { + memories.value.splice(index, 1) + } + // 刷新容量 + storageUsed.value = await getGroupStorageUsed(groupId) + } catch (error) { + console.error('删除记忆失败:', error) + throw error + } + } + + return { + memories, + storageUsed, + storageLimit, + loading, + storagePercent, + loadMemories, + upload, + remove + } +}) diff --git a/frontend/src/stores/notification.ts b/frontend/src/stores/notification.ts index 5f8b70f..42ce665 100644 --- a/frontend/src/stores/notification.ts +++ b/frontend/src/stores/notification.ts @@ -1,8 +1,15 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import type { Invitation, JoinRequest } from '@/types' +import type { Invitation, JoinRequest, AppNotification } from '@/types' import { getPendingInvitations, subscribeInvitations } from '@/api/invitations' import { getMyGroupsJoinRequests } from '@/api/groups' +import { + listNotifications, + markAsRead, + markAllAsRead as apiMarkAllRead, + deleteNotification, + subscribeNotifications +} from '@/api/notifications' import { pb } from '@/api/pocketbase' export const useNotificationStore = defineStore('notification', () => { @@ -12,9 +19,17 @@ export const useNotificationStore = defineStore('notification', () => { const showPanel = ref(false) let unsubFn: (() => Promise | void) | null = null let unsubJoinFn: (() => Promise | void) | null = null + let unsubAppFn: (() => Promise | void) | null = null + + // 站内应用通知 + const appNotifications = ref([]) + + const appUnreadCount = computed(() => + appNotifications.value.filter(n => !n.read).length + ) const unreadCount = computed(() => - pendingInvitations.value.length + pendingJoinRequests.value.length + pendingInvitations.value.length + pendingJoinRequests.value.length + appUnreadCount.value ) async function loadPendingInvitations() { @@ -29,10 +44,21 @@ export const useNotificationStore = defineStore('notification', () => { } } + async function loadAppNotifications() { + try { + const result = await listNotifications({ page: 1, limit: 50 }) + appNotifications.value = result.items + } catch (error) { + console.error('加载应用通知失败:', error) + } + } + async function startListening() { const user = pb.authStore.model if (!user) return + stopListening() + unsubFn = await subscribeInvitations(() => { loadPendingInvitations() }) @@ -40,6 +66,10 @@ export const useNotificationStore = defineStore('notification', () => { unsubJoinFn = await pb.collection('join_requests').subscribe('*', () => { loadPendingInvitations() }) + + unsubAppFn = await subscribeNotifications(() => { + loadAppNotifications() + }) } function stopListening() { @@ -51,6 +81,10 @@ export const useNotificationStore = defineStore('notification', () => { unsubJoinFn() unsubJoinFn = null } + if (unsubAppFn) { + unsubAppFn() + unsubAppFn = null + } } function removeInvitation(invitationId: string) { @@ -61,6 +95,34 @@ export const useNotificationStore = defineStore('notification', () => { pendingJoinRequests.value = pendingJoinRequests.value.filter(r => r.id !== requestId) } + async function markRead(id: string) { + try { + await markAsRead(id) + const notification = appNotifications.value.find(n => n.id === id) + if (notification) notification.read = true + } catch (error) { + console.error('标记已读失败:', error) + } + } + + async function markAllRead() { + try { + await apiMarkAllRead() + appNotifications.value.forEach(n => { n.read = true }) + } catch (error) { + console.error('全部标记已读失败:', error) + } + } + + async function removeNotification(id: string) { + try { + await deleteNotification(id) + appNotifications.value = appNotifications.value.filter(n => n.id !== id) + } catch (error) { + console.error('删除通知失败:', error) + } + } + function togglePanel() { showPanel.value = !showPanel.value } @@ -71,11 +133,17 @@ export const useNotificationStore = defineStore('notification', () => { loading, showPanel, unreadCount, + appNotifications, + appUnreadCount, loadPendingInvitations, + loadAppNotifications, startListening, stopListening, removeInvitation, removeJoinRequest, + markRead, + markAllRead, + removeNotification, togglePanel } }) diff --git a/frontend/src/stores/poll.ts b/frontend/src/stores/poll.ts new file mode 100644 index 0000000..b941318 --- /dev/null +++ b/frontend/src/stores/poll.ts @@ -0,0 +1,161 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { Poll, PollOption, PollVote } from '@/types' +import { + listPolls, + getPoll, + getPollOptions, + getPollVotes, + getUserVote, + votePoll, + cancelVote, + settlePoll, + updatePoll as apiUpdatePoll, + addPollOption as apiAddPollOption, + updatePollOption as apiUpdatePollOption, + deletePollOption as apiDeletePollOption, +} from '@/api/polls' + +export const usePollStore = defineStore('poll', () => { + // 状态 + const activePolls = ref([]) + const settledPolls = ref([]) + const currentPoll = ref(null) + const currentOptions = ref([]) + const currentVotes = ref([]) + const currentUserVote = ref(null) + const loading = ref(false) + + // 加载群组投票列表 + async function loadPolls(groupId: string) { + try { + loading.value = true + const [active, settled] = await Promise.all([ + listPolls(groupId, 'active'), + listPolls(groupId, 'settled'), + ]) + activePolls.value = active + settledPolls.value = settled + } catch (error) { + console.error('加载投票列表失败:', error) + } finally { + loading.value = false + } + } + + // 加载投票详情(含选项、投票记录、当前用户投票) + async function loadPollDetail(pollId: string) { + try { + loading.value = true + const [poll, options, votes, userVote] = await Promise.all([ + getPoll(pollId), + getPollOptions(pollId), + getPollVotes(pollId), + getUserVote(pollId), + ]) + currentPoll.value = poll + currentOptions.value = options + currentVotes.value = votes + currentUserVote.value = userVote + } catch (error) { + console.error('加载投票详情失败:', error) + throw error + } finally { + loading.value = false + } + } + + // 投票 + async function vote(pollId: string, optionId: string) { + try { + await votePoll(pollId, optionId) + // 重新加载详情以获取最新数据 + await loadPollDetail(pollId) + } catch (error) { + console.error('投票失败:', error) + throw error + } + } + + // 取消投票 + async function unvote(pollId: string) { + try { + await cancelVote(pollId) + // 重新加载详情以获取最新数据 + await loadPollDetail(pollId) + } catch (error) { + console.error('取消投票失败:', error) + throw error + } + } + + // 关闭投票 + async function settle(pollId: string) { + try { + await settlePoll(pollId) + await loadPollDetail(pollId) + } catch (error) { + console.error('关闭投票失败:', error) + throw error + } + } + + // 编辑投票 + async function edit(pollId: string, data: { + title: string + deadline: string + maxParticipants: number | null + options: { id?: string; content: string; hasVotes?: boolean }[] + deletedOptionIds: string[] + }) { + try { + await apiUpdatePoll(pollId, { + title: data.title, + deadline: data.deadline, + maxParticipants: data.maxParticipants, + }) + + for (const opt of data.options) { + if (opt.id && opt.content) { + await apiUpdatePollOption(opt.id, opt.content) + } else if (!opt.id && opt.content) { + await apiAddPollOption(pollId, opt.content) + } + } + + for (const id of data.deletedOptionIds) { + await apiDeletePollOption(id) + } + + await loadPollDetail(pollId) + } catch (error) { + console.error('编辑投票失败:', error) + throw error + } + } + + // 清除当前投票详情 + function clearCurrent() { + currentPoll.value = null + currentOptions.value = [] + currentVotes.value = [] + currentUserVote.value = null + } + + return { + activePolls, + settledPolls, + currentPoll, + currentOptions, + currentVotes, + currentUserVote, + loading, + loadPolls, + loadPollDetail, + vote, + unvote, + settle, + edit, + clearCurrent, + } +}) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 757f9e4..7254ea2 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -172,18 +172,89 @@ export interface JoinRequest { } } -// 获取用户显示名称(优先 name,回退 username) -export function displayName(user?: { name?: string; username: string } | null): string { - return user?.name || user?.username || '未知' +// 投票类型 +export type PollType = 'option' | 'rollcall' +export type PollStatus = 'active' | 'settled' + +// 投票 +export interface Poll { + id: string + group: string + creator: string + title: string + type: PollType + anonymous: boolean + deadline?: string + maxParticipants?: number + status: PollStatus + settledAt?: string + created: string + updated: string + expand?: { + creator?: User + group?: Group + } } -// 多媒体记忆类型 (二期规划) -export type MemoryFileType = 'image' | 'video' | 'audio' | 'other' +// 投票选项 +export interface PollOption { + id: string + poll: string + content: string + order: number +} -// 多媒体记忆 (二期规划) +// 投票记录 +export interface PollVote { + id: string + poll: string + option: string + user: string + created: string + expand?: { + user?: User + option?: PollOption + } +} + +// 通知类型 +export type NotificationType = 'poll_new' | 'poll_deadline' | 'poll_result' | 'team_invite' | 'team_starting' | 'join_request' | 'member_joined' + +// 站内通知 +export interface AppNotification { + id: string + user: string + type: NotificationType + title: string + content?: string + read: boolean + relatedId?: string + relatedType?: 'poll' | 'team' | 'group' + created: string + updated: string +} + +// 积分行为类型 +export type PointAction = 'vote' | 'team' | 'memory' + +// 积分流水 +export interface PointLog { + id: string + user: string + action: PointAction + points: number + relatedId: string + created: string + updated: string +} + +// 多媒体记忆文件类型 +export type MemoryFileType = 'image' | 'video' | 'audio' | 'document' | 'other' + +// 多媒体记忆 export interface Memory { id: string - groupId: string + group: string uploader: string title: string description?: string @@ -197,3 +268,8 @@ export interface Memory { group?: Group } } + +// 获取用户显示名称(优先 name,回退 username) +export function displayName(user?: { name?: string; username: string } | null): string { + return user?.name || user?.username || '未知' +} diff --git a/frontend/src/views/Changelog.vue b/frontend/src/views/Changelog.vue index 2181399..aaff87f 100644 --- a/frontend/src/views/Changelog.vue +++ b/frontend/src/views/Changelog.vue @@ -10,6 +10,24 @@ interface LogEntry { } const logs = ref([ + { + version: 'v0.1.0', + date: '2026-04-18', + title: '二期功能:投票、回忆、统计', + items: [ + { type: 'feat', text: '群组投票:支持选项投票和接龙报名两种模式,可设置截止时间、匿名投票' }, + { type: 'feat', text: '投票编辑:发起人可修改标题、选项、截止时间,已投票选项不可删除' }, + { type: 'feat', text: '投票结算:到达截止时间自动结算,发起人也可手动结束投票' }, + { type: 'feat', text: '多媒体回忆:群组内上传图片/视频/音频/文档,缩略图预览和弹窗播放' }, + { type: 'feat', text: '数据统计:群组内展示本周组队次数、投票参与率、积分排行' }, + { type: 'feat', text: '站内通知:新增投票、组队、入群等场景通知,铃铛图标显示未读数' }, + { type: 'feat', text: '积分体系:参与投票/组队/上传获取积分,群组内展示排行' }, + { type: 'feat', text: '群组详情页 Tab 重构:动态/投票/回忆/统计四个 Tab,带图标醒目展示' }, + { type: 'fix', text: '修复 nginx 代理文件上传 413 问题和静态资源误拦截 API 文件请求' }, + { type: 'fix', text: '修复投票列表 auto-cancel 竞态问题和选项排序 0 值校验失败' }, + { type: 'fix', text: '修复截止时间时区偏差,统一使用 ISO 格式存储' }, + ] + }, { version: 'v0.0.3', date: '2026-04-18', diff --git a/frontend/src/views/GroupView.vue b/frontend/src/views/GroupView.vue index 4524738..c01d000 100644 --- a/frontend/src/views/GroupView.vue +++ b/frontend/src/views/GroupView.vue @@ -1,20 +1,30 @@ @@ -502,4 +576,49 @@ async function refreshMembers() { transition: width 0.4s ease; min-width: 0; } + +/* ── Tab 样式 ── */ +.group-tabs--fancy :deep(.el-tabs__header) { + margin-bottom: 20px; +} + +.group-tabs--fancy :deep(.el-tabs__nav-wrap::after) { + height: 2px; + background: var(--gg-border); +} + +.group-tabs--fancy :deep(.el-tabs__active-bar) { + height: 3px; + border-radius: 2px; + background: var(--gg-primary); +} + +.group-tabs--fancy :deep(.el-tabs__item) { + font-size: 15px; + font-weight: 500; + padding: 0 24px; + height: 44px; + line-height: 44px; + color: var(--gg-text-muted); + transition: color 0.2s, transform 0.15s; +} + +.group-tabs--fancy :deep(.el-tabs__item:hover) { + color: var(--gg-primary); +} + +.group-tabs--fancy :deep(.el-tabs__item.is-active) { + color: var(--gg-primary); + font-weight: 700; +} + +.fancy-tab { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.fancy-tab .el-icon { + font-size: 17px; +}