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:
@@ -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
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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('*')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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 || '未知'
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user