feat: phase 2 - polls, memories, notifications, stats v0.1.0

- Group polls with option/rollcall modes, edit by creator, auto-settle
- Multimedia memories with upload, preview, inline video playback
- In-app notifications for poll/team/group events
- Points system and group stats dashboard
- Group detail tabs with icons (activity/polls/memories/stats)
- Fix: nginx file upload size, static cache blocking API, timezone, auto-cancel

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-18 18:19:46 +08:00
parent 71742da600
commit c5d3ac01ca
33 changed files with 5180 additions and 59 deletions
+53 -44
View File
@@ -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` 配置开发端口
@@ -0,0 +1,151 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})
@@ -0,0 +1,71 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})
@@ -0,0 +1,75 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})
@@ -0,0 +1,128 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})
@@ -0,0 +1,121 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})
@@ -0,0 +1,84 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})
@@ -0,0 +1,155 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})
@@ -0,0 +1,71 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})
@@ -0,0 +1,77 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})
@@ -0,0 +1,136 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})
@@ -0,0 +1,133 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})
@@ -0,0 +1,88 @@
/// <reference path="../pb_data/types.d.ts" />
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);
})
@@ -0,0 +1,16 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})
+3 -2
View File
@@ -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";
}
+99
View File
@@ -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<number> {
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)
}
})
}
+89
View File
@@ -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<AppNotification> {
return pb.collection('notifications').update(notificationId, { read: true }) as unknown as Promise<AppNotification>
}
export async function markAllAsRead(): Promise<void> {
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<void> {
await pb.collection('notifications').delete(notificationId)
}
export async function batchDelete(notificationIds: string[]): Promise<void> {
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<AppNotification> {
return pb.collection('notifications').create(data) as unknown as Promise<AppNotification>
}
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('*')
}
}
+92
View File
@@ -0,0 +1,92 @@
import { pb } from './pocketbase'
import type { PointLog, PointAction } from '@/types'
const POINT_MAP: Record<PointAction, number> = {
vote: 1,
team: 2,
memory: 1
}
export async function awardPoints(action: PointAction, relatedId: string): Promise<void> {
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<void> {
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<PointLog[]> {
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)
}
+220
View File
@@ -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<Poll> {
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<Poll[]> {
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<Poll> {
const result = await pb.collection('polls').getOne(pollId, {
expand: 'creator',
})
return result as unknown as Poll
}
export async function getPollOptions(pollId: string): Promise<PollOption[]> {
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<PollVote[]> {
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<PollVote> {
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<void> {
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<Poll> {
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<Poll> {
const result = await pb.collection('polls').update(pollId, data)
return result as unknown as Poll
}
export async function addPollOption(pollId: string, content: string): Promise<PollOption> {
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<PollOption> {
const result = await pb.collection('poll_options').update(optionId, { content })
return result as unknown as PollOption
}
export async function deletePollOption(optionId: string): Promise<void> {
await pb.collection('poll_options').delete(optionId)
}
export async function getUserVote(pollId: string): Promise<PollVote | null> {
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('*')
}
}
@@ -0,0 +1,531 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useGroupStore } from '@/stores/group'
import { useMemoryStore } from '@/stores/memory'
import { subscribeMemories } from '@/api/memories'
import { pb } from '@/api/pocketbase'
import { displayName } from '@/types'
import { Plus, PictureFilled, Document, VideoCamera, Headset, Loading, Delete } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import MemoryUploadDialog from './MemoryUploadDialog.vue'
const groupStore = useGroupStore()
const memoryStore = useMemoryStore()
const showUploadDialog = ref(false)
const activeFilter = ref<string>('')
let unsubscribeFn: (() => Promise<void>) | null = null
// 存储容量信息
const storageUsedGB = computed(() => (memoryStore.storageUsed / (1024 * 1024 * 1024)).toFixed(2))
const storageLimitGB = computed(() => (memoryStore.storageLimit / (1024 * 1024 * 1024)).toFixed(0))
const storagePercent = computed(() => memoryStore.storagePercent)
const isStorageWarning = computed(() => storagePercent.value >= 80)
// 过滤后的记忆列表
const filteredMemories = computed(() => {
if (!activeFilter.value) return memoryStore.memories
return memoryStore.memories.filter((m: any) => m.fileType === activeFilter.value)
})
// 获取文件访问 URL(支持缩略图)
function getFileUrl(memory: any, thumb?: string): string {
if (!memory?.file) return ''
return pb.files.getUrl(memory, memory.file, thumb ? { thumb } : {})
}
// 格式化文件大小
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
const size = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)
return `${size} ${units[i]}`
}
// 判断当前用户是否为上传者或群管理员
function isOwnerOrAdmin(memory: any): boolean {
const userId = pb.authStore.model?.id
if (!userId) return false
if (memory.uploader === userId) return true
// 群管理员 = 群主
if (groupStore.currentGroup?.owner === userId) return true
return false
}
// 获取文件类型图标
function getFileIcon(fileType: string) {
switch (fileType) {
case 'image': return PictureFilled
case 'video': return VideoCamera
case 'audio': return Headset
default: return Document
}
}
// 点击文件卡片(打开预览弹窗)
const showPreview = ref(false)
const previewMemory = ref<any>(null)
function handleCardClick(memory: any) {
if (memory.fileType === 'image' || memory.fileType === 'video') {
previewMemory.value = memory
showPreview.value = true
}
}
// 删除记忆
async function handleDelete(memory: any) {
try {
await ElMessageBox.confirm('确定要删除这条记忆吗?', '确认删除', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
})
await memoryStore.remove(memory.id, groupStore.currentGroupId)
ElMessage.success('删除成功')
} catch {
// 用户取消
}
}
// 格式化时间
function formatTime(dateStr: string): string {
const d = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffMin = Math.floor(diffMs / 60000)
const diffHour = Math.floor(diffMs / 3600000)
const diffDay = Math.floor(diffMs / 86400000)
if (diffMin < 1) return '刚刚'
if (diffMin < 60) return `${diffMin} 分钟前`
if (diffHour < 24) return `${diffHour} 小时前`
if (diffDay < 7) return `${diffDay} 天前`
return d.toLocaleDateString('zh-CN')
}
// 加载数据
async function loadData() {
const groupId = groupStore.currentGroupId
if (!groupId) return
await memoryStore.loadMemories(groupId, activeFilter.value || undefined)
}
// 订阅变更
async function setupSubscription() {
const groupId = groupStore.currentGroupId
if (!groupId) return
try {
unsubscribeFn = await subscribeMemories(groupId, () => {
loadData()
})
} catch (error) {
console.error('订阅记忆变更失败:', error)
}
}
onMounted(async () => {
if (groupStore.currentGroupId) {
await loadData()
await setupSubscription()
}
})
watch(() => groupStore.currentGroupId, async (newId, oldId) => {
if (newId && newId !== oldId) {
await loadData()
if (!unsubscribeFn) await setupSubscription()
}
})
onUnmounted(async () => {
if (unsubscribeFn) {
try {
await unsubscribeFn()
} catch {
// 忽略
}
unsubscribeFn = null
}
})
</script>
<template>
<div class="memory-grid-container">
<!-- 顶部标题栏 -->
<div class="memory-header">
<h3 class="memory-title">记忆相册</h3>
<el-button type="primary" :icon="Plus" @click="showUploadDialog = true">
上传
</el-button>
</div>
<!-- 存储容量条 -->
<div class="storage-section" :class="{ warning: isStorageWarning }">
<div class="storage-bar">
<div
class="storage-bar-fill"
:class="{ 'bar-warning': isStorageWarning }"
:style="{ width: Math.min(storagePercent, 100) + '%' }"
/>
</div>
<div class="storage-label">
{{ storageUsedGB }} GB / {{ storageLimitGB }} GB
<span v-if="isStorageWarning" class="storage-warn-text">空间不足</span>
</div>
</div>
<!-- 类型过滤 -->
<div class="filter-bar">
<el-button
:type="activeFilter === '' ? 'primary' : 'default'"
size="small"
@click="activeFilter = ''"
>全部</el-button>
<el-button
:type="activeFilter === 'image' ? 'primary' : 'default'"
size="small"
@click="activeFilter = 'image'"
>图片</el-button>
<el-button
:type="activeFilter === 'video' ? 'primary' : 'default'"
size="small"
@click="activeFilter = 'video'"
>视频</el-button>
<el-button
:type="activeFilter === 'audio' ? 'primary' : 'default'"
size="small"
@click="activeFilter = 'audio'"
>音频</el-button>
<el-button
:type="activeFilter === 'document' ? 'primary' : 'default'"
size="small"
@click="activeFilter = 'document'"
>文档</el-button>
</div>
<!-- 加载状态 -->
<div v-if="memoryStore.loading" class="loading-state">
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
<span>加载中...</span>
</div>
<!-- 空状态 -->
<div v-else-if="filteredMemories.length === 0" class="empty-state">
<el-icon :size="48" class="empty-icon"><PictureFilled /></el-icon>
<p>还没有记忆快来上传第一个吧</p>
</div>
<!-- 文件网格 -->
<div v-else class="memory-grid">
<div
v-for="memory in filteredMemories"
:key="memory.id"
class="memory-card"
@click="handleCardClick(memory)"
>
<!-- 文件预览区 -->
<div class="card-preview">
<!-- 图片缩略图 -->
<img
v-if="memory.fileType === 'image'"
:src="getFileUrl(memory, '300x300')"
:alt="memory.title"
class="preview-image"
loading="lazy"
/>
<!-- 视频缩略图取第一帧 -->
<video
v-else-if="memory.fileType === 'video'"
:src="getFileUrl(memory)"
class="preview-video"
preload="metadata"
muted
/>
<!-- 其他文件图标 -->
<div v-else class="preview-icon">
<el-icon :size="36"><component :is="getFileIcon(memory.fileType)" /></el-icon>
</div>
<!-- 视频播放按钮遮罩 -->
<div v-if="memory.fileType === 'video'" class="video-play-overlay">
<svg viewBox="0 0 24 24" fill="white" width="40" height="40">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" />
<polygon points="10,8 16,12 10,16" fill="white" />
</svg>
</div>
<!-- 删除按钮 -->
<el-button
v-if="isOwnerOrAdmin(memory)"
class="delete-btn"
:icon="Delete"
circle
size="small"
type="danger"
@click.stop="handleDelete(memory)"
/>
</div>
<!-- 文件信息 -->
<div class="card-info">
<div class="card-title" :title="memory.title">{{ memory.title }}</div>
<div class="card-meta">
<span class="card-uploader">{{ displayName(memory.expand?.uploader) }}</span>
<span class="card-size">{{ formatSize(memory.size) }}</span>
</div>
<div class="card-time">{{ formatTime(memory.created) }}</div>
</div>
</div>
</div>
<!-- 图片/视频预览弹窗 -->
<el-dialog
v-model="showPreview"
:title="previewMemory?.title"
width="80%"
class="preview-dialog"
@close="previewMemory = null"
>
<img
v-if="previewMemory?.fileType === 'image'"
:src="getFileUrl(previewMemory)"
class="preview-fullscreen"
/>
<video
v-else-if="previewMemory?.fileType === 'video'"
:src="getFileUrl(previewMemory)"
controls
autoplay
class="preview-fullscreen"
/>
</el-dialog>
<!-- 上传弹窗 -->
<MemoryUploadDialog
v-model="showUploadDialog"
@uploaded="loadData"
/>
</div>
</template>
<style scoped>
.memory-grid-container {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 顶部 */
.memory-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.memory-title {
font-size: 18px;
font-weight: 600;
color: var(--gg-text-primary);
margin: 0;
}
/* 存储条 */
.storage-section {
padding: 0;
}
.storage-bar {
height: 6px;
background: var(--gg-bg-secondary);
border-radius: 3px;
overflow: hidden;
}
.storage-bar-fill {
height: 100%;
background: var(--gg-primary);
border-radius: 3px;
transition: width 0.3s ease;
}
.storage-bar-fill.bar-warning {
background: var(--el-color-warning);
}
.storage-label {
margin-top: 4px;
font-size: 12px;
color: var(--gg-text-hint);
}
.storage-warn-text {
color: var(--el-color-warning);
font-weight: 500;
margin-left: 6px;
}
.storage-section.warning .storage-bar {
background: var(--el-color-warning-light-9);
}
/* 过滤栏 */
.filter-bar {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* 加载 & 空状态 */
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 0;
color: var(--gg-text-hint);
font-size: 14px;
}
.empty-icon {
color: var(--gg-text-hint);
opacity: 0.4;
}
/* 文件网格 */
.memory-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
/* 卡片 */
.memory-card {
border: 1px solid var(--gg-border);
border-radius: 8px;
overflow: hidden;
background: var(--gg-bg-primary);
transition: box-shadow 0.2s, transform 0.15s;
cursor: pointer;
}
.memory-card:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
/* 预览区 */
.card-preview {
position: relative;
width: 100%;
height: 150px;
background: var(--gg-bg-secondary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-icon {
color: var(--gg-text-hint);
}
.delete-btn {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.2s;
}
.memory-card:hover .delete-btn {
opacity: 1;
}
/* 信息区 */
.card-info {
padding: 10px 12px;
}
.card-title {
font-size: 14px;
font-weight: 500;
color: var(--gg-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
font-size: 12px;
color: var(--gg-text-hint);
}
.card-uploader {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
}
.card-time {
margin-top: 2px;
font-size: 11px;
color: var(--gg-text-hint);
}
/* 视频预览 */
.preview-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-play-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.memory-card:hover .video-play-overlay {
opacity: 1;
}
/* 预览弹窗 */
.preview-fullscreen {
max-width: 100%;
max-height: 70vh;
display: block;
margin: 0 auto;
}
/* 响应式 */
@media (max-width: 640px) {
.memory-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.card-preview {
height: 120px;
}
}
</style>
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useGroupStore } from '@/stores/group'
import { useMemoryStore } from '@/stores/memory'
import { ElMessage, type UploadFile } from 'element-plus'
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{ uploaded: [] }>()
const groupStore = useGroupStore()
const memoryStore = useMemoryStore()
const title = ref('')
const description = ref('')
const fileList = ref<UploadFile[]>([])
const loading = ref(false)
// 存储容量信息
const storageUsedGB = computed(() => (memoryStore.storageUsed / (1024 * 1024 * 1024)).toFixed(2))
const storageLimitGB = computed(() => (memoryStore.storageLimit / (1024 * 1024 * 1024)).toFixed(0))
const storagePercent = computed(() => memoryStore.storagePercent)
const isStorageWarning = computed(() => storagePercent.value >= 80)
// 文件选择变更
function handleFileChange(_file: UploadFile, uploadFileList: UploadFile[]) {
fileList.value = uploadFileList
}
// 移除文件
function handleFileRemove(_file: UploadFile, uploadFileList: UploadFile[]) {
fileList.value = uploadFileList
}
// 超出限制
function handleExceed() {
ElMessage.warning('只能上传一个文件,请先移除已选文件')
}
// 提交上传
async function handleSubmit() {
if (!title.value.trim()) {
ElMessage.warning('请输入标题')
return
}
if (fileList.value.length === 0) {
ElMessage.warning('请选择文件')
return
}
const rawFile = fileList.value[0].raw
if (!rawFile) {
ElMessage.error('文件读取失败,请重新选择')
return
}
const groupId = groupStore.currentGroupId
if (!groupId) {
ElMessage.error('未选择群组')
return
}
try {
loading.value = true
await memoryStore.upload(groupId, rawFile, {
title: title.value.trim(),
description: description.value.trim() || undefined
})
ElMessage.success('上传成功')
resetForm()
emit('uploaded')
visible.value = false
} catch (error: any) {
ElMessage.error(error.message || '上传失败')
} finally {
loading.value = false
}
}
// 重置表单
function resetForm() {
title.value = ''
description.value = ''
fileList.value = []
}
// 关闭时重置
function handleClose() {
resetForm()
}
</script>
<template>
<el-dialog
v-model="visible"
title="上传记忆"
width="480px"
@close="handleClose"
>
<div class="upload-form">
<div class="field">
<label>标题 *</label>
<el-input v-model="title" placeholder="给这段记忆起个名字" maxlength="100" />
</div>
<div class="field">
<label>描述</label>
<el-input
v-model="description"
type="textarea"
placeholder="简单描述一下(可选)"
:rows="3"
maxlength="500"
show-word-limit
/>
</div>
<div class="field">
<label>文件 *</label>
<el-upload
:auto-upload="false"
:limit="1"
:file-list="fileList"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:on-exceed="handleExceed"
drag
>
<div class="upload-trigger">
<el-icon class="upload-icon"><Plus /></el-icon>
<div>将文件拖到此处<em>点击上传</em></div>
</div>
</el-upload>
</div>
<!-- 存储信息 -->
<div class="storage-info" :class="{ warning: isStorageWarning }">
<div class="storage-bar">
<div
class="storage-bar-fill"
:class="{ 'bar-warning': isStorageWarning }"
:style="{ width: Math.min(storagePercent, 100) + '%' }"
/>
</div>
<div class="storage-text">
已用 {{ storageUsedGB }} GB / {{ storageLimitGB }} GB
<span v-if="isStorageWarning" class="warning-text">存储空间不足</span>
</div>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">上传</el-button>
</template>
</el-dialog>
</template>
<script lang="ts">
import { Plus } from '@element-plus/icons-vue'
export default { name: 'MemoryUploadDialog' }
</script>
<style scoped>
.upload-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 13px;
font-weight: 500;
color: var(--gg-text-secondary);
}
.upload-trigger {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--gg-text-secondary);
font-size: 13px;
}
.upload-trigger em {
color: var(--gg-primary);
font-style: normal;
}
.upload-icon {
font-size: 28px;
color: var(--gg-text-hint);
}
/* 存储信息 */
.storage-info {
padding: 10px 0 0;
}
.storage-bar {
height: 6px;
background: var(--gg-bg-secondary);
border-radius: 3px;
overflow: hidden;
}
.storage-bar-fill {
height: 100%;
background: var(--gg-primary);
border-radius: 3px;
transition: width 0.3s ease;
}
.storage-bar-fill.bar-warning {
background: var(--el-color-warning);
}
.storage-text {
margin-top: 6px;
font-size: 12px;
color: var(--gg-text-hint);
}
.warning-text {
color: var(--el-color-warning);
font-weight: 500;
}
.storage-info.warning .storage-bar {
background: var(--el-color-warning-light-9);
}
</style>
@@ -0,0 +1,284 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { createPoll } from '@/api/polls'
import { useGroupStore } from '@/stores/group'
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{
created: []
}>()
const groupStore = useGroupStore()
// 投票类型: option=选项投票, rollcall=接龙报名
const pollType = ref<'option' | 'rollcall'>('option')
const form = ref({
title: '',
options: ['', ''],
maxParticipants: 10,
deadline: '' as string | Date,
anonymous: false,
})
const loading = ref(false)
// 是否可以添加选项
const canAddOption = computed(() => form.value.options.length < 10)
function addOption() {
if (canAddOption.value) {
form.value.options.push('')
}
}
function removeOption(index: number) {
form.value.options.splice(index, 1)
}
function resetForm() {
pollType.value = 'option'
form.value = {
title: '',
options: ['', ''],
maxParticipants: 10,
deadline: '',
anonymous: false,
}
}
async function handleSubmit() {
// 标题必填
if (!form.value.title.trim()) {
ElMessage.warning('请输入投票标题')
return
}
// 选项投票模式: 至少2个非空选项
if (pollType.value === 'option') {
const nonEmpty = form.value.options.filter((o) => o.trim())
if (nonEmpty.length < 2) {
ElMessage.warning('选项投票至少需要2个非空选项')
return
}
}
const groupId = groupStore.currentGroupId
if (!groupId) {
ElMessage.error('请先选择群组')
return
}
loading.value = true
try {
// 接龙模式自动创建一个"报名参加"选项
const options =
pollType.value === 'rollcall'
? ['报名参加']
: form.value.options.filter((o) => o.trim())
await createPoll({
group: groupId,
title: form.value.title.trim(),
type: pollType.value,
anonymous: form.value.anonymous,
deadline: form.value.deadline ? new Date(String(form.value.deadline)).toISOString() : undefined,
maxParticipants: pollType.value === 'rollcall' ? form.value.maxParticipants : undefined,
options,
})
visible.value = false
resetForm()
ElMessage.success('投票创建成功')
emit('created')
} catch (error: any) {
ElMessage.error(error.message || '创建投票失败')
} finally {
loading.value = false
}
}
function handleOpen() {
resetForm()
}
</script>
<template>
<el-dialog
v-model="visible"
title="发起投票"
width="480px"
@open="handleOpen"
>
<div class="create-form">
<!-- 投票类型切换 -->
<div class="form-field">
<label>投票类型</label>
<div class="type-switch">
<button
:class="['type-btn', { active: pollType === 'option' }]"
@click="pollType = 'option'"
>
选项投票
</button>
<button
:class="['type-btn', { active: pollType === 'rollcall' }]"
@click="pollType = 'rollcall'"
>
接龙报名
</button>
</div>
</div>
<!-- 标题 -->
<div class="form-field">
<label>标题 <span class="required">*</span></label>
<el-input
v-model="form.title"
placeholder="请输入投票标题"
maxlength="100"
show-word-limit
/>
</div>
<!-- 选项列表仅选项投票模式 -->
<div v-if="pollType === 'option'" class="form-field">
<label>投票选项</label>
<div class="options-list">
<div
v-for="(_, index) in form.options"
:key="index"
class="option-item"
>
<el-input
v-model="form.options[index]"
:placeholder="`选项 ${index + 1}`"
maxlength="100"
/>
<el-button
v-if="form.options.length > 2"
type="danger"
text
@click="removeOption(index)"
>
删除
</el-button>
</div>
<el-button
v-if="canAddOption"
type="primary"
text
@click="addOption"
>
+ 添加选项
</el-button>
</div>
</div>
<!-- 人数上限仅接龙模式 -->
<div v-if="pollType === 'rollcall'" class="form-field">
<label>人数上限</label>
<el-input-number
v-model="form.maxParticipants"
:min="2"
:max="100"
/>
</div>
<!-- 截止时间 -->
<div class="form-field">
<label>截止时间</label>
<el-date-picker
v-model="form.deadline"
type="datetime"
placeholder="选择截止时间(可选)"
format="YYYY-MM-DD HH:mm"
style="width: 100%"
/>
</div>
<!-- 匿名开关 -->
<div class="form-field">
<label>匿名投票</label>
<el-switch v-model="form.anonymous" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
创建
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.create-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-field label {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.required {
color: var(--gg-danger);
}
.type-switch {
display: flex;
gap: 8px;
}
.type-btn {
flex: 1;
padding: 8px 16px;
border: 1px solid var(--gg-border, #dcdfe6);
border-radius: 6px;
background: var(--gg-bg, #fff);
color: var(--gg-text-secondary, #909399);
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.type-btn:hover {
border-color: var(--gg-primary, #67c23a);
color: var(--gg-primary, #67c23a);
}
.type-btn.active {
background: var(--gg-primary, #67c23a);
border-color: var(--gg-primary, #67c23a);
color: #fff;
}
.options-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-item {
display: flex;
align-items: center;
gap: 8px;
}
.option-item .el-input {
flex: 1;
}
</style>
@@ -0,0 +1,318 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { usePollStore } from '@/stores/poll'
import type { PollOption, PollVote } from '@/types'
const visible = defineModel<boolean>({ default: false })
const props = defineProps<{
pollId: string
pollType: 'option' | 'rollcall'
pollAnonymous: boolean
options: PollOption[]
votes: PollVote[]
}>()
const emit = defineEmits<{
saved: []
}>()
const pollStore = usePollStore()
interface EditOption {
id?: string
content: string
hasVotes: boolean
}
const form = ref({
title: '',
options: [] as EditOption[],
maxParticipants: 10 as number | null,
deadline: '' as string | Date,
})
const loading = ref(false)
// 哪些选项被标记删除
const deletedIds = ref<Set<string>>(new Set())
const activeOptions = computed(() =>
form.value.options.filter(o => !deletedIds.value.has(o.id || ''))
)
const canAddOption = computed(() => activeOptions.value.length < 10)
function initForm() {
deletedIds.value = new Set()
// 从 store 读取当前投票数据
const poll = pollStore.currentPoll
if (!poll) return
form.value.title = poll.title
form.value.maxParticipants = poll.maxParticipants || null
form.value.deadline = poll.deadline ? new Date(poll.deadline) : ''
// 获取每个选项是否有投票
const voteCounts: Record<string, number> = {}
for (const v of pollStore.currentVotes) {
voteCounts[v.option] = (voteCounts[v.option] || 0) + 1
}
form.value.options = pollStore.currentOptions.map(o => ({
id: o.id,
content: o.content,
hasVotes: (voteCounts[o.id] || 0) > 0,
}))
}
function addOption() {
if (!canAddOption.value) return
form.value.options.push({ content: '', hasVotes: false })
}
function removeOption(opt: EditOption) {
if (opt.id) {
deletedIds.value.add(opt.id)
} else {
const idx = form.value.options.indexOf(opt)
if (idx !== -1) form.value.options.splice(idx, 1)
}
}
async function handleSubmit() {
if (!form.value.title.trim()) {
ElMessage.warning('请输入投票标题')
return
}
const kept = activeOptions.value.filter(o => o.content.trim())
if (props.pollType === 'option' && kept.length < 2) {
ElMessage.warning('选项投票至少需要2个非空选项')
return
}
loading.value = true
try {
await pollStore.edit(props.pollId, {
title: form.value.title.trim(),
deadline: form.value.deadline ? new Date(form.value.deadline).toISOString() : '',
maxParticipants: props.pollType === 'rollcall' ? form.value.maxParticipants : null,
options: activeOptions.value.map(o => ({
id: o.id,
content: o.content.trim(),
hasVotes: o.hasVotes,
})),
deletedOptionIds: Array.from(deletedIds.value),
})
visible.value = false
ElMessage.success('投票已更新')
emit('saved')
} catch (error: any) {
ElMessage.error(error.message || '更新投票失败')
} finally {
loading.value = false
}
}
</script>
<template>
<el-dialog
v-model="visible"
title="编辑投票"
width="480px"
@open="initForm"
>
<div class="edit-form">
<!-- 投票类型只读 -->
<div class="form-field">
<label>投票类型</label>
<div class="type-readonly">
{{ pollType === 'option' ? '选项投票' : '接龙报名' }}
</div>
</div>
<!-- 标题 -->
<div class="form-field">
<label>标题 <span class="required">*</span></label>
<el-input
v-model="form.title"
placeholder="请输入投票标题"
maxlength="100"
show-word-limit
/>
</div>
<!-- 选项列表仅选项投票模式 -->
<div v-if="pollType === 'option'" class="form-field">
<label>投票选项</label>
<div class="options-list">
<div
v-for="opt in form.options"
:key="opt.id || opt.content"
v-show="!deletedIds.has(opt.id || '')"
class="option-item"
>
<el-input
v-model="opt.content"
placeholder="选项内容"
maxlength="100"
/>
<el-button
v-if="opt.hasVotes"
type="info"
text
disabled
title="已有投票,不可删除"
>
已投票
</el-button>
<el-button
v-else-if="activeOptions.length > 2"
type="danger"
text
@click="removeOption(opt)"
>
删除
</el-button>
</div>
<button
v-if="canAddOption"
class="add-option-btn"
@click="addOption"
>
<el-icon><Plus /></el-icon> 添加选项
</button>
</div>
</div>
<!-- 人数上限仅接龙模式 -->
<div v-if="pollType === 'rollcall'" class="form-field">
<label>人数上限</label>
<el-input-number
v-model="form.maxParticipants"
:min="2"
:max="100"
/>
</div>
<!-- 截止时间 -->
<div class="form-field">
<label>截止时间</label>
<el-date-picker
v-model="form.deadline"
type="datetime"
placeholder="选择截止时间(可选)"
format="YYYY-MM-DD HH:mm"
style="width: 100%"
/>
</div>
<!-- 匿名只读 -->
<div class="form-field">
<label>匿名投票</label>
<div class="type-readonly">{{ pollAnonymous ? '是' : '否' }}</div>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<button class="save-btn" :disabled="loading" @click="handleSubmit">
{{ loading ? '保存中...' : '保存' }}
</button>
</template>
</el-dialog>
</template>
<style scoped>
.edit-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-field label {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.required {
color: var(--gg-danger);
}
.type-readonly {
padding: 8px 16px;
border: 1px solid var(--gg-border);
border-radius: 6px;
background: var(--gg-bg-elevated);
color: var(--gg-text-secondary);
font-size: 14px;
}
.options-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-item {
display: flex;
align-items: center;
gap: 8px;
}
.option-item .el-input {
flex: 1;
}
.add-option-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 7px 16px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient-green);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.add-option-btn:hover {
opacity: 0.85;
}
.save-btn {
padding: 8px 20px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient-green);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.save-btn:hover {
opacity: 0.85;
}
.save-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
+322
View File
@@ -0,0 +1,322 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Poll, PollOption, PollVote } from '@/types'
const props = defineProps<{
poll: Poll
options: PollOption[]
votes: PollVote[]
currentUserVote: string | null
}>()
const emit = defineEmits<{
click: []
}>()
// 投票类型标签
const typeLabel = computed(() => {
return props.poll.type === 'option' ? '选项投票' : '接龙报名'
})
// 状态标签
const statusLabel = computed(() => {
return props.poll.status === 'active' ? '进行中' : '已结束'
})
// 是否进行中
const isActive = computed(() => props.poll.status === 'active')
// 总投票人数(去重)
const totalVotes = computed(() => {
const uniqueUsers = new Set(props.votes.map(v => v.user))
return uniqueUsers.size
})
// 各选项得票数
const optionVoteCounts = computed(() => {
const counts: Record<string, number> = {}
for (const vote of props.votes) {
counts[vote.option] = (counts[vote.option] || 0) + 1
}
return counts
})
// 领先选项(最高票)
const topOption = computed(() => {
if (props.options.length === 0 || props.votes.length === 0) return null
let topId = ''
let topCount = 0
for (const [optionId, count] of Object.entries(optionVoteCounts.value)) {
if (count > topCount) {
topCount = count
topId = optionId
}
}
const option = props.options.find(o => o.id === topId)
return option ? { content: option.content, count: topCount } : null
})
// 截止时间倒计时
const deadlineText = computed(() => {
if (!props.poll.deadline) return null
const deadline = new Date(props.poll.deadline).getTime()
const now = Date.now()
const diff = deadline - now
if (diff <= 0) return '已截止'
const minutes = Math.floor(diff / (1000 * 60))
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}天后截止`
if (hours > 0) return `${hours}小时后截止`
if (minutes > 0) return `${minutes}分钟后截止`
return '即将截止'
})
</script>
<template>
<div class="poll-card" @click="emit('click')">
<!-- 顶部标签行 -->
<div class="poll-card__tags">
<span class="poll-card__type-tag" :class="`poll-card__type-tag--${poll.type}`">
{{ typeLabel }}
</span>
<span class="poll-card__status-tag" :class="isActive ? 'poll-card__status-tag--active' : 'poll-card__status-tag--settled'">
{{ statusLabel }}
</span>
<span v-if="poll.anonymous" class="poll-card__anon-tag">
匿名
</span>
<span v-if="currentUserVote" class="poll-card__voted-badge">
已参与
</span>
</div>
<!-- 标题 -->
<div class="poll-card__title">
{{ poll.title }}
</div>
<!-- 统计信息 -->
<div class="poll-card__stats">
<span class="poll-card__stat">
<svg class="poll-card__stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
{{ totalVotes }} 人参与
</span>
<span v-if="poll.maxParticipants" class="poll-card__stat">
<svg class="poll-card__stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="9" y1="9" x2="15" y2="15" />
<line x1="15" y1="9" x2="9" y2="15" />
</svg>
{{ poll.maxParticipants }}
</span>
</div>
<!-- 领先选项 -->
<div v-if="topOption" class="poll-card__top-option">
<span class="poll-card__top-label">领先</span>
<span class="poll-card__top-content">{{ topOption.content }}</span>
<span class="poll-card__top-count">{{ topOption.count }} </span>
</div>
<!-- 底部截止时间 -->
<div v-if="deadlineText" class="poll-card__footer">
<svg class="poll-card__footer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span :class="{ 'poll-card__deadline--urgent': deadlineText === '即将截止' }">
{{ deadlineText }}
</span>
</div>
</div>
</template>
<style scoped>
.poll-card {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
padding: 16px 18px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
}
.poll-card:hover {
border-color: var(--gg-primary-light);
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
transform: translateY(-1px);
}
/* 标签行 */
.poll-card__tags {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.poll-card__type-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
}
.poll-card__type-tag--option {
background: rgba(5, 150, 105, 0.1);
color: var(--gg-primary);
}
.poll-card__type-tag--rollcall {
background: rgba(13, 148, 136, 0.1);
color: var(--gg-accent);
}
.poll-card__status-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
}
.poll-card__status-tag--active {
background: rgba(16, 185, 129, 0.1);
color: var(--gg-success);
}
.poll-card__status-tag--settled {
background: var(--gg-bg-elevated);
color: var(--gg-text-muted);
}
.poll-card__anon-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
line-height: 18px;
background: rgba(245, 158, 11, 0.1);
color: var(--gg-warning);
}
.poll-card__voted-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
background: rgba(5, 150, 105, 0.15);
color: var(--gg-primary);
}
/* 标题 */
.poll-card__title {
font-size: 15px;
font-weight: 600;
color: var(--gg-text);
line-height: 1.5;
margin-bottom: 10px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* 统计信息 */
.poll-card__stats {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 8px;
}
.poll-card__stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--gg-text-secondary);
}
.poll-card__stat-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
/* 领先选项 */
.poll-card__top-option {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(5, 150, 105, 0.06);
border-radius: 6px;
margin-bottom: 8px;
}
.poll-card__top-label {
font-size: 11px;
font-weight: 600;
color: var(--gg-primary);
white-space: nowrap;
}
.poll-card__top-content {
font-size: 13px;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.poll-card__top-count {
font-size: 12px;
color: var(--gg-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
/* 底部 */
.poll-card__footer {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--gg-text-muted);
}
.poll-card__footer-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.poll-card__deadline--urgent {
color: var(--gg-danger);
font-weight: 600;
}
</style>
+460
View File
@@ -0,0 +1,460 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Edit } from '@element-plus/icons-vue'
import { usePollStore } from '@/stores/poll'
import { pb } from '@/api/pocketbase'
import { subscribePollVotes } from '@/api/polls'
import { displayName } from '@/types'
import EditPollDialog from './EditPollDialog.vue'
const props = defineProps<{
pollId: string
}>()
const emit = defineEmits<{
back: []
}>()
const pollStore = usePollStore()
let unsubscribeFn: (() => void) | null = null
const showEdit = ref(false)
// 每选项的投票数
const optionVoteCounts = computed(() => {
const counts: Record<string, number> = {}
for (const vote of pollStore.currentVotes) {
counts[vote.option] = (counts[vote.option] || 0) + 1
}
return counts
})
// 总投票人数(去重)
const totalVotes = computed(() => {
const users = new Set(pollStore.currentVotes.map((v) => v.user))
return users.size
})
// 接龙是否满员
const isRollcallFull = computed(() => {
const poll = pollStore.currentPoll
if (!poll || poll.type !== 'rollcall') return false
if (!poll.maxParticipants) return false
return totalVotes.value >= poll.maxParticipants
})
// 当前用户是否是发起人
const isCreator = computed(() => {
return pollStore.currentPoll?.creator === pb.authStore.model?.id
})
// 当前用户已投的选项ID集合
const votedOptionIds = computed(() => {
const user = pb.authStore.model
if (!user) return new Set<string>()
return new Set(
pollStore.currentVotes
.filter((v) => v.user === user.id)
.map((v) => v.option)
)
})
// 当前用户是否已投票
const hasVoted = computed(() => votedOptionIds.value.size > 0)
// 投票是否已结束
const isSettled = computed(() => pollStore.currentPoll?.status === 'settled')
// 获取某个选项的投票人列表
function getVotersForOption(optionId: string) {
return pollStore.currentVotes.filter((v) => v.option === optionId)
}
// 判断选项是否可点击
function canVote(_optionId: string) {
if (isSettled.value) return false
if (hasVoted.value) return false
if (isRollcallFull.value) return false
return true
}
// 点击选项投票
async function handleVote(optionId: string) {
if (!canVote(optionId)) return
try {
await pollStore.vote(props.pollId, optionId)
} catch (error: any) {
ElMessage.error(error.message || '投票失败')
}
}
// 取消投票
async function handleCancelVote() {
try {
await ElMessageBox.confirm('确定要取消投票吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await pollStore.unvote(props.pollId)
ElMessage.success('已取消投票')
} catch {
// 用户取消操作
}
}
// 结束投票
async function handleSettle() {
try {
await ElMessageBox.confirm('确定要结束投票吗?结束后将无法再投票。', '结束投票', {
confirmButtonText: '确定结束',
cancelButtonText: '取消',
type: 'warning',
})
await pollStore.settle(props.pollId)
ElMessage.success('投票已结束')
} catch {
// 用户取消操作
}
}
onMounted(async () => {
try {
await pollStore.loadPollDetail(props.pollId)
// 订阅投票记录变更
unsubscribeFn = await subscribePollVotes(props.pollId, () => {
pollStore.loadPollDetail(props.pollId)
})
} catch (error) {
console.error('加载投票详情失败:', error)
}
})
onUnmounted(() => {
if (unsubscribeFn) {
unsubscribeFn()
unsubscribeFn = null
}
pollStore.clearCurrent()
})
</script>
<template>
<div class="poll-detail">
<!-- 顶部操作栏 -->
<div class="detail-header">
<el-button text @click="emit('back')">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<div class="detail-header-actions">
<button
v-if="isCreator && !isSettled"
class="action-btn action-btn--edit"
@click="showEdit = true"
>
<el-icon><Edit /></el-icon> 编辑
</button>
<button
v-if="isCreator && !isSettled"
class="action-btn action-btn--settle"
@click="handleSettle"
>
结束投票
</button>
</div>
</div>
<!-- 加载中 -->
<div v-if="pollStore.loading" class="loading-state">
<el-icon class="is-loading"><ArrowLeft /></el-icon>
<span>加载中...</span>
</div>
<!-- 投票内容 -->
<template v-else-if="pollStore.currentPoll">
<!-- 标题区域 -->
<div class="poll-title-area">
<h2 class="poll-title">{{ pollStore.currentPoll.title }}</h2>
<div class="poll-meta">
<span v-if="pollStore.currentPoll.type === 'rollcall'" class="poll-type-tag rollcall">
接龙报名
</span>
<span v-else class="poll-type-tag option">选项投票</span>
<span v-if="pollStore.currentPoll.anonymous" class="poll-type-tag anonymous">
匿名
</span>
<span class="poll-info">
{{ totalVotes }} 人参与
</span>
<span
v-if="pollStore.currentPoll.type === 'rollcall' && pollStore.currentPoll.maxParticipants"
class="poll-info"
>
{{ totalVotes }} / {{ pollStore.currentPoll.maxParticipants }}
</span>
</div>
</div>
<!-- 选项列表 -->
<div class="options-area">
<div
v-for="option in pollStore.currentOptions"
:key="option.id"
:class="[
'option-card',
{
voted: votedOptionIds.has(option.id),
disabled: !canVote(option.id),
},
]"
@click="handleVote(option.id)"
>
<div class="option-header">
<span class="option-text">{{ option.content }}</span>
<span class="option-count">{{ optionVoteCounts[option.id] || 0 }} </span>
</div>
<!-- 进度条 -->
<div class="option-progress">
<div
class="progress-bar"
:style="{
width: totalVotes > 0
? ((optionVoteCounts[option.id] || 0) / totalVotes * 100) + '%'
: '0%',
}"
/>
</div>
<!-- 投票人列表非匿名时 -->
<div
v-if="!pollStore.currentPoll.anonymous && getVotersForOption(option.id).length > 0"
class="voter-list"
>
<span
v-for="vote in getVotersForOption(option.id)"
:key="vote.id"
class="voter-name"
>
{{ displayName(vote.expand?.user) }}
</span>
</div>
</div>
</div>
<!-- 底部操作 -->
<div v-if="hasVoted && !isSettled" class="detail-footer">
<el-button type="warning" text @click="handleCancelVote">
取消投票
</el-button>
</div>
</template>
<EditPollDialog
v-model="showEdit"
:poll-id="pollId"
:poll-type="pollStore.currentPoll?.type || 'option'"
:poll-anonymous="pollStore.currentPoll?.anonymous || false"
:options="pollStore.currentOptions"
:votes="pollStore.currentVotes"
@saved="pollStore.loadPollDetail(pollId)"
/>
</div>
</template>
<style scoped>
.poll-detail {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-header-actions {
display: flex;
gap: 8px;
}
.action-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 14px;
border: none;
border-radius: var(--gg-radius-sm);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.action-btn--edit {
background: var(--gg-gradient-green);
color: #fff;
}
.action-btn--edit:hover {
opacity: 0.85;
}
.action-btn--settle {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
color: var(--gg-text-secondary);
}
.action-btn--settle:hover {
border-color: var(--gg-danger);
color: var(--gg-danger);
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px 0;
color: var(--gg-text-secondary, #909399);
}
.poll-title-area {
display: flex;
flex-direction: column;
gap: 8px;
}
.poll-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--gg-text);
}
.poll-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.poll-type-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.poll-type-tag.option {
background: rgba(103, 194, 58, 0.1);
color: var(--gg-primary, #67c23a);
}
.poll-type-tag.rollcall {
background: rgba(64, 158, 255, 0.1);
color: #409eff;
}
.poll-type-tag.anonymous {
background: rgba(144, 147, 153, 0.1);
color: var(--gg-text-secondary, #909399);
}
.poll-info {
font-size: 13px;
color: var(--gg-text-secondary, #909399);
}
.options-area {
display: flex;
flex-direction: column;
gap: 10px;
}
.option-card {
padding: 12px 16px;
border: 1px solid var(--gg-border, #ebeef5);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.option-card:hover:not(.disabled) {
border-color: var(--gg-primary, #67c23a);
background: rgba(103, 194, 58, 0.03);
}
.option-card.voted {
border-color: var(--gg-primary, #67c23a);
background: rgba(103, 194, 58, 0.06);
}
.option-card.disabled {
cursor: default;
opacity: 0.75;
}
.option-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.option-text {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.option-count {
font-size: 13px;
color: var(--gg-text-secondary, #909399);
}
.option-progress {
height: 4px;
background: var(--gg-bg-page, #f5f7fa);
border-radius: 2px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--gg-primary, #67c23a);
border-radius: 2px;
transition: width 0.3s ease;
}
.voted .progress-bar {
background: var(--gg-primary, #67c23a);
}
.voter-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.voter-name {
font-size: 12px;
padding: 2px 6px;
background: var(--gg-bg-page, #f5f7fa);
border-radius: 4px;
color: var(--gg-text-secondary, #909399);
}
.detail-footer {
display: flex;
justify-content: center;
padding-top: 8px;
}
</style>
+318
View File
@@ -0,0 +1,318 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { usePollStore } from '@/stores/poll'
import { useGroupStore } from '@/stores/group'
import { subscribePolls, getPollOptions, getPollVotes, getUserVote } from '@/api/polls'
import PollCard from './PollCard.vue'
import CreatePollDialog from './CreatePollDialog.vue'
import type { PollOption, PollVote } from '@/types'
const emit = defineEmits<{
viewPoll: [pollId: string]
}>()
const pollStore = usePollStore()
const groupStore = useGroupStore()
const showCreate = ref(false)
let unsubscribeFn: (() => void) | null = null
// 缓存每个 poll 的 options / votes / userVote
interface PollExtra {
options: PollOption[]
votes: PollVote[]
userVote: string | null
}
const pollExtras = ref<Record<string, PollExtra>>({})
// 加载所有投票及其详情
async function loadAll() {
const groupId = groupStore.currentGroupId
if (!groupId) return
await pollStore.loadPolls(groupId)
// 并行加载每条 poll 的 options / votes / userVote
const allPolls = [...pollStore.activePolls, ...pollStore.settledPolls]
const results = await Promise.allSettled(
allPolls.map(async (poll) => {
const [options, votes, userVote] = await Promise.all([
getPollOptions(poll.id),
getPollVotes(poll.id),
getUserVote(poll.id),
])
return {
pollId: poll.id,
options,
votes,
userVote: userVote?.option ?? null,
}
})
)
const extras: Record<string, PollExtra> = {}
for (const r of results) {
if (r.status === 'fulfilled') {
extras[r.value.pollId] = {
options: r.value.options,
votes: r.value.votes,
userVote: r.value.userVote,
}
}
}
pollExtras.value = extras
}
// 实时订阅投票变更
async function startSubscription() {
const groupId = groupStore.currentGroupId
if (!groupId) return
unsubscribeFn = await subscribePolls(groupId, () => {
loadAll()
})
}
function stopSubscription() {
if (unsubscribeFn) {
unsubscribeFn()
unsubscribeFn = null
}
}
// 获取某个 poll 的缓存 extras
function getExtra(pollId: string): PollExtra {
return pollExtras.value[pollId] || { options: [], votes: [], userVote: null }
}
// 分栏数据
const activePolls = computed(() => pollStore.activePolls)
const settledPolls = computed(() => pollStore.settledPolls)
onMounted(() => {
if (groupStore.currentGroupId) {
loadAll()
startSubscription()
}
})
watch(() => groupStore.currentGroupId, (newId, oldId) => {
if (newId && newId !== oldId) {
loadAll()
if (!unsubscribeFn) startSubscription()
}
})
onUnmounted(() => {
stopSubscription()
})
</script>
<template>
<div class="poll-list">
<!-- 顶部操作栏 -->
<div class="poll-list__header">
<h3 class="poll-list__title">投票</h3>
<button class="poll-list__create-btn" @click="showCreate = true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="poll-list__create-icon">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
发起投票
</button>
</div>
<CreatePollDialog
v-model="showCreate"
@created="loadAll(); showCreate = false"
/>
<!-- 进行中 -->
<section v-if="activePolls.length > 0" class="poll-list__section">
<div class="poll-list__section-header">
<span class="poll-list__section-dot poll-list__section-dot--active"></span>
<span class="poll-list__section-label">进行中</span>
<span class="poll-list__section-count">{{ activePolls.length }}</span>
</div>
<div class="poll-list__grid">
<PollCard
v-for="poll in activePolls"
:key="poll.id"
:poll="poll"
:options="getExtra(poll.id).options"
:votes="getExtra(poll.id).votes"
:current-user-vote="getExtra(poll.id).userVote"
@click="emit('viewPoll', poll.id)"
/>
</div>
</section>
<!-- 已结束 -->
<section v-if="settledPolls.length > 0" class="poll-list__section">
<div class="poll-list__section-header">
<span class="poll-list__section-dot poll-list__section-dot--settled"></span>
<span class="poll-list__section-label">已结束</span>
<span class="poll-list__section-count">{{ settledPolls.length }}</span>
</div>
<div class="poll-list__grid">
<PollCard
v-for="poll in settledPolls"
:key="poll.id"
:poll="poll"
:options="getExtra(poll.id).options"
:votes="getExtra(poll.id).votes"
:current-user-vote="getExtra(poll.id).userVote"
@click="emit('viewPoll', poll.id)"
/>
</div>
</section>
<!-- 空状态 -->
<div
v-if="activePolls.length === 0 && settledPolls.length === 0 && !pollStore.loading"
class="poll-list__empty"
>
<svg class="poll-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="9" y1="9" x2="15" y2="9" />
<line x1="9" y1="13" x2="13" y2="13" />
</svg>
<p class="poll-list__empty-text">暂无投票</p>
<p class="poll-list__empty-hint">群组内还没有发起过投票</p>
</div>
</div>
</template>
<style scoped>
.poll-list {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 顶部操作栏 */
.poll-list__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.poll-list__title {
font-size: 18px;
font-weight: 700;
color: var(--gg-text);
margin: 0;
}
.poll-list__create-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient-green);
color: #ffffff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.poll-list__create-btn:hover {
opacity: 0.85;
}
.poll-list__create-icon {
width: 16px;
height: 16px;
}
/* 分栏 */
.poll-list__section {
display: flex;
flex-direction: column;
gap: 12px;
}
.poll-list__section-header {
display: flex;
align-items: center;
gap: 6px;
}
.poll-list__section-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.poll-list__section-dot--active {
background: var(--gg-success);
box-shadow: 0 0 6px var(--gg-success);
}
.poll-list__section-dot--settled {
background: var(--gg-text-muted);
}
.poll-list__section-label {
font-size: 14px;
font-weight: 600;
color: var(--gg-text-secondary);
}
.poll-list__section-count {
font-size: 12px;
color: var(--gg-text-muted);
background: var(--gg-bg-elevated);
padding: 1px 7px;
border-radius: 10px;
}
/* 网格布局 */
.poll-list__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
/* 空状态 */
.poll-list__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
text-align: center;
}
.poll-list__empty-icon {
width: 48px;
height: 48px;
color: var(--gg-text-muted);
margin-bottom: 12px;
opacity: 0.5;
}
.poll-list__empty-text {
font-size: 15px;
font-weight: 600;
color: var(--gg-text-secondary);
margin: 0 0 4px;
}
.poll-list__empty-hint {
font-size: 13px;
color: var(--gg-text-muted);
margin: 0;
}
/* 响应式 */
@media (max-width: 640px) {
.poll-list__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,312 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useGroupStore } from '@/stores/group'
import { pb } from '@/api/pocketbase'
import { displayName } from '@/types'
import { TrendCharts, Trophy } from '@element-plus/icons-vue'
const groupStore = useGroupStore()
const weekSessionCount = ref(0)
const pollParticipationRate = ref('0%')
const topMembers = ref<{ name: string; points: number }[]>([])
const loading = ref(true)
async function loadStats() {
const groupId = groupStore.currentGroupId
if (!groupId) return
loading.value = true
try {
// 查询本周组队次数
const sevenDaysAgo = new Date()
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
const sessionResult = await pb.collection('team_sessions').getList(1, 1, {
filter: `sourceGroup="${groupId}" && created>="${sevenDaysAgo.toISOString()}"`,
$autoCancel: false,
})
weekSessionCount.value = sessionResult.totalItems
// 查询投票参与率(已投票人数 / 群成员数)
const memberCount = groupStore.currentMembers.length || 1
const memberIds = groupStore.currentMembers.map(m => m.id)
const pollResult = await pb.collection('polls').getFullList({
filter: `group="${groupId}" && status="active"`,
$autoCancel: false,
})
let votedUserCount = 0
if (pollResult.length > 0 && memberIds.length > 0) {
const pollIds = (pollResult as any[]).map(p => p.id)
const pollFilter = pollIds.map(id => `poll="${id}"`).join(' || ')
const votes = await pb.collection('poll_votes').getFullList({
filter: `(${pollFilter})`,
$autoCancel: false,
})
const votedUsers = new Set((votes as any[]).map(v => v.user))
votedUserCount = votedUsers.size
}
const rate = memberCount > 0 ? Math.round((votedUserCount * 100) / memberCount) : 0
pollParticipationRate.value = `${rate}%`
// 群成员积分排行 top 10
const sorted = [...groupStore.currentMembers]
.sort((a, b) => (b.points || 0) - (a.points || 0))
.slice(0, 10)
topMembers.value = sorted.map(m => ({
name: displayName(m),
points: m.points || 0,
}))
} catch (error) {
console.error('加载统计数据失败:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
if (groupStore.currentGroupId) loadStats()
})
watch(() => groupStore.currentGroupId, (newId, oldId) => {
if (newId && newId !== oldId) loadStats()
})
</script>
<template>
<div class="stats-panel">
<!-- 加载状态 -->
<div v-if="loading" class="stats-loading">
<el-icon class="is-loading" :size="20"><TrendCharts /></el-icon>
<span>加载统计数据中...</span>
</div>
<template v-else>
<!-- 统计卡片 -->
<div class="stats-cards">
<!-- 本周组队 -->
<div class="stat-card">
<div class="stat-card-icon">
<el-icon :size="24"><TrendCharts /></el-icon>
</div>
<div class="stat-card-body">
<div class="stat-card-value">{{ weekSessionCount }}</div>
<div class="stat-card-label">本周组队</div>
</div>
</div>
<!-- 投票参与率 -->
<div class="stat-card">
<div class="stat-card-icon icon-poll">
<el-icon :size="24"><TrendCharts /></el-icon>
</div>
<div class="stat-card-body">
<div class="stat-card-value">{{ pollParticipationRate }}</div>
<div class="stat-card-label">投票参与率</div>
</div>
</div>
<!-- 群成员 -->
<div class="stat-card">
<div class="stat-card-icon icon-members">
<el-icon :size="24"><TrendCharts /></el-icon>
</div>
<div class="stat-card-body">
<div class="stat-card-value">{{ groupStore.currentMembers.length }}</div>
<div class="stat-card-label">群成员</div>
</div>
</div>
</div>
<!-- 活跃排行 -->
<div class="ranking-section">
<div class="ranking-header">
<el-icon><Trophy /></el-icon>
<span>积分排行</span>
</div>
<div v-if="topMembers.length === 0" class="ranking-empty">暂无排行数据</div>
<div v-else class="ranking-list">
<div
v-for="(member, index) in topMembers"
:key="index"
class="ranking-item"
>
<span class="ranking-index" :class="{ 'top-three': index < 3 }">
{{ index + 1 }}
</span>
<span class="ranking-name">{{ member.name }}</span>
<span class="ranking-points">{{ member.points }} </span>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.stats-panel {
display: flex;
flex-direction: column;
gap: 24px;
}
.stats-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 48px 0;
color: var(--gg-text-secondary);
font-size: 14px;
}
/* 统计卡片 */
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.stat-card {
display: flex;
align-items: center;
gap: 14px;
padding: 20px;
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
transition: box-shadow 0.2s;
}
.stat-card:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.stat-card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(5, 150, 105, 0.12);
color: var(--gg-primary);
flex-shrink: 0;
}
.stat-card-icon.icon-poll {
background: rgba(64, 158, 255, 0.12);
color: #409eff;
}
.stat-card-icon.icon-members {
background: rgba(230, 162, 60, 0.12);
color: #e6a23c;
}
.stat-card-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-card-value {
font-size: 24px;
font-weight: 700;
color: var(--gg-text);
line-height: 1.2;
}
.stat-card-label {
font-size: 13px;
color: var(--gg-text-muted);
}
/* 排行 */
.ranking-section {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
padding: 20px;
}
.ranking-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: var(--gg-text);
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--gg-border);
}
.ranking-empty {
text-align: center;
padding: 24px 0;
color: var(--gg-text-muted);
font-size: 14px;
}
.ranking-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.ranking-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: var(--gg-radius-sm);
background: var(--gg-bg);
transition: background 0.2s;
}
.ranking-item:hover {
background: var(--gg-bg-elevated);
}
.ranking-index {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
font-size: 13px;
font-weight: 600;
color: var(--gg-text-muted);
background: var(--gg-bg-elevated);
flex-shrink: 0;
}
.ranking-index.top-three {
background: rgba(5, 150, 105, 0.15);
color: var(--gg-primary);
}
.ranking-name {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ranking-points {
font-size: 13px;
font-weight: 600;
color: var(--gg-primary-light);
}
/* 响应式 */
@media (max-width: 640px) {
.stats-cards {
grid-template-columns: 1fr;
}
}
</style>
+82
View File
@@ -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<Memory[]>([])
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
}
})
+70 -2
View File
@@ -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> | void) | null = null
let unsubJoinFn: (() => Promise<void> | void) | null = null
let unsubAppFn: (() => Promise<void> | void) | null = null
// 站内应用通知
const appNotifications = ref<AppNotification[]>([])
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
}
})
+161
View File
@@ -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<Poll[]>([])
const settledPolls = ref<Poll[]>([])
const currentPoll = ref<Poll | null>(null)
const currentOptions = ref<PollOption[]>([])
const currentVotes = ref<PollVote[]>([])
const currentUserVote = ref<PollVote | null>(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,
}
})
+83 -7
View File
@@ -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 || '未知'
}
+18
View File
@@ -10,6 +10,24 @@ interface LogEntry {
}
const logs = ref<LogEntry[]>([
{
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',
+123 -4
View File
@@ -1,20 +1,30 @@
<!-- src/views/GroupView.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, computed } from 'vue'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useGroupStore } from '@/stores/group'
import { useTeamStore } from '@/stores/team'
import { pb } from '@/api/pocketbase'
import { settlePoll } from '@/api/polls'
import TeamSessionPanel from '@/components/team/TeamSessionPanel.vue'
import IdleMembersList from '@/components/team/IdleMembersList.vue'
import GroupMembersPanel from '@/components/group/GroupMembersPanel.vue'
import PollList from '@/components/poll/PollList.vue'
import PollDetail from '@/components/poll/PollDetail.vue'
import MemoryGrid from '@/components/memory/MemoryGrid.vue'
import GroupStatsPanel from '@/components/stats/GroupStatsPanel.vue'
import { Promotion, Opportunity, UserFilled } from '@element-plus/icons-vue'
import { DataLine, PictureFilled, TrendCharts } from '@element-plus/icons-vue'
const route = useRoute()
const groupStore = useGroupStore()
const teamStore = useTeamStore()
const activeTab = ref('activity')
const viewingPollId = ref<string | null>(null)
const unsubFns: (() => Promise<void>)[] = []
let settleTimer: ReturnType<typeof setInterval> | null = null
const groupId = route.params.id as string
@@ -77,6 +87,9 @@ onMounted(async () => {
unsubFns.push(await pb.collection('team_sessions').subscribe('*', () => {
teamStore.loadActiveSession()
}))
// 投票结算定时器:每 60 秒检查过期投票
settleTimer = setInterval(checkExpiredPolls, 60000)
})
onUnmounted(async () => {
@@ -84,11 +97,42 @@ onUnmounted(async () => {
try { await unsub() } catch (_) {}
}
unsubFns.length = 0
if (settleTimer) {
clearInterval(settleTimer)
settleTimer = null
}
})
async function refreshMembers() {
await groupStore.setCurrentGroup(groupId)
}
async function checkExpiredPolls() {
const currentGroupId = groupStore.currentGroupId
const user = pb.authStore.model
if (!currentGroupId || !user) return
try {
// 优化:只有当前登录用户是投票发起人时,才主动去结算自己发起的投票
// 这避免了群里有 10 个成员在线时,10 个客户端同时发起请求的问题
const result = await pb.collection('polls').getList(1, 50, {
filter: `group="${currentGroupId}" && status="active" && deadline!="" && creator="${user.id}"`,
$autoCancel: false
})
const now = new Date()
for (const poll of result.items as any[]) {
if (poll.deadline && new Date(poll.deadline) <= now) {
try {
await settlePoll(poll.id)
} catch (e) {
console.error('结算投票失败:', poll.id, e)
}
}
}
} catch (e) {
console.error('检查过期投票失败:', e)
}
}
</script>
<template>
@@ -113,8 +157,14 @@ async function refreshMembers() {
</div>
</section>
<!-- 主体双栏 -->
<div class="body-grid">
<!-- Tab 系统 -->
<el-tabs v-model="activeTab" class="group-tabs group-tabs--fancy">
<el-tab-pane name="activity">
<template #label>
<span class="fancy-tab"><el-icon><Promotion /></el-icon> 动态</span>
</template>
<!-- 主体双栏 -->
<div class="body-grid">
<!-- 左列: 主内容 -->
<div class="left-col">
<!-- 当前临时小组 -->
@@ -169,7 +219,31 @@ async function refreshMembers() {
<aside class="right-col">
<GroupMembersPanel />
</aside>
</div>
</div>
</el-tab-pane>
<el-tab-pane name="polls">
<template #label>
<span class="fancy-tab"><el-icon><DataLine /></el-icon> 投票</span>
</template>
<PollDetail v-if="viewingPollId" :poll-id="viewingPollId" @back="viewingPollId = null" />
<PollList v-else @view-poll="viewingPollId = $event" />
</el-tab-pane>
<el-tab-pane name="memories">
<template #label>
<span class="fancy-tab"><el-icon><PictureFilled /></el-icon> 回忆</span>
</template>
<MemoryGrid />
</el-tab-pane>
<el-tab-pane name="stats">
<template #label>
<span class="fancy-tab"><el-icon><TrendCharts /></el-icon> 统计</span>
</template>
<GroupStatsPanel />
</el-tab-pane>
</el-tabs>
</div>
</template>
@@ -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;
}
</style>